Compare commits
35 Commits
b2ae37637d
...
v0.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebe768711b | ||
|
|
af45f93854 | ||
|
|
da1d74309e | ||
|
|
7a4d7b6915 | ||
|
|
ab7031e6ed | ||
|
|
cf3cec0164 | ||
|
|
79762c3f0d | ||
|
|
715cbc1894 | ||
|
|
dd398178f0 | ||
|
|
8b0d473fcd | ||
|
|
30e9b55379 | ||
|
|
3091754b0f | ||
|
|
26de222884 | ||
|
|
2f2f93f37e | ||
|
|
1b9a3b84a0 | ||
|
|
c77de4a232 | ||
|
|
15b8c09e17 | ||
|
|
77e87504d6 | ||
|
|
d8a21f0724 | ||
|
|
4a91ca0774 | ||
|
|
52c22f1eb9 | ||
|
|
a517785050 | ||
|
|
474738a894 | ||
|
|
41397ae067 | ||
|
|
dd91a4989b | ||
|
|
f06f5f2bb1 | ||
|
|
c8caf3dc44 | ||
|
|
2de10f6eb0 | ||
|
|
e2c0f203f9 | ||
|
|
a383b9bcf4 | ||
|
|
6aeba1fe83 | ||
|
|
7a1625c297 | ||
|
|
9d2d87e7e1 | ||
|
|
b5c19b6774 | ||
|
|
213aa86c47 |
@@ -36,7 +36,7 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
|
||||
- Spring Boot 3.4.3 parent POM
|
||||
- Depends on `com.cameleer3:cameleer3-common` from Gitea Maven registry
|
||||
- Jackson `JavaTimeModule` for `Instant` deserialization
|
||||
- Communication: receives HTTP POST data from agents (executions, diagrams, metrics, logs), serves SSE event streams for config push/commands
|
||||
- Communication: receives HTTP POST data from agents (executions, diagrams, metrics, logs), serves SSE event streams for config push/commands (config-update, deep-trace, replay, route-control)
|
||||
- Maintains agent instance registry with states: LIVE → STALE → DEAD
|
||||
- Storage: PostgreSQL (TimescaleDB) for structured data, OpenSearch for full-text search and application log storage
|
||||
- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing, bootstrap token for registration
|
||||
@@ -57,6 +57,10 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
|
||||
- K8s probes: server uses `/api/v1/health`, PostgreSQL uses `pg_isready`, OpenSearch uses `/_cluster/health`
|
||||
- Docker build uses buildx registry cache + `--provenance=false` for Gitea compatibility
|
||||
|
||||
## UI Styling
|
||||
|
||||
- Always use `@cameleer/design-system` CSS variables for colors (`var(--amber)`, `var(--error)`, `var(--success)`, etc.) — never hardcode hex values. This applies to CSS modules, inline styles, and SVG `fill`/`stroke` attributes. SVG presentation attributes resolve `var()` correctly.
|
||||
|
||||
## Disabled Skills
|
||||
|
||||
- Do NOT use any `gsd:*` skills in this project. This includes all `/gsd:` prefixed commands.
|
||||
|
||||
8
HOWTO.md
8
HOWTO.md
@@ -325,6 +325,12 @@ curl -s -X POST http://localhost:8081/api/v1/agents/groups/order-service-prod/co
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"type":"deep-trace","payload":{"routeId":"route-1","durationSeconds":60}}'
|
||||
|
||||
# Send route control command to agent group (start/stop/suspend/resume)
|
||||
curl -s -X POST http://localhost:8081/api/v1/agents/groups/order-service-prod/commands \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"type":"route-control","payload":{"routeId":"route-1","action":"stop","nonce":"unique-uuid"}}'
|
||||
|
||||
# Broadcast command to all live agents
|
||||
curl -s -X POST http://localhost:8081/api/v1/agents/commands \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -338,7 +344,7 @@ curl -s -X POST http://localhost:8081/api/v1/agents/agent-1/commands/{commandId}
|
||||
|
||||
**Agent lifecycle:** LIVE (heartbeat within 90s) → STALE (missed 3 heartbeats) → DEAD (5min after STALE). DEAD agents kept indefinitely.
|
||||
|
||||
**SSE events:** `config-update`, `deep-trace`, `replay` commands pushed in real time. Server sends ping keepalive every 15s.
|
||||
**SSE events:** `config-update`, `deep-trace`, `replay`, `route-control` commands pushed in real time. Server sends ping keepalive every 15s.
|
||||
|
||||
**Command expiry:** Unacknowledged commands expire after 60 seconds.
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.cameleer3.server.app.dto.CommandAckRequest;
|
||||
import com.cameleer3.server.app.dto.CommandBroadcastResponse;
|
||||
import com.cameleer3.server.app.dto.CommandRequest;
|
||||
import com.cameleer3.server.app.dto.CommandSingleResponse;
|
||||
import com.cameleer3.server.app.dto.ReplayRequest;
|
||||
import com.cameleer3.server.app.dto.ReplayResponse;
|
||||
import com.cameleer3.server.core.admin.AuditCategory;
|
||||
import com.cameleer3.server.core.admin.AuditResult;
|
||||
import com.cameleer3.server.core.admin.AuditService;
|
||||
@@ -13,6 +15,7 @@ import com.cameleer3.server.core.agent.AgentEventService;
|
||||
import com.cameleer3.server.core.agent.AgentInfo;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.agent.AgentState;
|
||||
import com.cameleer3.server.core.agent.CommandReply;
|
||||
import com.cameleer3.server.core.agent.CommandType;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -32,7 +35,14 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/**
|
||||
* Command push endpoints for sending commands to agents via SSE.
|
||||
@@ -184,6 +194,75 @@ public class AgentCommandController {
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/replay")
|
||||
@Operation(summary = "Replay an exchange on a specific agent (synchronous)",
|
||||
description = "Sends a replay command and waits for the agent to complete the replay. "
|
||||
+ "Returns the replay result including status, replayExchangeId, and duration.")
|
||||
@ApiResponse(responseCode = "200", description = "Replay completed (check status for success/failure)")
|
||||
@ApiResponse(responseCode = "404", description = "Agent not found or not connected")
|
||||
@ApiResponse(responseCode = "504", description = "Agent did not respond in time")
|
||||
public ResponseEntity<ReplayResponse> replayExchange(@PathVariable String id,
|
||||
@RequestBody ReplayRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
AgentInfo agent = registryService.findById(id);
|
||||
if (agent == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Agent not found: " + id);
|
||||
}
|
||||
|
||||
// Build protocol-compliant replay payload
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("routeId", request.routeId());
|
||||
Map<String, Object> exchange = new LinkedHashMap<>();
|
||||
exchange.put("body", request.body() != null ? request.body() : "");
|
||||
exchange.put("headers", request.headers() != null ? request.headers() : Map.of());
|
||||
payload.put("exchange", exchange);
|
||||
if (request.originalExchangeId() != null) {
|
||||
payload.put("originalExchangeId", request.originalExchangeId());
|
||||
}
|
||||
payload.put("nonce", UUID.randomUUID().toString());
|
||||
|
||||
String payloadJson;
|
||||
try {
|
||||
payloadJson = objectMapper.writeValueAsString(payload);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("Failed to serialize replay payload", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new ReplayResponse("FAILURE", "Failed to serialize request", null));
|
||||
}
|
||||
|
||||
CompletableFuture<CommandReply> future = registryService.addCommandWithReply(
|
||||
id, CommandType.REPLAY, payloadJson);
|
||||
|
||||
Map<String, Object> auditDetails = new LinkedHashMap<>();
|
||||
auditDetails.put("routeId", request.routeId());
|
||||
if (request.originalExchangeId() != null) {
|
||||
auditDetails.put("originalExchangeId", request.originalExchangeId());
|
||||
}
|
||||
|
||||
try {
|
||||
CommandReply reply = future.orTimeout(30, TimeUnit.SECONDS).join();
|
||||
auditDetails.put("replyStatus", reply.status());
|
||||
auditDetails.put("replyMessage", reply.message() != null ? reply.message() : "");
|
||||
auditService.log("replay_exchange", AuditCategory.AGENT, id, auditDetails,
|
||||
"SUCCESS".equals(reply.status()) ? AuditResult.SUCCESS : AuditResult.FAILURE, httpRequest);
|
||||
return ResponseEntity.ok(new ReplayResponse(reply.status(), reply.message(), reply.data()));
|
||||
} catch (CompletionException e) {
|
||||
if (e.getCause() instanceof TimeoutException) {
|
||||
auditDetails.put("error", "timeout");
|
||||
auditService.log("replay_exchange", AuditCategory.AGENT, id, auditDetails,
|
||||
AuditResult.FAILURE, httpRequest);
|
||||
return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT)
|
||||
.body(new ReplayResponse("FAILURE", "Agent did not respond within 30 seconds", null));
|
||||
}
|
||||
auditDetails.put("error", e.getCause().getMessage());
|
||||
auditService.log("replay_exchange", AuditCategory.AGENT, id, auditDetails,
|
||||
AuditResult.FAILURE, httpRequest);
|
||||
log.error("Error awaiting replay reply from agent {}", id, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(new ReplayResponse("FAILURE", "Internal error: " + e.getCause().getMessage(), null));
|
||||
}
|
||||
}
|
||||
|
||||
private CommandType mapCommandType(String typeStr) {
|
||||
return switch (typeStr) {
|
||||
case "config-update" -> CommandType.CONFIG_UPDATE;
|
||||
@@ -191,8 +270,9 @@ public class AgentCommandController {
|
||||
case "replay" -> CommandType.REPLAY;
|
||||
case "set-traced-processors" -> CommandType.SET_TRACED_PROCESSORS;
|
||||
case "test-expression" -> CommandType.TEST_EXPRESSION;
|
||||
case "route-control" -> CommandType.ROUTE_CONTROL;
|
||||
default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Invalid command type: " + typeStr + ". Valid: config-update, deep-trace, replay, set-traced-processors, test-expression");
|
||||
"Invalid command type: " + typeStr + ". Valid: config-update, deep-trace, replay, set-traced-processors, test-expression, route-control");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ public class ApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
||||
String reason = ex.getReason();
|
||||
return ResponseEntity.status(ex.getStatusCode())
|
||||
.body(new ErrorResponse(ex.getReason() != null ? ex.getReason() : "Unknown error"));
|
||||
.body(new ErrorResponse(reason != null ? reason : "Unknown error"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.dto.AppSettingsRequest;
|
||||
import com.cameleer3.server.core.admin.AppSettings;
|
||||
import com.cameleer3.server.core.admin.AppSettingsRepository;
|
||||
import com.cameleer3.server.core.admin.AuditCategory;
|
||||
import com.cameleer3.server.core.admin.AuditResult;
|
||||
import com.cameleer3.server.core.admin.AuditService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/app-settings")
|
||||
@PreAuthorize("hasAnyRole('ADMIN', 'OPERATOR')")
|
||||
@Tag(name = "App Settings", description = "Per-application dashboard settings (ADMIN/OPERATOR)")
|
||||
public class AppSettingsController {
|
||||
|
||||
private final AppSettingsRepository repository;
|
||||
private final AuditService auditService;
|
||||
|
||||
public AppSettingsController(AppSettingsRepository repository, AuditService auditService) {
|
||||
this.repository = repository;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all application settings")
|
||||
public ResponseEntity<List<AppSettings>> getAll() {
|
||||
return ResponseEntity.ok(repository.findAll());
|
||||
}
|
||||
|
||||
@GetMapping("/{appId}")
|
||||
@Operation(summary = "Get settings for a specific application (returns defaults if not configured)")
|
||||
public ResponseEntity<AppSettings> getByAppId(@PathVariable String appId) {
|
||||
AppSettings settings = repository.findByAppId(appId).orElse(AppSettings.defaults(appId));
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
@PutMapping("/{appId}")
|
||||
@Operation(summary = "Create or update settings for an application")
|
||||
public ResponseEntity<AppSettings> update(@PathVariable String appId,
|
||||
@Valid @RequestBody AppSettingsRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
List<String> errors = request.validate();
|
||||
if (!errors.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors));
|
||||
}
|
||||
|
||||
AppSettings saved = repository.save(request.toSettings(appId));
|
||||
auditService.log("update_app_settings", AuditCategory.CONFIG, appId,
|
||||
Map.of("settings", saved), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{appId}")
|
||||
@Operation(summary = "Delete application settings (reverts to defaults)")
|
||||
public ResponseEntity<Void> delete(@PathVariable String appId, HttpServletRequest httpRequest) {
|
||||
repository.delete(appId);
|
||||
auditService.log("delete_app_settings", AuditCategory.CONFIG, appId,
|
||||
Map.of(), AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,8 @@ public class DatabaseAdminController {
|
||||
String host = extractHost(dataSource);
|
||||
return ResponseEntity.ok(new DatabaseStatusResponse(true, version, host, schema, timescaleDb));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.ok(new DatabaseStatusResponse(false, null, null, null, false));
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(new DatabaseStatusResponse(false, null, null, null, false));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@ public class OpenSearchAdminController {
|
||||
health.numberOfNodes(),
|
||||
opensearchUrl));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.ok(new OpenSearchStatusResponse(
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(new OpenSearchStatusResponse(
|
||||
false, "UNREACHABLE", null, 0, opensearchUrl));
|
||||
}
|
||||
}
|
||||
@@ -149,7 +150,8 @@ public class OpenSearchAdminController {
|
||||
pageItems, totalIndices, totalDocs,
|
||||
humanSize(totalBytes), page, size, totalPages));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.ok(new IndicesPageResponse(
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(new IndicesPageResponse(
|
||||
List.of(), 0, 0, "0 B", page, size, 0));
|
||||
}
|
||||
}
|
||||
@@ -234,7 +236,8 @@ public class OpenSearchAdminController {
|
||||
searchLatency, indexingLatency,
|
||||
heapUsed, heapMax));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.ok(new PerformanceResponse(0, 0, 0, 0, 0, 0));
|
||||
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(new PerformanceResponse(0, 0, 0, 0, 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.dto.ProcessorMetrics;
|
||||
import com.cameleer3.server.app.dto.RouteMetrics;
|
||||
import com.cameleer3.server.core.admin.AppSettings;
|
||||
import com.cameleer3.server.core.admin.AppSettingsRepository;
|
||||
import com.cameleer3.server.core.storage.StatsStore;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -18,6 +21,7 @@ import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/routes")
|
||||
@@ -25,9 +29,14 @@ import java.util.List;
|
||||
public class RouteMetricsController {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final StatsStore statsStore;
|
||||
private final AppSettingsRepository appSettingsRepository;
|
||||
|
||||
public RouteMetricsController(JdbcTemplate jdbc) {
|
||||
public RouteMetricsController(JdbcTemplate jdbc, StatsStore statsStore,
|
||||
AppSettingsRepository appSettingsRepository) {
|
||||
this.jdbc = jdbc;
|
||||
this.statsStore = statsStore;
|
||||
this.appSettingsRepository = appSettingsRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/metrics")
|
||||
@@ -78,7 +87,7 @@ public class RouteMetricsController {
|
||||
|
||||
routeKeys.add(new RouteKey(applicationName, routeId));
|
||||
return new RouteMetrics(routeId, applicationName, total, successRate,
|
||||
avgDur, p99Dur, errorRate, tps, List.of());
|
||||
avgDur, p99Dur, errorRate, tps, List.of(), -1.0);
|
||||
}, params.toArray());
|
||||
|
||||
// Fetch sparklines (12 buckets over the time window)
|
||||
@@ -100,13 +109,34 @@ public class RouteMetricsController {
|
||||
m.appId(), m.routeId());
|
||||
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
|
||||
m.successRate(), m.avgDurationMs(), m.p99DurationMs(),
|
||||
m.errorRate(), m.throughputPerSec(), sparkline));
|
||||
m.errorRate(), m.throughputPerSec(), sparkline, m.slaCompliance()));
|
||||
} catch (Exception e) {
|
||||
// Leave sparkline empty on error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich with SLA compliance per route
|
||||
if (!metrics.isEmpty()) {
|
||||
// Determine SLA threshold (per-app or default)
|
||||
String effectiveAppId = appId != null ? appId : (metrics.isEmpty() ? null : metrics.get(0).appId());
|
||||
int threshold = appSettingsRepository.findByAppId(effectiveAppId != null ? effectiveAppId : "")
|
||||
.map(AppSettings::slaThresholdMs).orElse(300);
|
||||
|
||||
Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant,
|
||||
effectiveAppId, threshold);
|
||||
|
||||
for (int i = 0; i < metrics.size(); i++) {
|
||||
RouteMetrics m = metrics.get(i);
|
||||
long[] counts = slaCounts.get(m.routeId());
|
||||
double sla = (counts != null && counts[1] > 0)
|
||||
? counts[0] * 100.0 / counts[1] : 100.0;
|
||||
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
|
||||
m.successRate(), m.avgDurationMs(), m.p99DurationMs(),
|
||||
m.errorRate(), m.throughputPerSec(), m.sparkline(), sla));
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(metrics);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.core.admin.AppSettings;
|
||||
import com.cameleer3.server.core.admin.AppSettingsRepository;
|
||||
import com.cameleer3.server.core.agent.AgentInfo;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.search.ExecutionStats;
|
||||
@@ -8,6 +10,8 @@ import com.cameleer3.server.core.search.SearchRequest;
|
||||
import com.cameleer3.server.core.search.SearchResult;
|
||||
import com.cameleer3.server.core.search.SearchService;
|
||||
import com.cameleer3.server.core.search.StatsTimeseries;
|
||||
import com.cameleer3.server.core.search.TopError;
|
||||
import com.cameleer3.server.core.storage.StatsStore;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -20,6 +24,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Search endpoints for querying route executions.
|
||||
@@ -34,10 +39,13 @@ public class SearchController {
|
||||
|
||||
private final SearchService searchService;
|
||||
private final AgentRegistryService registryService;
|
||||
private final AppSettingsRepository appSettingsRepository;
|
||||
|
||||
public SearchController(SearchService searchService, AgentRegistryService registryService) {
|
||||
public SearchController(SearchService searchService, AgentRegistryService registryService,
|
||||
AppSettingsRepository appSettingsRepository) {
|
||||
this.searchService = searchService;
|
||||
this.registryService = registryService;
|
||||
this.appSettingsRepository = appSettingsRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/executions")
|
||||
@@ -87,21 +95,29 @@ public class SearchController {
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
@Operation(summary = "Aggregate execution stats (P99 latency, active count)")
|
||||
@Operation(summary = "Aggregate execution stats (P99 latency, active count, SLA compliance)")
|
||||
public ResponseEntity<ExecutionStats> stats(
|
||||
@RequestParam Instant from,
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(required = false) String routeId,
|
||||
@RequestParam(required = false) String application) {
|
||||
Instant end = to != null ? to : Instant.now();
|
||||
ExecutionStats stats;
|
||||
if (routeId == null && application == null) {
|
||||
return ResponseEntity.ok(searchService.stats(from, end));
|
||||
}
|
||||
if (routeId == null) {
|
||||
return ResponseEntity.ok(searchService.statsForApp(from, end, application));
|
||||
}
|
||||
stats = searchService.stats(from, end);
|
||||
} else if (routeId == null) {
|
||||
stats = searchService.statsForApp(from, end, application);
|
||||
} else {
|
||||
List<String> agentIds = resolveApplicationToAgentIds(application);
|
||||
return ResponseEntity.ok(searchService.stats(from, end, routeId, agentIds));
|
||||
stats = searchService.stats(from, end, routeId, agentIds);
|
||||
}
|
||||
|
||||
// Enrich with SLA compliance
|
||||
int threshold = appSettingsRepository
|
||||
.findByAppId(application != null ? application : "")
|
||||
.map(AppSettings::slaThresholdMs).orElse(300);
|
||||
double sla = searchService.slaCompliance(from, end, threshold, application, routeId);
|
||||
return ResponseEntity.ok(stats.withSlaCompliance(sla));
|
||||
}
|
||||
|
||||
@GetMapping("/stats/timeseries")
|
||||
@@ -126,6 +142,48 @@ public class SearchController {
|
||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, routeId, agentIds));
|
||||
}
|
||||
|
||||
@GetMapping("/stats/timeseries/by-app")
|
||||
@Operation(summary = "Timeseries grouped by application")
|
||||
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByApp(
|
||||
@RequestParam Instant from,
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(defaultValue = "24") int buckets) {
|
||||
Instant end = to != null ? to : Instant.now();
|
||||
return ResponseEntity.ok(searchService.timeseriesGroupedByApp(from, end, buckets));
|
||||
}
|
||||
|
||||
@GetMapping("/stats/timeseries/by-route")
|
||||
@Operation(summary = "Timeseries grouped by route for an application")
|
||||
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByRoute(
|
||||
@RequestParam Instant from,
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(defaultValue = "24") int buckets,
|
||||
@RequestParam String application) {
|
||||
Instant end = to != null ? to : Instant.now();
|
||||
return ResponseEntity.ok(searchService.timeseriesGroupedByRoute(from, end, buckets, application));
|
||||
}
|
||||
|
||||
@GetMapping("/stats/punchcard")
|
||||
@Operation(summary = "Transaction punchcard: weekday x hour grid (rolling 7 days)")
|
||||
public ResponseEntity<List<StatsStore.PunchcardCell>> punchcard(
|
||||
@RequestParam(required = false) String application) {
|
||||
Instant to = Instant.now();
|
||||
Instant from = to.minus(java.time.Duration.ofDays(7));
|
||||
return ResponseEntity.ok(searchService.punchcard(from, to, application));
|
||||
}
|
||||
|
||||
@GetMapping("/errors/top")
|
||||
@Operation(summary = "Top N errors with velocity trend")
|
||||
public ResponseEntity<List<TopError>> topErrors(
|
||||
@RequestParam Instant from,
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(required = false) String application,
|
||||
@RequestParam(required = false) String routeId,
|
||||
@RequestParam(defaultValue = "5") int limit) {
|
||||
Instant end = to != null ? to : Instant.now();
|
||||
return ResponseEntity.ok(searchService.topErrors(from, end, application, routeId, limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an application name to agent IDs.
|
||||
* Returns null if application is null/blank (no filtering).
|
||||
|
||||
@@ -415,12 +415,13 @@ public class ElkDiagramRenderer implements DiagramRenderer {
|
||||
for (ElkEdge elkEdge : allEdges) {
|
||||
String sourceId = elkEdge.getSources().isEmpty() ? "" : elkEdge.getSources().get(0).getIdentifier();
|
||||
String targetId = elkEdge.getTargets().isEmpty() ? "" : elkEdge.getTargets().get(0).getIdentifier();
|
||||
ElkNode edgeRoot = getElkRoot(elkEdge.getContainingNode());
|
||||
ElkNode containingNode = elkEdge.getContainingNode();
|
||||
ElkNode edgeRoot = containingNode != null ? getElkRoot(containingNode) : null;
|
||||
|
||||
List<double[]> points = new ArrayList<>();
|
||||
for (ElkEdgeSection section : elkEdge.getSections()) {
|
||||
double cx = getAbsoluteX(elkEdge.getContainingNode(), edgeRoot);
|
||||
double cy = getAbsoluteY(elkEdge.getContainingNode(), edgeRoot);
|
||||
double cx = containingNode != null ? getAbsoluteX(containingNode, edgeRoot) : 0;
|
||||
double cy = containingNode != null ? getAbsoluteY(containingNode, edgeRoot) : 0;
|
||||
points.add(new double[]{section.getStartX() + cx, section.getStartY() + cy});
|
||||
for (ElkBendPoint bp : section.getBendPoints()) {
|
||||
points.add(new double[]{bp.getX() + cx, bp.getY() + cy});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.cameleer3.server.app.dto;
|
||||
|
||||
import com.cameleer3.server.core.admin.AppSettings;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "Per-application dashboard settings")
|
||||
public record AppSettingsRequest(
|
||||
@NotNull @Min(1)
|
||||
@Schema(description = "SLA duration threshold in milliseconds")
|
||||
Integer slaThresholdMs,
|
||||
|
||||
@NotNull @Min(0) @Max(100)
|
||||
@Schema(description = "Error rate % threshold for warning (yellow) health dot")
|
||||
Double healthErrorWarn,
|
||||
|
||||
@NotNull @Min(0) @Max(100)
|
||||
@Schema(description = "Error rate % threshold for critical (red) health dot")
|
||||
Double healthErrorCrit,
|
||||
|
||||
@NotNull @Min(0) @Max(100)
|
||||
@Schema(description = "SLA compliance % threshold for warning (yellow) health dot")
|
||||
Double healthSlaWarn,
|
||||
|
||||
@NotNull @Min(0) @Max(100)
|
||||
@Schema(description = "SLA compliance % threshold for critical (red) health dot")
|
||||
Double healthSlaCrit
|
||||
) {
|
||||
|
||||
public AppSettings toSettings(String appId) {
|
||||
Instant now = Instant.now();
|
||||
return new AppSettings(appId, slaThresholdMs, healthErrorWarn, healthErrorCrit,
|
||||
healthSlaWarn, healthSlaCrit, now, now);
|
||||
}
|
||||
|
||||
public List<String> validate() {
|
||||
List<String> errors = new ArrayList<>();
|
||||
if (healthErrorWarn != null && healthErrorCrit != null
|
||||
&& healthErrorWarn > healthErrorCrit) {
|
||||
errors.add("healthErrorWarn must be <= healthErrorCrit");
|
||||
}
|
||||
if (healthSlaWarn != null && healthSlaCrit != null
|
||||
&& healthSlaWarn < healthSlaCrit) {
|
||||
errors.add("healthSlaWarn must be >= healthSlaCrit (higher SLA = healthier)");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cameleer3.server.app.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Schema(description = "Request to replay an exchange on an agent")
|
||||
public record ReplayRequest(
|
||||
@NotNull @Schema(description = "Camel route ID to replay on")
|
||||
String routeId,
|
||||
@Schema(description = "Message body for the replayed exchange")
|
||||
String body,
|
||||
@Schema(description = "Message headers for the replayed exchange")
|
||||
Map<String, String> headers,
|
||||
@Schema(description = "Exchange ID of the original execution being replayed (for audit trail)")
|
||||
String originalExchangeId
|
||||
) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cameleer3.server.app.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(description = "Result of a replay command")
|
||||
public record ReplayResponse(
|
||||
@Schema(description = "Replay outcome: SUCCESS or FAILURE")
|
||||
String status,
|
||||
@Schema(description = "Human-readable result message")
|
||||
String message,
|
||||
@Schema(description = "Structured result data from the agent (JSON)")
|
||||
String data
|
||||
) {}
|
||||
@@ -15,5 +15,6 @@ public record RouteMetrics(
|
||||
@NotNull double p99DurationMs,
|
||||
@NotNull double errorRate,
|
||||
@NotNull double throughputPerSec,
|
||||
@NotNull List<Double> sparkline
|
||||
@NotNull List<Double> sparkline,
|
||||
double slaCompliance
|
||||
) {}
|
||||
|
||||
@@ -362,6 +362,7 @@ public class OpenSearchIndex implements SearchIndex {
|
||||
}).toList());
|
||||
}
|
||||
map.put("has_trace_data", doc.hasTraceData());
|
||||
map.put("is_replay", doc.isReplay());
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -399,7 +400,8 @@ public class OpenSearchIndex implements SearchIndex {
|
||||
null, // diagramContentHash not stored in index
|
||||
extractHighlight(hit),
|
||||
attributes,
|
||||
Boolean.TRUE.equals(src.get("has_trace_data"))
|
||||
Boolean.TRUE.equals(src.get("has_trace_data")),
|
||||
Boolean.TRUE.equals(src.get("is_replay"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/agents/*/commands").hasAnyRole("OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/agents/groups/*/commands").hasAnyRole("OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/agents/commands").hasAnyRole("OPERATOR", "ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/agents/*/replay").hasAnyRole("OPERATOR", "ADMIN")
|
||||
|
||||
// Search endpoints
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.cameleer3.server.app.storage;
|
||||
|
||||
import com.cameleer3.server.core.admin.AppSettings;
|
||||
import com.cameleer3.server.core.admin.AppSettingsRepository;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public class PostgresAppSettingsRepository implements AppSettingsRepository {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
private static final RowMapper<AppSettings> ROW_MAPPER = (rs, rowNum) -> new AppSettings(
|
||||
rs.getString("app_id"),
|
||||
rs.getInt("sla_threshold_ms"),
|
||||
rs.getDouble("health_error_warn"),
|
||||
rs.getDouble("health_error_crit"),
|
||||
rs.getDouble("health_sla_warn"),
|
||||
rs.getDouble("health_sla_crit"),
|
||||
rs.getTimestamp("created_at").toInstant(),
|
||||
rs.getTimestamp("updated_at").toInstant());
|
||||
|
||||
public PostgresAppSettingsRepository(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AppSettings> findByAppId(String appId) {
|
||||
List<AppSettings> results = jdbc.query(
|
||||
"SELECT * FROM app_settings WHERE app_id = ?", ROW_MAPPER, appId);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AppSettings> findAll() {
|
||||
return jdbc.query("SELECT * FROM app_settings ORDER BY app_id", ROW_MAPPER);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AppSettings save(AppSettings settings) {
|
||||
jdbc.update("""
|
||||
INSERT INTO app_settings (app_id, sla_threshold_ms, health_error_warn,
|
||||
health_error_crit, health_sla_warn, health_sla_crit, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, now(), now())
|
||||
ON CONFLICT (app_id) DO UPDATE SET
|
||||
sla_threshold_ms = EXCLUDED.sla_threshold_ms,
|
||||
health_error_warn = EXCLUDED.health_error_warn,
|
||||
health_error_crit = EXCLUDED.health_error_crit,
|
||||
health_sla_warn = EXCLUDED.health_sla_warn,
|
||||
health_sla_crit = EXCLUDED.health_sla_crit,
|
||||
updated_at = now()
|
||||
""",
|
||||
settings.appId(), settings.slaThresholdMs(),
|
||||
settings.healthErrorWarn(), settings.healthErrorCrit(),
|
||||
settings.healthSlaWarn(), settings.healthSlaCrit());
|
||||
return findByAppId(settings.appId()).orElseThrow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String appId) {
|
||||
jdbc.update("DELETE FROM app_settings WHERE app_id = ?", appId);
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,10 @@ public class PostgresExecutionStore implements ExecutionStore {
|
||||
attributes,
|
||||
error_type, error_category, root_cause_type, root_cause_message,
|
||||
trace_id, span_id,
|
||||
processors_json, has_trace_data,
|
||||
processors_json, has_trace_data, is_replay,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb,
|
||||
?, ?, ?, ?, ?, ?, ?::jsonb, ?, now(), now())
|
||||
?, ?, ?, ?, ?, ?, ?::jsonb, ?, ?, now(), now())
|
||||
ON CONFLICT (execution_id, start_time) DO UPDATE SET
|
||||
status = CASE
|
||||
WHEN EXCLUDED.status IN ('COMPLETED', 'FAILED')
|
||||
@@ -62,6 +62,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
||||
span_id = COALESCE(EXCLUDED.span_id, executions.span_id),
|
||||
processors_json = COALESCE(EXCLUDED.processors_json, executions.processors_json),
|
||||
has_trace_data = EXCLUDED.has_trace_data OR executions.has_trace_data,
|
||||
is_replay = EXCLUDED.is_replay OR executions.is_replay,
|
||||
updated_at = now()
|
||||
""",
|
||||
execution.executionId(), execution.routeId(), execution.agentId(),
|
||||
@@ -78,7 +79,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
||||
execution.errorType(), execution.errorCategory(),
|
||||
execution.rootCauseType(), execution.rootCauseMessage(),
|
||||
execution.traceId(), execution.spanId(),
|
||||
execution.processorsJson(), execution.hasTraceData());
|
||||
execution.processorsJson(), execution.hasTraceData(), execution.isReplay());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -180,7 +181,8 @@ public class PostgresExecutionStore implements ExecutionStore {
|
||||
rs.getString("root_cause_type"), rs.getString("root_cause_message"),
|
||||
rs.getString("trace_id"), rs.getString("span_id"),
|
||||
rs.getString("processors_json"),
|
||||
rs.getBoolean("has_trace_data"));
|
||||
rs.getBoolean("has_trace_data"),
|
||||
rs.getBoolean("is_replay"));
|
||||
|
||||
private static final RowMapper<ProcessorRecord> PROCESSOR_MAPPER = (rs, rowNum) ->
|
||||
new ProcessorRecord(
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.cameleer3.server.app.storage;
|
||||
import com.cameleer3.server.core.search.ExecutionStats;
|
||||
import com.cameleer3.server.core.search.StatsTimeseries;
|
||||
import com.cameleer3.server.core.search.StatsTimeseries.TimeseriesBucket;
|
||||
import com.cameleer3.server.core.search.TopError;
|
||||
import com.cameleer3.server.core.storage.StatsStore;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -12,7 +13,9 @@ import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class PostgresStatsStore implements StatsStore {
|
||||
@@ -184,4 +187,242 @@ public class PostgresStatsStore implements StatsStore {
|
||||
|
||||
return new StatsTimeseries(buckets);
|
||||
}
|
||||
|
||||
// ── Grouped timeseries ────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public Map<String, StatsTimeseries> timeseriesGroupedByApp(Instant from, Instant to, int bucketCount) {
|
||||
return queryGroupedTimeseries("stats_1m_app", "application_name", from, to,
|
||||
bucketCount, List.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, StatsTimeseries> timeseriesGroupedByRoute(Instant from, Instant to,
|
||||
int bucketCount, String applicationName) {
|
||||
return queryGroupedTimeseries("stats_1m_route", "route_id", from, to,
|
||||
bucketCount, List.of(new Filter("application_name", applicationName)));
|
||||
}
|
||||
|
||||
private Map<String, StatsTimeseries> queryGroupedTimeseries(
|
||||
String view, String groupCol, Instant from, Instant to,
|
||||
int bucketCount, List<Filter> filters) {
|
||||
|
||||
long intervalSeconds = Duration.between(from, to).toSeconds() / Math.max(bucketCount, 1);
|
||||
if (intervalSeconds < 60) intervalSeconds = 60;
|
||||
|
||||
String sql = "SELECT time_bucket(? * INTERVAL '1 second', bucket) AS period, " +
|
||||
groupCol + " AS group_key, " +
|
||||
"COALESCE(SUM(total_count), 0) AS total_count, " +
|
||||
"COALESCE(SUM(failed_count), 0) AS failed_count, " +
|
||||
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum) / SUM(total_count) ELSE 0 END AS avg_duration, " +
|
||||
"COALESCE(MAX(p99_duration), 0) AS p99_duration, " +
|
||||
"COALESCE(SUM(running_count), 0) AS active_count " +
|
||||
"FROM " + view + " WHERE bucket >= ? AND bucket < ?";
|
||||
|
||||
List<Object> params = new ArrayList<>();
|
||||
params.add(intervalSeconds);
|
||||
params.add(Timestamp.from(from));
|
||||
params.add(Timestamp.from(to));
|
||||
for (Filter f : filters) {
|
||||
sql += " AND " + f.column() + " = ?";
|
||||
params.add(f.value());
|
||||
}
|
||||
sql += " GROUP BY period, group_key ORDER BY period, group_key";
|
||||
|
||||
Map<String, List<TimeseriesBucket>> grouped = new LinkedHashMap<>();
|
||||
jdbc.query(sql, (rs) -> {
|
||||
String key = rs.getString("group_key");
|
||||
TimeseriesBucket bucket = new TimeseriesBucket(
|
||||
rs.getTimestamp("period").toInstant(),
|
||||
rs.getLong("total_count"), rs.getLong("failed_count"),
|
||||
rs.getLong("avg_duration"), rs.getLong("p99_duration"),
|
||||
rs.getLong("active_count"));
|
||||
grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(bucket);
|
||||
}, params.toArray());
|
||||
|
||||
Map<String, StatsTimeseries> result = new LinkedHashMap<>();
|
||||
grouped.forEach((key, buckets) -> result.put(key, new StatsTimeseries(buckets)));
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── SLA compliance ────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public double slaCompliance(Instant from, Instant to, int thresholdMs,
|
||||
String applicationName, String routeId) {
|
||||
String sql = "SELECT " +
|
||||
"COUNT(*) FILTER (WHERE duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
|
||||
"COUNT(*) FILTER (WHERE status != 'RUNNING') AS total " +
|
||||
"FROM executions WHERE start_time >= ? AND start_time < ?";
|
||||
|
||||
List<Object> params = new ArrayList<>();
|
||||
params.add(thresholdMs);
|
||||
params.add(Timestamp.from(from));
|
||||
params.add(Timestamp.from(to));
|
||||
if (applicationName != null) {
|
||||
sql += " AND application_name = ?";
|
||||
params.add(applicationName);
|
||||
}
|
||||
if (routeId != null) {
|
||||
sql += " AND route_id = ?";
|
||||
params.add(routeId);
|
||||
}
|
||||
|
||||
return jdbc.query(sql, (rs, rowNum) -> {
|
||||
long total = rs.getLong("total");
|
||||
if (total == 0) return 1.0;
|
||||
return rs.getLong("compliant") * 100.0 / total;
|
||||
}, params.toArray()).stream().findFirst().orElse(1.0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, long[]> slaCountsByApp(Instant from, Instant to, int defaultThresholdMs) {
|
||||
String sql = "SELECT application_name, " +
|
||||
"COUNT(*) FILTER (WHERE duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
|
||||
"COUNT(*) FILTER (WHERE status != 'RUNNING') AS total " +
|
||||
"FROM executions WHERE start_time >= ? AND start_time < ? " +
|
||||
"GROUP BY application_name";
|
||||
|
||||
Map<String, long[]> result = new LinkedHashMap<>();
|
||||
jdbc.query(sql, (rs) -> {
|
||||
result.put(rs.getString("application_name"),
|
||||
new long[]{rs.getLong("compliant"), rs.getLong("total")});
|
||||
}, defaultThresholdMs, Timestamp.from(from), Timestamp.from(to));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, long[]> slaCountsByRoute(Instant from, Instant to,
|
||||
String applicationName, int thresholdMs) {
|
||||
String sql = "SELECT route_id, " +
|
||||
"COUNT(*) FILTER (WHERE duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
|
||||
"COUNT(*) FILTER (WHERE status != 'RUNNING') AS total " +
|
||||
"FROM executions WHERE start_time >= ? AND start_time < ? " +
|
||||
"AND application_name = ? GROUP BY route_id";
|
||||
|
||||
Map<String, long[]> result = new LinkedHashMap<>();
|
||||
jdbc.query(sql, (rs) -> {
|
||||
result.put(rs.getString("route_id"),
|
||||
new long[]{rs.getLong("compliant"), rs.getLong("total")});
|
||||
}, thresholdMs, Timestamp.from(from), Timestamp.from(to), applicationName);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Top errors ────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public List<TopError> topErrors(Instant from, Instant to, String applicationName,
|
||||
String routeId, int limit) {
|
||||
StringBuilder where = new StringBuilder(
|
||||
"status = 'FAILED' AND start_time >= ? AND start_time < ?");
|
||||
List<Object> params = new ArrayList<>();
|
||||
params.add(Timestamp.from(from));
|
||||
params.add(Timestamp.from(to));
|
||||
if (applicationName != null) {
|
||||
where.append(" AND application_name = ?");
|
||||
params.add(applicationName);
|
||||
}
|
||||
|
||||
String table;
|
||||
String groupId;
|
||||
if (routeId != null) {
|
||||
// L3: attribute errors to processors
|
||||
table = "processor_executions";
|
||||
groupId = "processor_id";
|
||||
where.append(" AND route_id = ?");
|
||||
params.add(routeId);
|
||||
} else {
|
||||
// L1/L2: attribute errors to routes
|
||||
table = "executions";
|
||||
groupId = "route_id";
|
||||
}
|
||||
|
||||
Instant fiveMinAgo = Instant.now().minus(5, ChronoUnit.MINUTES);
|
||||
Instant tenMinAgo = Instant.now().minus(10, ChronoUnit.MINUTES);
|
||||
|
||||
String sql = "WITH counted AS (" +
|
||||
" SELECT COALESCE(error_type, LEFT(error_message, 200)) AS error_key, " +
|
||||
" " + groupId + " AS group_id, " +
|
||||
" COUNT(*) AS cnt, MAX(start_time) AS last_seen " +
|
||||
" FROM " + table + " WHERE " + where +
|
||||
" GROUP BY error_key, group_id ORDER BY cnt DESC LIMIT ?" +
|
||||
"), velocity AS (" +
|
||||
" SELECT COALESCE(error_type, LEFT(error_message, 200)) AS error_key, " +
|
||||
" COUNT(*) FILTER (WHERE start_time >= ?) AS recent_5m, " +
|
||||
" COUNT(*) FILTER (WHERE start_time >= ? AND start_time < ?) AS prev_5m " +
|
||||
" FROM " + table + " WHERE " + where +
|
||||
" GROUP BY error_key" +
|
||||
") SELECT c.error_key, c.group_id, c.cnt, c.last_seen, " +
|
||||
" COALESCE(v.recent_5m, 0) / 5.0 AS velocity, " +
|
||||
" CASE " +
|
||||
" WHEN COALESCE(v.recent_5m, 0) > COALESCE(v.prev_5m, 0) * 1.2 THEN 'accelerating' " +
|
||||
" WHEN COALESCE(v.recent_5m, 0) < COALESCE(v.prev_5m, 0) * 0.8 THEN 'decelerating' " +
|
||||
" ELSE 'stable' END AS trend " +
|
||||
"FROM counted c LEFT JOIN velocity v ON c.error_key = v.error_key " +
|
||||
"ORDER BY c.cnt DESC";
|
||||
|
||||
// Build full params: counted-where params + limit + velocity timestamps + velocity-where params
|
||||
List<Object> fullParams = new ArrayList<>(params);
|
||||
fullParams.add(limit);
|
||||
fullParams.add(Timestamp.from(fiveMinAgo));
|
||||
fullParams.add(Timestamp.from(tenMinAgo));
|
||||
fullParams.add(Timestamp.from(fiveMinAgo));
|
||||
fullParams.addAll(params); // same where clause for velocity CTE
|
||||
|
||||
return jdbc.query(sql, (rs, rowNum) -> {
|
||||
String errorKey = rs.getString("error_key");
|
||||
String gid = rs.getString("group_id");
|
||||
return new TopError(
|
||||
errorKey,
|
||||
routeId != null ? routeId : gid, // routeId
|
||||
routeId != null ? gid : null, // processorId (only at L3)
|
||||
rs.getLong("cnt"),
|
||||
rs.getDouble("velocity"),
|
||||
rs.getString("trend"),
|
||||
rs.getTimestamp("last_seen").toInstant());
|
||||
}, fullParams.toArray());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int activeErrorTypes(Instant from, Instant to, String applicationName) {
|
||||
String sql = "SELECT COUNT(DISTINCT COALESCE(error_type, LEFT(error_message, 200))) " +
|
||||
"FROM executions WHERE status = 'FAILED' AND start_time >= ? AND start_time < ?";
|
||||
|
||||
List<Object> params = new ArrayList<>();
|
||||
params.add(Timestamp.from(from));
|
||||
params.add(Timestamp.from(to));
|
||||
if (applicationName != null) {
|
||||
sql += " AND application_name = ?";
|
||||
params.add(applicationName);
|
||||
}
|
||||
|
||||
Integer count = jdbc.queryForObject(sql, Integer.class, params.toArray());
|
||||
return count != null ? count : 0;
|
||||
}
|
||||
|
||||
// ── Punchcard ─────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public List<PunchcardCell> punchcard(Instant from, Instant to, String applicationName) {
|
||||
String view = applicationName != null ? "stats_1m_app" : "stats_1m_all";
|
||||
String sql = "SELECT EXTRACT(DOW FROM bucket) AS weekday, " +
|
||||
"EXTRACT(HOUR FROM bucket) AS hour, " +
|
||||
"COALESCE(SUM(total_count), 0) AS total_count, " +
|
||||
"COALESCE(SUM(failed_count), 0) AS failed_count " +
|
||||
"FROM " + view + " WHERE bucket >= ? AND bucket < ?";
|
||||
|
||||
List<Object> params = new ArrayList<>();
|
||||
params.add(Timestamp.from(from));
|
||||
params.add(Timestamp.from(to));
|
||||
if (applicationName != null) {
|
||||
sql += " AND application_name = ?";
|
||||
params.add(applicationName);
|
||||
}
|
||||
sql += " GROUP BY weekday, hour ORDER BY weekday, hour";
|
||||
|
||||
return jdbc.query(sql, (rs, rowNum) -> new PunchcardCell(
|
||||
rs.getInt("weekday"), rs.getInt("hour"),
|
||||
rs.getLong("total_count"), rs.getLong("failed_count")),
|
||||
params.toArray());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ public class SpaForwardController {
|
||||
@GetMapping(value = {
|
||||
"/login",
|
||||
"/executions",
|
||||
"/executions/{path:[^\\.]*}",
|
||||
"/executions/**",
|
||||
"/oidc/callback",
|
||||
"/admin/{path:[^\\.]*}"
|
||||
"/admin/**"
|
||||
})
|
||||
public String forward() {
|
||||
return "forward:/index.html";
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Per-application dashboard settings (SLA thresholds, health dot thresholds)
|
||||
CREATE TABLE app_settings (
|
||||
app_id TEXT PRIMARY KEY,
|
||||
sla_threshold_ms INTEGER NOT NULL DEFAULT 300,
|
||||
health_error_warn DOUBLE PRECISION NOT NULL DEFAULT 1.0,
|
||||
health_error_crit DOUBLE PRECISION NOT NULL DEFAULT 5.0,
|
||||
health_sla_warn DOUBLE PRECISION NOT NULL DEFAULT 99.0,
|
||||
health_sla_crit DOUBLE PRECISION NOT NULL DEFAULT 95.0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Flag indicating whether this execution is a replayed exchange
|
||||
ALTER TABLE executions ADD COLUMN IF NOT EXISTS is_replay BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Backfill: check inputHeaders JSON for X-Cameleer-Replay header
|
||||
UPDATE executions SET is_replay = TRUE
|
||||
WHERE input_headers IS NOT NULL
|
||||
AND input_headers::jsonb ? 'X-Cameleer-Replay';
|
||||
@@ -36,7 +36,7 @@ class OpenSearchIndexIT extends AbstractPostgresIT {
|
||||
"OrderNotFoundException: order-12345 not found", null,
|
||||
List.of(new ProcessorDoc("proc-1", "log", "COMPLETED",
|
||||
null, null, "request body with customer-99", null, null, null, null)),
|
||||
null, false);
|
||||
null, false, false);
|
||||
|
||||
searchIndex.index(doc);
|
||||
refreshOpenSearchIndices();
|
||||
@@ -62,7 +62,7 @@ class OpenSearchIndexIT extends AbstractPostgresIT {
|
||||
now, now.plusMillis(50), 50L, null, null,
|
||||
List.of(new ProcessorDoc("proc-1", "bean", "COMPLETED",
|
||||
null, null, "UniquePayloadIdentifier12345", null, null, null, null)),
|
||||
null, false);
|
||||
null, false, false);
|
||||
|
||||
searchIndex.index(doc);
|
||||
refreshOpenSearchIndices();
|
||||
|
||||
@@ -27,7 +27,7 @@ class PostgresExecutionStoreIT extends AbstractPostgresIT {
|
||||
now, now.plusMillis(100), 100L,
|
||||
null, null, null,
|
||||
"REGULAR", null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, false);
|
||||
null, null, null, null, null, null, null, false, false);
|
||||
|
||||
executionStore.upsert(record);
|
||||
Optional<ExecutionRecord> found = executionStore.findById("exec-1");
|
||||
@@ -45,12 +45,12 @@ class PostgresExecutionStoreIT extends AbstractPostgresIT {
|
||||
"exec-dup", "route-a", "agent-1", "app-1",
|
||||
"RUNNING", null, null, now, null, null, null, null, null,
|
||||
null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, false);
|
||||
null, null, null, null, null, null, null, false, false);
|
||||
ExecutionRecord second = new ExecutionRecord(
|
||||
"exec-dup", "route-a", "agent-1", "app-1",
|
||||
"COMPLETED", null, null, now, now.plusMillis(200), 200L, null, null, null,
|
||||
"COMPLETE", null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, false);
|
||||
null, null, null, null, null, null, null, false, false);
|
||||
|
||||
executionStore.upsert(first);
|
||||
executionStore.upsert(second);
|
||||
@@ -68,7 +68,7 @@ class PostgresExecutionStoreIT extends AbstractPostgresIT {
|
||||
"exec-proc", "route-a", "agent-1", "app-1",
|
||||
"COMPLETED", null, null, now, now.plusMillis(50), 50L, null, null, null,
|
||||
"COMPLETE", null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, false);
|
||||
null, null, null, null, null, null, null, false, false);
|
||||
executionStore.upsert(exec);
|
||||
|
||||
List<ProcessorRecord> processors = List.of(
|
||||
|
||||
@@ -61,6 +61,6 @@ class PostgresStatsStoreIT extends AbstractPostgresIT {
|
||||
startTime, startTime.plusMillis(durationMs), durationMs,
|
||||
status.equals("FAILED") ? "error" : null, null, null,
|
||||
null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, false));
|
||||
null, null, null, null, null, null, null, false, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.cameleer3.server.core.admin;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record AppSettings(
|
||||
String appId,
|
||||
int slaThresholdMs,
|
||||
double healthErrorWarn,
|
||||
double healthErrorCrit,
|
||||
double healthSlaWarn,
|
||||
double healthSlaCrit,
|
||||
Instant createdAt,
|
||||
Instant updatedAt) {
|
||||
|
||||
public static AppSettings defaults(String appId) {
|
||||
Instant now = Instant.now();
|
||||
return new AppSettings(appId, 300, 1.0, 5.0, 99.0, 95.0, now, now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cameleer3.server.core.admin;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AppSettingsRepository {
|
||||
Optional<AppSettings> findByAppId(String appId);
|
||||
List<AppSettings> findAll();
|
||||
AppSettings save(AppSettings settings);
|
||||
void delete(String appId);
|
||||
}
|
||||
@@ -8,5 +8,6 @@ public enum CommandType {
|
||||
DEEP_TRACE,
|
||||
REPLAY,
|
||||
SET_TRACED_PROCESSORS,
|
||||
TEST_EXPRESSION
|
||||
TEST_EXPRESSION,
|
||||
ROUTE_CONTROL
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ public class DetailService {
|
||||
p.getRootCauseType(), p.getRootCauseMessage(),
|
||||
p.getErrorHandlerType(), p.getCircuitBreakerState(),
|
||||
p.getFallbackTriggered(),
|
||||
p.getFilterMatched(), p.getDuplicateMessage(),
|
||||
hasTrace
|
||||
);
|
||||
for (ProcessorNode child : convertProcessors(p.getChildren())) {
|
||||
@@ -132,6 +133,7 @@ public class DetailService {
|
||||
p.rootCauseType(), p.rootCauseMessage(),
|
||||
p.errorHandlerType(), p.circuitBreakerState(),
|
||||
p.fallbackTriggered(),
|
||||
null, null, // filterMatched, duplicateMessage (not in flat DB records)
|
||||
hasTrace
|
||||
));
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ public final class ProcessorNode {
|
||||
private final String errorHandlerType;
|
||||
private final String circuitBreakerState;
|
||||
private final Boolean fallbackTriggered;
|
||||
private final Boolean filterMatched;
|
||||
private final Boolean duplicateMessage;
|
||||
private final boolean hasTraceData;
|
||||
private final List<ProcessorNode> children;
|
||||
|
||||
@@ -50,6 +52,7 @@ public final class ProcessorNode {
|
||||
String rootCauseType, String rootCauseMessage,
|
||||
String errorHandlerType, String circuitBreakerState,
|
||||
Boolean fallbackTriggered,
|
||||
Boolean filterMatched, Boolean duplicateMessage,
|
||||
boolean hasTraceData) {
|
||||
this.processorId = processorId;
|
||||
this.processorType = processorType;
|
||||
@@ -73,6 +76,8 @@ public final class ProcessorNode {
|
||||
this.errorHandlerType = errorHandlerType;
|
||||
this.circuitBreakerState = circuitBreakerState;
|
||||
this.fallbackTriggered = fallbackTriggered;
|
||||
this.filterMatched = filterMatched;
|
||||
this.duplicateMessage = duplicateMessage;
|
||||
this.hasTraceData = hasTraceData;
|
||||
this.children = new ArrayList<>();
|
||||
}
|
||||
@@ -103,6 +108,8 @@ public final class ProcessorNode {
|
||||
public String getErrorHandlerType() { return errorHandlerType; }
|
||||
public String getCircuitBreakerState() { return circuitBreakerState; }
|
||||
public Boolean getFallbackTriggered() { return fallbackTriggered; }
|
||||
public Boolean getFilterMatched() { return filterMatched; }
|
||||
public Boolean getDuplicateMessage() { return duplicateMessage; }
|
||||
public boolean isHasTraceData() { return hasTraceData; }
|
||||
public List<ProcessorNode> getChildren() { return List.copyOf(children); }
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ public class SearchIndexer implements SearchIndexerStats {
|
||||
exec.status(), exec.correlationId(), exec.exchangeId(),
|
||||
exec.startTime(), exec.endTime(), exec.durationMs(),
|
||||
exec.errorMessage(), exec.errorStacktrace(), processorDocs,
|
||||
exec.attributes(), exec.hasTraceData()));
|
||||
exec.attributes(), exec.hasTraceData(), exec.isReplay()));
|
||||
|
||||
indexedCount.incrementAndGet();
|
||||
lastIndexedAt = Instant.now();
|
||||
|
||||
@@ -102,6 +102,12 @@ public class IngestionService {
|
||||
|
||||
boolean hasTraceData = hasAnyTraceData(exec.getProcessors());
|
||||
|
||||
boolean isReplay = exec.getReplayExchangeId() != null;
|
||||
if (!isReplay && inputSnapshot != null && inputSnapshot.getHeaders() != null) {
|
||||
isReplay = "true".equalsIgnoreCase(
|
||||
String.valueOf(inputSnapshot.getHeaders().get("X-Cameleer-Replay")));
|
||||
}
|
||||
|
||||
return new ExecutionRecord(
|
||||
exec.getExchangeId(), exec.getRouteId(), agentId, applicationName,
|
||||
exec.getStatus() != null ? exec.getStatus().name() : "RUNNING",
|
||||
@@ -117,7 +123,8 @@ public class IngestionService {
|
||||
exec.getRootCauseType(), exec.getRootCauseMessage(),
|
||||
exec.getTraceId(), exec.getSpanId(),
|
||||
toJsonObject(exec.getProcessors()),
|
||||
hasTraceData
|
||||
hasTraceData,
|
||||
isReplay
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.cameleer3.server.core.ingestion;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
@@ -16,6 +19,8 @@ import java.util.concurrent.BlockingQueue;
|
||||
*/
|
||||
public class WriteBuffer<T> {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WriteBuffer.class);
|
||||
|
||||
private final BlockingQueue<T> queue;
|
||||
private final int capacity;
|
||||
|
||||
@@ -45,7 +50,10 @@ public class WriteBuffer<T> {
|
||||
return false;
|
||||
}
|
||||
for (T item : items) {
|
||||
queue.offer(item);
|
||||
if (!queue.offer(item)) {
|
||||
log.warn("WriteBuffer offer rejected despite capacity check — possible concurrent modification");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -14,4 +14,23 @@ public record ExecutionStats(
|
||||
long prevTotalCount,
|
||||
long prevFailedCount,
|
||||
long prevAvgDurationMs,
|
||||
long prevP99LatencyMs) {}
|
||||
long prevP99LatencyMs,
|
||||
double slaCompliance) {
|
||||
|
||||
/** Constructor without SLA compliance (backward-compatible, sets to -1). */
|
||||
public ExecutionStats(long totalCount, long failedCount, long avgDurationMs,
|
||||
long p99LatencyMs, long activeCount, long totalToday,
|
||||
long prevTotalCount, long prevFailedCount,
|
||||
long prevAvgDurationMs, long prevP99LatencyMs) {
|
||||
this(totalCount, failedCount, avgDurationMs, p99LatencyMs, activeCount,
|
||||
totalToday, prevTotalCount, prevFailedCount, prevAvgDurationMs,
|
||||
prevP99LatencyMs, -1.0);
|
||||
}
|
||||
|
||||
/** Return a copy with the given SLA compliance value. */
|
||||
public ExecutionStats withSlaCompliance(double slaCompliance) {
|
||||
return new ExecutionStats(totalCount, failedCount, avgDurationMs, p99LatencyMs,
|
||||
activeCount, totalToday, prevTotalCount, prevFailedCount,
|
||||
prevAvgDurationMs, prevP99LatencyMs, slaCompliance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ public record ExecutionSummary(
|
||||
String diagramContentHash,
|
||||
String highlight,
|
||||
Map<String, String> attributes,
|
||||
boolean hasTraceData
|
||||
boolean hasTraceData,
|
||||
boolean isReplay
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.cameleer3.server.core.storage.StatsStore;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class SearchService {
|
||||
|
||||
@@ -48,4 +49,42 @@ public class SearchService {
|
||||
String routeId, List<String> agentIds) {
|
||||
return statsStore.timeseriesForRoute(from, to, bucketCount, routeId, agentIds);
|
||||
}
|
||||
|
||||
// ── Dashboard-specific queries ────────────────────────────────────────
|
||||
|
||||
public Map<String, StatsTimeseries> timeseriesGroupedByApp(Instant from, Instant to, int bucketCount) {
|
||||
return statsStore.timeseriesGroupedByApp(from, to, bucketCount);
|
||||
}
|
||||
|
||||
public Map<String, StatsTimeseries> timeseriesGroupedByRoute(Instant from, Instant to,
|
||||
int bucketCount, String applicationName) {
|
||||
return statsStore.timeseriesGroupedByRoute(from, to, bucketCount, applicationName);
|
||||
}
|
||||
|
||||
public double slaCompliance(Instant from, Instant to, int thresholdMs,
|
||||
String applicationName, String routeId) {
|
||||
return statsStore.slaCompliance(from, to, thresholdMs, applicationName, routeId);
|
||||
}
|
||||
|
||||
public Map<String, long[]> slaCountsByApp(Instant from, Instant to, int defaultThresholdMs) {
|
||||
return statsStore.slaCountsByApp(from, to, defaultThresholdMs);
|
||||
}
|
||||
|
||||
public Map<String, long[]> slaCountsByRoute(Instant from, Instant to,
|
||||
String applicationName, int thresholdMs) {
|
||||
return statsStore.slaCountsByRoute(from, to, applicationName, thresholdMs);
|
||||
}
|
||||
|
||||
public List<TopError> topErrors(Instant from, Instant to, String applicationName,
|
||||
String routeId, int limit) {
|
||||
return statsStore.topErrors(from, to, applicationName, routeId, limit);
|
||||
}
|
||||
|
||||
public int activeErrorTypes(Instant from, Instant to, String applicationName) {
|
||||
return statsStore.activeErrorTypes(from, to, applicationName);
|
||||
}
|
||||
|
||||
public List<StatsStore.PunchcardCell> punchcard(Instant from, Instant to, String applicationName) {
|
||||
return statsStore.punchcard(from, to, applicationName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cameleer3.server.core.search;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record TopError(
|
||||
String errorType,
|
||||
String routeId,
|
||||
String processorId,
|
||||
long count,
|
||||
double velocity,
|
||||
String trend,
|
||||
Instant lastSeen) {}
|
||||
@@ -30,7 +30,8 @@ public interface ExecutionStore {
|
||||
String rootCauseType, String rootCauseMessage,
|
||||
String traceId, String spanId,
|
||||
String processorsJson,
|
||||
boolean hasTraceData
|
||||
boolean hasTraceData,
|
||||
boolean isReplay
|
||||
) {}
|
||||
|
||||
record ProcessorRecord(
|
||||
|
||||
@@ -2,9 +2,11 @@ package com.cameleer3.server.core.storage;
|
||||
|
||||
import com.cameleer3.server.core.search.ExecutionStats;
|
||||
import com.cameleer3.server.core.search.StatsTimeseries;
|
||||
import com.cameleer3.server.core.search.TopError;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public interface StatsStore {
|
||||
|
||||
@@ -33,4 +35,34 @@ public interface StatsStore {
|
||||
// Per-processor timeseries
|
||||
StatsTimeseries timeseriesForProcessor(Instant from, Instant to, int bucketCount,
|
||||
String routeId, String processorType);
|
||||
|
||||
// Grouped timeseries by application (for L1 dashboard charts)
|
||||
Map<String, StatsTimeseries> timeseriesGroupedByApp(Instant from, Instant to, int bucketCount);
|
||||
|
||||
// Grouped timeseries by route within an application (for L2 dashboard charts)
|
||||
Map<String, StatsTimeseries> timeseriesGroupedByRoute(Instant from, Instant to, int bucketCount,
|
||||
String applicationName);
|
||||
|
||||
// SLA compliance: % of completed exchanges with duration <= thresholdMs
|
||||
double slaCompliance(Instant from, Instant to, int thresholdMs,
|
||||
String applicationName, String routeId);
|
||||
|
||||
// Batch SLA counts by app: {appId -> [compliant, total]}
|
||||
Map<String, long[]> slaCountsByApp(Instant from, Instant to, int defaultThresholdMs);
|
||||
|
||||
// Batch SLA counts by route within an app: {routeId -> [compliant, total]}
|
||||
Map<String, long[]> slaCountsByRoute(Instant from, Instant to, String applicationName,
|
||||
int thresholdMs);
|
||||
|
||||
// Top N errors with velocity trend
|
||||
List<TopError> topErrors(Instant from, Instant to, String applicationName,
|
||||
String routeId, int limit);
|
||||
|
||||
// Count of distinct error types in window
|
||||
int activeErrorTypes(Instant from, Instant to, String applicationName);
|
||||
|
||||
// Punchcard: aggregate by weekday (0=Sun..6=Sat) x hour (0-23) over last 7 days
|
||||
List<PunchcardCell> punchcard(Instant from, Instant to, String applicationName);
|
||||
|
||||
record PunchcardCell(int weekday, int hour, long totalCount, long failedCount) {}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ public record ExecutionDocument(
|
||||
String errorMessage, String errorStacktrace,
|
||||
List<ProcessorDoc> processors,
|
||||
String attributes,
|
||||
boolean hasTraceData
|
||||
boolean hasTraceData,
|
||||
boolean isReplay
|
||||
) {
|
||||
public record ProcessorDoc(
|
||||
String processorId, String processorType, String status,
|
||||
|
||||
415
ui/package-lock.json
generated
415
ui/package-lock.json
generated
@@ -8,13 +8,14 @@
|
||||
"name": "ui",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@cameleer/design-system": "^0.1.20",
|
||||
"@cameleer/design-system": "^0.1.21",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"lucide-react": "^1.7.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router": "^7.13.1",
|
||||
"recharts": "^3.8.1",
|
||||
"swagger-ui-dist": "^5.32.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
@@ -277,9 +278,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cameleer/design-system": {
|
||||
"version": "0.1.20",
|
||||
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.20/design-system-0.1.20.tgz",
|
||||
"integrity": "sha512-3fFW3z3Zg1qjUn6rEYlIeAAhlpEE5z6Udaf5LRPrlcpGCY2kA8EP3QSGQCKZG5HVsr3BtRxfN9TvFHVaZhrw4g==",
|
||||
"version": "0.1.21",
|
||||
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.21/design-system-0.1.21.tgz",
|
||||
"integrity": "sha512-8MZKdnwklBPp4kner2Ij0JU8FfjpaaHZp3JD8nYPx0+BfqktlYb2jBrRzmyLKdMvtTcdcl5wFGd/U2HcvN4+Yg==",
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -711,6 +712,42 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
|
||||
@@ -980,6 +1017,18 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.91.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.2.tgz",
|
||||
@@ -1017,6 +1066,69 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1061,6 +1173,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
|
||||
@@ -1585,6 +1703,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1679,6 +1806,127 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1697,6 +1945,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -1721,6 +1975,16 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -1928,6 +2192,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -2120,6 +2390,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -2160,6 +2440,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -2944,6 +3233,36 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz",
|
||||
@@ -2982,6 +3301,51 @@
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -2992,6 +3356,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -3133,6 +3503,12 @@
|
||||
"@scarf/scarf": "=1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -3290,6 +3666,37 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
|
||||
|
||||
@@ -14,13 +14,14 @@
|
||||
"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.1.20",
|
||||
"@cameleer/design-system": "^0.1.21",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"lucide-react": "^1.7.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router": "^7.13.1",
|
||||
"recharts": "^3.8.1",
|
||||
"swagger-ui-dist": "^5.32.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -46,6 +46,7 @@ export function useRouteMetrics(from?: string, to?: string, appId?: string) {
|
||||
if (!res.ok) throw new Error('Failed to load route metrics');
|
||||
return res.json();
|
||||
},
|
||||
placeholderData: (prev: unknown) => prev,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,25 +154,59 @@ export function useTestExpression() {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Route Control ────────────────────────────────────────────────────────
|
||||
|
||||
export function useSendRouteCommand() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ application, action, routeId }: {
|
||||
application: string
|
||||
action: 'start' | 'stop' | 'suspend' | 'resume'
|
||||
routeId: string
|
||||
}) => {
|
||||
const { data, error } = await api.POST('/agents/groups/{group}/commands', {
|
||||
params: { path: { group: application } },
|
||||
body: { type: 'route-control', payload: { routeId, action, nonce: crypto.randomUUID() } } as any,
|
||||
})
|
||||
if (error) throw new Error('Failed to send route command')
|
||||
return data!
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Replay Exchange ───────────────────────────────────────────────────────
|
||||
|
||||
export interface ReplayResult {
|
||||
status: string
|
||||
message: string
|
||||
data?: string
|
||||
}
|
||||
|
||||
export function useReplayExchange() {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
agentId,
|
||||
routeId,
|
||||
headers,
|
||||
body,
|
||||
originalExchangeId,
|
||||
}: {
|
||||
agentId: string
|
||||
headers: Record<string, string>
|
||||
routeId: string
|
||||
headers?: Record<string, string>
|
||||
body: string
|
||||
}) => {
|
||||
const { data, error } = await api.POST('/agents/{id}/commands', {
|
||||
params: { path: { id: agentId } },
|
||||
body: { type: 'replay', payload: { headers, body } } as any,
|
||||
originalExchangeId?: string
|
||||
}): Promise<ReplayResult> => {
|
||||
const res = await authFetch(`/api/v1/agents/${encodeURIComponent(agentId)}/replay`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ routeId, body, headers: headers ?? {}, originalExchangeId }),
|
||||
})
|
||||
if (error) throw new Error('Failed to send replay command')
|
||||
return data!
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) throw new Error('Agent not found')
|
||||
if (res.status === 504) throw new Error('Replay timed out — agent did not respond')
|
||||
throw new Error('Failed to send replay command')
|
||||
}
|
||||
return res.json() as Promise<ReplayResult>
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
161
ui/src/api/queries/dashboard.ts
Normal file
161
ui/src/api/queries/dashboard.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { config } from '../../config';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { useRefreshInterval } from './use-refresh-interval';
|
||||
|
||||
function authHeaders() {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Cameleer-Protocol-Version': '1',
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string, params?: Record<string, string | undefined>): Promise<T> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null) qs.set(k, v);
|
||||
}
|
||||
}
|
||||
const url = `${config.apiBaseUrl}${path}${qs.toString() ? `?${qs}` : ''}`;
|
||||
const res = await fetch(url, { headers: authHeaders() });
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${path}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Timeseries by app (L1 charts) ─────────────────────────────────────
|
||||
|
||||
export interface TimeseriesBucket {
|
||||
time: string;
|
||||
totalCount: number;
|
||||
failedCount: number;
|
||||
avgDurationMs: number;
|
||||
p99DurationMs: number;
|
||||
activeCount: number;
|
||||
}
|
||||
|
||||
export interface GroupedTimeseries {
|
||||
[key: string]: { buckets: TimeseriesBucket[] };
|
||||
}
|
||||
|
||||
export function useTimeseriesByApp(from?: string, to?: string) {
|
||||
const refetchInterval = useRefreshInterval(30_000);
|
||||
return useQuery({
|
||||
queryKey: ['dashboard', 'timeseries-by-app', from, to],
|
||||
queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-app', {
|
||||
from, to, buckets: '24',
|
||||
}),
|
||||
enabled: !!from,
|
||||
placeholderData: (prev: GroupedTimeseries | undefined) => prev,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Timeseries by route (L2 charts) ───────────────────────────────────
|
||||
|
||||
export function useTimeseriesByRoute(from?: string, to?: string, application?: string) {
|
||||
const refetchInterval = useRefreshInterval(30_000);
|
||||
return useQuery({
|
||||
queryKey: ['dashboard', 'timeseries-by-route', from, to, application],
|
||||
queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-route', {
|
||||
from, to, application, buckets: '24',
|
||||
}),
|
||||
enabled: !!from && !!application,
|
||||
placeholderData: (prev: GroupedTimeseries | undefined) => prev,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Top errors (L2/L3) ────────────────────────────────────────────────
|
||||
|
||||
export interface TopError {
|
||||
errorType: string;
|
||||
routeId: string | null;
|
||||
processorId: string | null;
|
||||
count: number;
|
||||
velocity: number;
|
||||
trend: 'accelerating' | 'stable' | 'decelerating';
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export function useTopErrors(from?: string, to?: string, application?: string, routeId?: string) {
|
||||
const refetchInterval = useRefreshInterval(10_000);
|
||||
return useQuery({
|
||||
queryKey: ['dashboard', 'top-errors', from, to, application, routeId],
|
||||
queryFn: () => fetchJson<TopError[]>('/search/errors/top', {
|
||||
from, to, application, routeId, limit: '5',
|
||||
}),
|
||||
enabled: !!from,
|
||||
placeholderData: (prev: TopError[] | undefined) => prev,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Punchcard (weekday x hour heatmap, rolling 7 days) ────────────────
|
||||
|
||||
export interface PunchcardCell {
|
||||
weekday: number;
|
||||
hour: number;
|
||||
totalCount: number;
|
||||
failedCount: number;
|
||||
}
|
||||
|
||||
export function usePunchcard(application?: string) {
|
||||
const refetchInterval = useRefreshInterval(60_000);
|
||||
return useQuery({
|
||||
queryKey: ['dashboard', 'punchcard', application],
|
||||
queryFn: () => fetchJson<PunchcardCell[]>('/search/stats/punchcard', { application }),
|
||||
placeholderData: (prev: PunchcardCell[] | undefined) => prev ?? [],
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
// ── App settings ──────────────────────────────────────────────────────
|
||||
|
||||
export interface AppSettings {
|
||||
appId: string;
|
||||
slaThresholdMs: number;
|
||||
healthErrorWarn: number;
|
||||
healthErrorCrit: number;
|
||||
healthSlaWarn: number;
|
||||
healthSlaCrit: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function useAppSettings(appId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['app-settings', appId],
|
||||
queryFn: () => fetchJson<AppSettings>(`/admin/app-settings/${appId}`),
|
||||
enabled: !!appId,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAllAppSettings() {
|
||||
return useQuery({
|
||||
queryKey: ['app-settings', 'all'],
|
||||
queryFn: () => fetchJson<AppSettings[]>('/admin/app-settings'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAppSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ appId, settings }: { appId: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
const res = await fetch(`${config.apiBaseUrl}/admin/app-settings/${appId}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update app settings');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
487
ui/src/api/schema.d.ts
vendored
487
ui/src/api/schema.d.ts
vendored
@@ -122,6 +122,25 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/app-settings/{appId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get settings for a specific application (returns defaults if not configured) */
|
||||
get: operations["getByAppId"];
|
||||
/** Create or update settings for an application */
|
||||
put: operations["update"];
|
||||
post?: never;
|
||||
/** Delete application settings (reverts to defaults) */
|
||||
delete: operations["delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/search/executions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -594,7 +613,7 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Aggregate execution stats (P99 latency, active count) */
|
||||
/** Aggregate execution stats (P99 latency, active count, SLA compliance) */
|
||||
get: operations["stats"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
@@ -621,6 +640,74 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/search/stats/timeseries/by-route": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Timeseries grouped by route for an application */
|
||||
get: operations["timeseriesByRoute"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/search/stats/timeseries/by-app": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Timeseries grouped by application */
|
||||
get: operations["timeseriesByApp"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/search/stats/punchcard": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Transaction punchcard: weekday x hour grid (rolling 7 days) */
|
||||
get: operations["punchcard"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/search/errors/top": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Top N errors with velocity trend */
|
||||
get: operations["topErrors"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/routes/metrics": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -725,7 +812,7 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get exchange snapshot for a specific processor */
|
||||
/** Get exchange snapshot for a specific processor by index */
|
||||
get: operations["getProcessorSnapshot"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
@@ -742,8 +829,8 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get exchange snapshot for a processor by processorId */
|
||||
get: operations["getProcessorSnapshotById"];
|
||||
/** Get exchange snapshot for a specific processor by processorId */
|
||||
get: operations["processorSnapshotById"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
@@ -812,6 +899,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/config/{application}/processor-routes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get processor to route mapping
|
||||
* @description Returns a map of processorId → routeId for all processors seen in this application
|
||||
*/
|
||||
get: operations["getProcessorRouteMapping"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/auth/oidc/config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1092,6 +1199,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/app-settings": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List all application settings */
|
||||
get: operations["getAll"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/opensearch/indices/{name}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1127,7 +1251,7 @@ export interface components {
|
||||
tracedProcessors?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
logForwardingLevel?: string;
|
||||
applicationLogLevel?: string;
|
||||
taps?: components["schemas"]["TapDefinition"][];
|
||||
/** Format: int32 */
|
||||
tapVersion?: number;
|
||||
@@ -1135,6 +1259,10 @@ export interface components {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
compressSuccess?: boolean;
|
||||
agentLogLevel?: string;
|
||||
routeSamplingRates?: {
|
||||
[key: string]: number;
|
||||
};
|
||||
};
|
||||
TapDefinition: {
|
||||
tapId?: string;
|
||||
@@ -1284,6 +1412,51 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
parentGroupId?: string;
|
||||
};
|
||||
/** @description Per-application dashboard settings */
|
||||
AppSettingsRequest: {
|
||||
/**
|
||||
* Format: int32
|
||||
* @description SLA duration threshold in milliseconds
|
||||
*/
|
||||
slaThresholdMs: number;
|
||||
/**
|
||||
* Format: double
|
||||
* @description Error rate % threshold for warning (yellow) health dot
|
||||
*/
|
||||
healthErrorWarn: number;
|
||||
/**
|
||||
* Format: double
|
||||
* @description Error rate % threshold for critical (red) health dot
|
||||
*/
|
||||
healthErrorCrit: number;
|
||||
/**
|
||||
* Format: double
|
||||
* @description SLA compliance % threshold for warning (yellow) health dot
|
||||
*/
|
||||
healthSlaWarn: number;
|
||||
/**
|
||||
* Format: double
|
||||
* @description SLA compliance % threshold for critical (red) health dot
|
||||
*/
|
||||
healthSlaCrit: number;
|
||||
};
|
||||
AppSettings: {
|
||||
appId?: string;
|
||||
/** Format: int32 */
|
||||
slaThresholdMs?: number;
|
||||
/** Format: double */
|
||||
healthErrorWarn?: number;
|
||||
/** Format: double */
|
||||
healthErrorCrit?: number;
|
||||
/** Format: double */
|
||||
healthSlaWarn?: number;
|
||||
/** Format: double */
|
||||
healthSlaCrit?: number;
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
/** Format: date-time */
|
||||
updatedAt?: string;
|
||||
};
|
||||
SearchRequest: {
|
||||
status?: string;
|
||||
/** Format: date-time */
|
||||
@@ -1330,7 +1503,8 @@ export interface components {
|
||||
attributes: {
|
||||
[key: string]: string;
|
||||
};
|
||||
hasTraceData?: boolean;
|
||||
hasTraceData: boolean;
|
||||
isReplay: boolean;
|
||||
};
|
||||
SearchResultExecutionSummary: {
|
||||
data: components["schemas"]["ExecutionSummary"][];
|
||||
@@ -1508,6 +1682,8 @@ export interface components {
|
||||
prevAvgDurationMs: number;
|
||||
/** Format: int64 */
|
||||
prevP99LatencyMs: number;
|
||||
/** Format: double */
|
||||
slaCompliance: number;
|
||||
};
|
||||
StatsTimeseries: {
|
||||
buckets: components["schemas"]["TimeseriesBucket"][];
|
||||
@@ -1526,6 +1702,28 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
activeCount: number;
|
||||
};
|
||||
PunchcardCell: {
|
||||
/** Format: int32 */
|
||||
weekday?: number;
|
||||
/** Format: int32 */
|
||||
hour?: number;
|
||||
/** Format: int64 */
|
||||
totalCount?: number;
|
||||
/** Format: int64 */
|
||||
failedCount?: number;
|
||||
};
|
||||
TopError: {
|
||||
errorType?: string;
|
||||
routeId?: string;
|
||||
processorId?: string;
|
||||
/** Format: int64 */
|
||||
count?: number;
|
||||
/** Format: double */
|
||||
velocity?: number;
|
||||
trend?: string;
|
||||
/** Format: date-time */
|
||||
lastSeen?: string;
|
||||
};
|
||||
/** @description Aggregated route performance metrics */
|
||||
RouteMetrics: {
|
||||
routeId: string;
|
||||
@@ -1543,6 +1741,8 @@ export interface components {
|
||||
/** Format: double */
|
||||
throughputPerSec: number;
|
||||
sparkline: number[];
|
||||
/** Format: double */
|
||||
slaCompliance: number;
|
||||
};
|
||||
ProcessorMetrics: {
|
||||
processorId: string;
|
||||
@@ -1586,6 +1786,8 @@ export interface components {
|
||||
exchangeCount: number;
|
||||
/** Format: date-time */
|
||||
lastSeen: string;
|
||||
/** @description The from() endpoint URI, e.g. 'direct:processOrder' */
|
||||
fromEndpointUri: string;
|
||||
};
|
||||
/** @description Application log entry from OpenSearch */
|
||||
LogEntryResponse: {
|
||||
@@ -1627,12 +1829,12 @@ export interface components {
|
||||
attributes: {
|
||||
[key: string]: string;
|
||||
};
|
||||
errorType?: string;
|
||||
errorCategory?: string;
|
||||
rootCauseType?: string;
|
||||
rootCauseMessage?: string;
|
||||
traceId?: string;
|
||||
spanId?: string;
|
||||
errorType: string;
|
||||
errorCategory: string;
|
||||
rootCauseType: string;
|
||||
rootCauseMessage: string;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
};
|
||||
ProcessorNode: {
|
||||
processorId: string;
|
||||
@@ -1644,30 +1846,32 @@ export interface components {
|
||||
endTime: string;
|
||||
/** Format: int64 */
|
||||
durationMs: number;
|
||||
/** Format: int32 */
|
||||
loopIndex?: number;
|
||||
/** Format: int32 */
|
||||
loopSize?: number;
|
||||
/** Format: int32 */
|
||||
splitIndex?: number;
|
||||
/** Format: int32 */
|
||||
splitSize?: number;
|
||||
/** Format: int32 */
|
||||
multicastIndex?: number;
|
||||
errorMessage: string;
|
||||
errorStackTrace: string;
|
||||
attributes: {
|
||||
[key: string]: string;
|
||||
};
|
||||
resolvedEndpointUri?: string;
|
||||
errorType?: string;
|
||||
errorCategory?: string;
|
||||
rootCauseType?: string;
|
||||
rootCauseMessage?: string;
|
||||
errorHandlerType?: string;
|
||||
circuitBreakerState?: string;
|
||||
fallbackTriggered?: boolean;
|
||||
hasTraceData?: boolean;
|
||||
/** Format: int32 */
|
||||
loopIndex: number;
|
||||
/** Format: int32 */
|
||||
loopSize: number;
|
||||
/** Format: int32 */
|
||||
splitIndex: number;
|
||||
/** Format: int32 */
|
||||
splitSize: number;
|
||||
/** Format: int32 */
|
||||
multicastIndex: number;
|
||||
resolvedEndpointUri: string;
|
||||
errorType: string;
|
||||
errorCategory: string;
|
||||
rootCauseType: string;
|
||||
rootCauseMessage: string;
|
||||
errorHandlerType: string;
|
||||
circuitBreakerState: string;
|
||||
fallbackTriggered: boolean;
|
||||
filterMatched: boolean;
|
||||
duplicateMessage: boolean;
|
||||
hasTraceData: boolean;
|
||||
children: components["schemas"]["ProcessorNode"][];
|
||||
};
|
||||
DiagramLayout: {
|
||||
@@ -2527,6 +2731,74 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getByAppId: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
appId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["AppSettings"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
appId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AppSettingsRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["AppSettings"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
appId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
searchGet: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -3512,6 +3784,107 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
timeseriesByRoute: {
|
||||
parameters: {
|
||||
query: {
|
||||
from: string;
|
||||
to?: string;
|
||||
buckets?: number;
|
||||
application: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": {
|
||||
[key: string]: components["schemas"]["StatsTimeseries"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
timeseriesByApp: {
|
||||
parameters: {
|
||||
query: {
|
||||
from: string;
|
||||
to?: string;
|
||||
buckets?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": {
|
||||
[key: string]: components["schemas"]["StatsTimeseries"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
punchcard: {
|
||||
parameters: {
|
||||
query?: {
|
||||
application?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["PunchcardCell"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
topErrors: {
|
||||
parameters: {
|
||||
query: {
|
||||
from: string;
|
||||
to?: string;
|
||||
application?: string;
|
||||
routeId?: string;
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["TopError"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getMetrics: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -3680,7 +4053,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getProcessorSnapshotById: {
|
||||
processorSnapshotById: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@@ -3721,8 +4094,7 @@ export interface operations {
|
||||
query: {
|
||||
application: string;
|
||||
routeId: string;
|
||||
/** @description Layout direction: LR (left-to-right) or TB (top-to-bottom) */
|
||||
direction?: "LR" | "TB";
|
||||
direction?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -3753,8 +4125,7 @@ export interface operations {
|
||||
renderDiagram: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Layout direction: LR (left-to-right) or TB (top-to-bottom) */
|
||||
direction?: "LR" | "TB";
|
||||
direction?: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
@@ -3805,6 +4176,30 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getProcessorRouteMapping: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
application: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Mapping returned */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getConfig_2: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4199,6 +4594,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getAll: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["AppSettings"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteIndex: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -28,19 +28,6 @@ const TABS: { key: DetailTab; label: string }[] = [
|
||||
{ key: 'log', label: 'Log' },
|
||||
];
|
||||
|
||||
function formatDuration(ms: number | undefined): string {
|
||||
if (ms === undefined || ms === null) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function statusClass(status: string): string {
|
||||
const s = status?.toUpperCase();
|
||||
if (s === 'COMPLETED') return styles.statusCompleted;
|
||||
if (s === 'FAILED') return styles.statusFailed;
|
||||
return '';
|
||||
}
|
||||
|
||||
export function DetailPanel({
|
||||
selectedProcessor,
|
||||
executionDetail,
|
||||
@@ -99,22 +86,11 @@ export function DetailPanel({
|
||||
if (activeTab === 'output' && !hasOutput) setActiveTab('info');
|
||||
}, [hasHeaders, hasInput, hasOutput, activeTab]);
|
||||
|
||||
// Header display
|
||||
const headerName = selectedProcessor ? selectedProcessor.processorType : 'Exchange';
|
||||
const headerStatus = selectedProcessor ? selectedProcessor.status : executionDetail.status;
|
||||
const headerId = selectedProcessor ? selectedProcessor.processorId : executionDetail.executionId;
|
||||
const headerDuration = selectedProcessor ? selectedProcessor.durationMs : executionDetail.durationMs;
|
||||
|
||||
return (
|
||||
<div className={styles.detailPanel}>
|
||||
{/* Processor / Exchange header bar */}
|
||||
{/* Header bar */}
|
||||
<div className={styles.processorHeader}>
|
||||
<span className={styles.processorName}>{headerName}</span>
|
||||
<span className={`${styles.statusBadge} ${statusClass(headerStatus)}`}>
|
||||
{headerStatus}
|
||||
</span>
|
||||
<span className={styles.processorId}>{headerId}</span>
|
||||
<span className={styles.processorDuration}>{formatDuration(headerDuration)}</span>
|
||||
<span className={styles.processorName}>{selectedProcessor ? 'Processor Details' : 'Exchange Details'}</span>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
|
||||
@@ -61,6 +61,28 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.downloadBtn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 10;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border, #E4DFD8);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-surface, #FFFFFF);
|
||||
color: var(--text-secondary, #5C5347);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.downloadBtn:hover {
|
||||
opacity: 1;
|
||||
background: var(--bg-hover, #F5F0EA);
|
||||
}
|
||||
|
||||
.splitter {
|
||||
height: 4px;
|
||||
background: var(--border, #E4DFD8);
|
||||
|
||||
@@ -20,15 +20,46 @@ interface ExecutionDiagramProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ITERATION_WRAPPER_TYPES = new Set([
|
||||
'loopIteration', 'splitIteration', 'multicastBranch',
|
||||
]);
|
||||
|
||||
function wrapperIndex(proc: ProcessorNode): number | undefined {
|
||||
return proc.loopIndex ?? proc.splitIndex ?? proc.multicastIndex ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a processor in the tree, respecting iteration filtering.
|
||||
* Only recurses into the selected iteration wrapper so the returned
|
||||
* ProcessorNode has data from the correct iteration.
|
||||
*/
|
||||
function findProcessorInTree(
|
||||
nodes: ProcessorNode[] | undefined,
|
||||
processorId: string | null,
|
||||
iterationState?: Map<string, import('./types').IterationInfo>,
|
||||
parentId?: string,
|
||||
): ProcessorNode | null {
|
||||
if (!nodes || !processorId) return null;
|
||||
for (const n of nodes) {
|
||||
if (!n.processorId) continue;
|
||||
|
||||
// Iteration wrapper: only recurse into the selected iteration
|
||||
if (ITERATION_WRAPPER_TYPES.has(n.processorType)) {
|
||||
if (parentId && iterationState?.has(parentId)) {
|
||||
const info = iterationState.get(parentId)!;
|
||||
const idx = wrapperIndex(n);
|
||||
if (idx != null && idx !== info.current) continue;
|
||||
}
|
||||
if (n.children) {
|
||||
const found = findProcessorInTree(n.children, processorId, iterationState, n.processorId);
|
||||
if (found) return found;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (n.processorId === processorId) return n;
|
||||
if (n.children) {
|
||||
const found = findProcessorInTree(n.children, processorId);
|
||||
const found = findProcessorInTree(n.children, processorId, iterationState, n.processorId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
@@ -120,6 +151,18 @@ export function ExecutionDiagram({
|
||||
}
|
||||
}, [detail?.processors]);
|
||||
|
||||
const handleDownloadJson = useCallback(() => {
|
||||
if (!detail) return;
|
||||
const json = JSON.stringify(detail, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `execution-${executionId}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [detail, executionId]);
|
||||
|
||||
// Loading state
|
||||
if (detailLoading || (detail && diagramLoading)) {
|
||||
return (
|
||||
@@ -158,6 +201,13 @@ export function ExecutionDiagram({
|
||||
<div ref={containerRef} className={`${styles.executionDiagram} ${className ?? ''}`}>
|
||||
{/* Diagram area */}
|
||||
<div className={styles.diagramArea} style={{ height: `${splitPercent}%` }}>
|
||||
<button
|
||||
className={styles.downloadBtn}
|
||||
onClick={handleDownloadJson}
|
||||
title="Download execution JSON"
|
||||
>
|
||||
↓ JSON
|
||||
</button>
|
||||
<ProcessDiagram
|
||||
application={detail.applicationName}
|
||||
routeId={detail.routeId}
|
||||
@@ -185,7 +235,11 @@ export function ExecutionDiagram({
|
||||
{/* Detail panel */}
|
||||
<div className={styles.detailArea} style={{ height: `${100 - splitPercent}%` }}>
|
||||
<DetailPanel
|
||||
selectedProcessor={findProcessorInTree(detail.processors, selectedProcessorId || null)}
|
||||
selectedProcessor={
|
||||
selectedProcessorId && overlay.has(selectedProcessorId)
|
||||
? findProcessorInTree(detail.processors, selectedProcessorId, iterationState)
|
||||
: null
|
||||
}
|
||||
executionDetail={detail}
|
||||
executionId={executionId}
|
||||
onSelectProcessor={setSelectedProcessorId}
|
||||
|
||||
@@ -12,6 +12,10 @@ export interface NodeExecutionState {
|
||||
hasTraceData?: boolean;
|
||||
/** Runtime-resolved endpoint URI (for TO_DYNAMIC, etc.) */
|
||||
resolvedEndpointUri?: string;
|
||||
/** Filter processor: true if predicate matched, false if message was rejected */
|
||||
filterMatched?: boolean;
|
||||
/** Idempotent consumer: true if duplicate message detected and children skipped */
|
||||
duplicateMessage?: boolean;
|
||||
}
|
||||
|
||||
export interface IterationInfo {
|
||||
|
||||
@@ -61,6 +61,8 @@ function buildOverlay(
|
||||
subRouteFailed: subRouteFailed || undefined,
|
||||
hasTraceData: !!proc.hasTraceData,
|
||||
resolvedEndpointUri: proc.resolvedEndpointUri || undefined,
|
||||
filterMatched: proc.filterMatched ?? undefined,
|
||||
duplicateMessage: proc.duplicateMessage ?? undefined,
|
||||
});
|
||||
|
||||
// Recurse into children
|
||||
|
||||
@@ -210,7 +210,21 @@ function LayoutContent() {
|
||||
|
||||
const handlePaletteSelect = useCallback((result: any) => {
|
||||
if (result.path) {
|
||||
navigate(result.path, { state: result.path ? { sidebarReveal: result.path } : undefined });
|
||||
const state: Record<string, unknown> = { sidebarReveal: result.path };
|
||||
|
||||
// For exchange/attribute results, pass selectedExchange in state
|
||||
if (result.category === 'exchange' || result.category === 'attribute') {
|
||||
const parts = result.path.split('/').filter(Boolean);
|
||||
if (parts.length === 4 && parts[0] === 'exchanges') {
|
||||
state.selectedExchange = {
|
||||
executionId: parts[3],
|
||||
applicationName: parts[1],
|
||||
routeId: parts[2],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
navigate(result.path, { state });
|
||||
}
|
||||
setPaletteOpen(false);
|
||||
}, [navigate, setPaletteOpen]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
|
||||
import type { NodeConfig } from './types';
|
||||
import type { NodeConfig, LatencyHeatmapEntry } from './types';
|
||||
import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types';
|
||||
import { colorForType, isCompoundType, iconForType, type IconElement } from './node-colors';
|
||||
import { DiagramNode } from './DiagramNode';
|
||||
@@ -27,6 +27,7 @@ interface CompoundNodeProps {
|
||||
iterationState?: Map<string, IterationInfo>;
|
||||
/** Called when user changes iteration on a compound stepper */
|
||||
onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void;
|
||||
latencyHeatmap?: Map<string, LatencyHeatmapEntry>;
|
||||
onNodeClick: (nodeId: string) => void;
|
||||
onNodeDoubleClick?: (nodeId: string) => void;
|
||||
onNodeEnter: (nodeId: string) => void;
|
||||
@@ -36,7 +37,7 @@ interface CompoundNodeProps {
|
||||
export function CompoundNode({
|
||||
node, edges, parentX = 0, parentY = 0,
|
||||
selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
|
||||
overlayActive, iterationState, onIterationChange,
|
||||
overlayActive, iterationState, onIterationChange, latencyHeatmap,
|
||||
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
|
||||
}: CompoundNodeProps) {
|
||||
const x = (node.x ?? 0) - parentX;
|
||||
@@ -61,10 +62,19 @@ export function CompoundNode({
|
||||
|
||||
const childProps = {
|
||||
edges, selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
|
||||
overlayActive, iterationState, onIterationChange,
|
||||
overlayActive, iterationState, onIterationChange, latencyHeatmap,
|
||||
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
|
||||
};
|
||||
|
||||
// Gate state: filter rejected or idempotent duplicate → amber container
|
||||
const ownState = node.id ? executionOverlay?.get(node.id) : undefined;
|
||||
const isGated = ownState?.filterMatched === false || ownState?.duplicateMessage === true;
|
||||
const effectiveColor = isGated ? 'var(--amber)' : color;
|
||||
|
||||
// Dim compound when overlay is active but neither the compound nor any
|
||||
// descendant was executed in the current iteration.
|
||||
const isSkipped = overlayActive && !ownState && !hasExecutedDescendant(node, executionOverlay);
|
||||
|
||||
// _TRY_BODY / _CB_MAIN: transparent wrapper — no header, no border, just layout
|
||||
if (node.type === '_TRY_BODY' || node.type === '_CB_MAIN') {
|
||||
return (
|
||||
@@ -79,7 +89,7 @@ export function CompoundNode({
|
||||
if (node.type === '_CB_FALLBACK') {
|
||||
const fallbackColor = '#7C3AED'; // EIP purple
|
||||
return (
|
||||
<g data-node-id={node.id} transform={`translate(${x}, ${y})`}>
|
||||
<g data-node-id={node.id} transform={`translate(${x}, ${y})`} opacity={isSkipped ? 0.35 : undefined}>
|
||||
<rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
|
||||
fill={fallbackColor} fillOpacity={0.06} />
|
||||
<rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
|
||||
@@ -100,7 +110,7 @@ export function CompoundNode({
|
||||
: (node.label ? `finally: ${node.label}` : 'finally');
|
||||
|
||||
return (
|
||||
<g data-node-id={node.id} transform={`translate(${x}, ${y})`}>
|
||||
<g data-node-id={node.id} transform={`translate(${x}, ${y})`} opacity={isSkipped ? 0.35 : undefined}>
|
||||
{/* Tinted background */}
|
||||
<rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
|
||||
fill={color} fillOpacity={0.06} />
|
||||
@@ -117,9 +127,10 @@ export function CompoundNode({
|
||||
);
|
||||
}
|
||||
|
||||
// Default compound rendering (DO_TRY, EIP_CHOICE, etc.)
|
||||
// Default compound rendering (DO_TRY, EIP_CHOICE, EIP_FILTER, EIP_IDEMPOTENT_CONSUMER, etc.)
|
||||
const containerFill = isGated ? 'var(--amber-bg)' : 'white';
|
||||
return (
|
||||
<g data-node-id={node.id} transform={`translate(${x}, ${y})`}>
|
||||
<g data-node-id={node.id} transform={`translate(${x}, ${y})`} opacity={isSkipped ? 0.35 : undefined}>
|
||||
{/* Container body */}
|
||||
<rect
|
||||
x={0}
|
||||
@@ -127,14 +138,14 @@ export function CompoundNode({
|
||||
width={w}
|
||||
height={h}
|
||||
rx={CORNER_RADIUS}
|
||||
fill="white"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
fill={containerFill}
|
||||
stroke={effectiveColor}
|
||||
strokeWidth={isGated ? 2 : 1.5}
|
||||
/>
|
||||
|
||||
{/* Colored header bar */}
|
||||
<rect x={0} y={0} width={w} height={HEADER_HEIGHT} rx={CORNER_RADIUS} fill={color} />
|
||||
<rect x={CORNER_RADIUS} y={CORNER_RADIUS} width={w - CORNER_RADIUS * 2} height={HEADER_HEIGHT - CORNER_RADIUS} fill={color} />
|
||||
<rect x={0} y={0} width={w} height={HEADER_HEIGHT} rx={CORNER_RADIUS} fill={effectiveColor} />
|
||||
<rect x={CORNER_RADIUS} y={CORNER_RADIUS} width={w - CORNER_RADIUS * 2} height={HEADER_HEIGHT - CORNER_RADIUS} fill={effectiveColor} />
|
||||
|
||||
{/* Header icon (left-aligned) */}
|
||||
<g transform={`translate(6, ${HEADER_HEIGHT / 2 - 5}) scale(0.417)`}>
|
||||
@@ -243,6 +254,7 @@ function renderChildren(
|
||||
config={child.id ? props.nodeConfigs?.get(child.id) : undefined}
|
||||
executionState={props.executionOverlay?.get(child.id ?? '')}
|
||||
overlayActive={props.overlayActive}
|
||||
heatmapEntry={child.id ? props.latencyHeatmap?.get(child.id) : undefined}
|
||||
onClick={() => child.id && props.onNodeClick(child.id)}
|
||||
onDoubleClick={() => child.id && props.onNodeDoubleClick?.(child.id)}
|
||||
onMouseEnter={() => child.id && props.onNodeEnter(child.id)}
|
||||
@@ -260,3 +272,15 @@ function collectIds(nodes: DiagramNodeType[], set: Set<string>) {
|
||||
if (n.children) collectIds(n.children, set);
|
||||
}
|
||||
}
|
||||
|
||||
function hasExecutedDescendant(
|
||||
node: DiagramNodeType,
|
||||
overlay?: Map<string, NodeExecutionState>,
|
||||
): boolean {
|
||||
if (!overlay || !node.children) return false;
|
||||
for (const child of node.children) {
|
||||
if (child.id && overlay.has(child.id)) return true;
|
||||
if (child.children && hasExecutedDescendant(child, overlay)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
|
||||
import type { NodeConfig } from './types';
|
||||
import type { NodeConfig, LatencyHeatmapEntry } from './types';
|
||||
import type { NodeExecutionState } from '../ExecutionDiagram/types';
|
||||
import { colorForType, iconForType, type IconElement } from './node-colors';
|
||||
import { ConfigBadge } from './ConfigBadge';
|
||||
|
||||
const TOP_BAR_HEIGHT = 6;
|
||||
const TEXT_LEFT = 32;
|
||||
@@ -16,12 +16,27 @@ interface DiagramNodeProps {
|
||||
config?: NodeConfig;
|
||||
executionState?: NodeExecutionState;
|
||||
overlayActive?: boolean;
|
||||
heatmapEntry?: LatencyHeatmapEntry;
|
||||
onClick: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
}
|
||||
|
||||
/** Interpolate green (120°) → yellow (60°) → red (0°) based on pctOfRoute */
|
||||
function heatmapColor(pct: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, pct));
|
||||
// 0% → hue 120 (green), 50% → hue 60 (yellow), 100% → hue 0 (red)
|
||||
const hue = 120 - (clamped / 100) * 120;
|
||||
return `hsl(${Math.round(hue)}, 70%, 92%)`;
|
||||
}
|
||||
|
||||
function heatmapBorderColor(pct: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, pct));
|
||||
const hue = 120 - (clamped / 100) * 120;
|
||||
return `hsl(${Math.round(hue)}, 60%, 50%)`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
@@ -29,7 +44,7 @@ function formatDuration(ms: number): string {
|
||||
|
||||
export function DiagramNode({
|
||||
node, isHovered, isSelected, config,
|
||||
executionState, overlayActive,
|
||||
executionState, overlayActive, heatmapEntry,
|
||||
onClick, onDoubleClick, onMouseEnter, onMouseLeave,
|
||||
}: DiagramNodeProps) {
|
||||
const x = node.x ?? 0;
|
||||
@@ -49,7 +64,7 @@ export function DiagramNode({
|
||||
const isFailed = executionState?.status === 'FAILED';
|
||||
const isSkipped = overlayActive && !executionState;
|
||||
|
||||
// Colors based on execution state
|
||||
// Colors based on execution state (heatmap takes priority when no execution overlay)
|
||||
let cardFill = isHovered ? '#F5F0EA' : 'white';
|
||||
let borderStroke = isHovered || isSelected ? color : '#E4DFD8';
|
||||
let borderWidth = isHovered || isSelected ? 1.5 : 1;
|
||||
@@ -67,6 +82,11 @@ export function DiagramNode({
|
||||
borderWidth = 2;
|
||||
topBarColor = '#C0392B';
|
||||
labelColor = '#C0392B';
|
||||
} else if (heatmapEntry && !overlayActive) {
|
||||
cardFill = heatmapColor(heatmapEntry.pctOfRoute);
|
||||
borderStroke = heatmapBorderColor(heatmapEntry.pctOfRoute);
|
||||
borderWidth = 1.5;
|
||||
topBarColor = heatmapBorderColor(heatmapEntry.pctOfRoute);
|
||||
}
|
||||
|
||||
const statusColor = isCompleted ? '#3D7C47' : isFailed ? '#C0392B' : undefined;
|
||||
@@ -138,7 +158,7 @@ export function DiagramNode({
|
||||
{detail}
|
||||
</text>
|
||||
)}
|
||||
<text x={TEXT_LEFT} y={h - 5} fill="#1A7F8E" fontSize={9} fontStyle="italic">
|
||||
<text x={TEXT_LEFT} y={TOP_BAR_HEIGHT + (detail && detail !== typeName ? 35 : 24)} fill="#1A7F8E" fontSize={9} fontStyle="italic">
|
||||
→ {resolvedUri.split('?')[0]}
|
||||
</text>
|
||||
</>
|
||||
@@ -156,30 +176,92 @@ export function DiagramNode({
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* Config badges */}
|
||||
{(config || executionState?.hasTraceData) && (
|
||||
<ConfigBadge nodeWidth={w} config={config ?? {}} hasTraceData={executionState?.hasTraceData} />
|
||||
)}
|
||||
{/* Inline badges row: hasTrace, hasTap, status — inside card, top-right */}
|
||||
{(() => {
|
||||
const BADGE_R = 6;
|
||||
const BADGE_D = BADGE_R * 2;
|
||||
const BADGE_GAP = 3;
|
||||
const cy = TOP_BAR_HEIGHT + BADGE_R + 2;
|
||||
const showTrace = config?.traceEnabled || executionState?.hasTraceData;
|
||||
const showTap = !!config?.tapExpression;
|
||||
if (!showTrace && !showTap && !isCompleted && !isFailed) return null;
|
||||
const badges: React.ReactNode[] = [];
|
||||
let slot = 0;
|
||||
|
||||
{/* Execution overlay: status badge inside card, top-right corner */}
|
||||
{isCompleted && (
|
||||
// Status badge (rightmost, only during overlay)
|
||||
const statusCx = w - BADGE_R - 4;
|
||||
if (isCompleted) {
|
||||
badges.push(
|
||||
<g key="status">
|
||||
<circle cx={statusCx} cy={cy} r={BADGE_R} fill="#3D7C47" />
|
||||
<path d={`M${statusCx - 3} ${cy} l2 2 4-4`} fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
);
|
||||
slot++;
|
||||
} else if (isFailed) {
|
||||
badges.push(
|
||||
<g key="status">
|
||||
<circle cx={statusCx} cy={cy} r={BADGE_R} fill="none" stroke="#C0392B" strokeWidth={2} opacity={0.5}>
|
||||
<animate attributeName="r" values="6;14" dur="1.5s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.5;0" dur="1.5s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx={statusCx} cy={cy} r={BADGE_R} fill="none" stroke="#C0392B" strokeWidth={2} opacity={0.5}>
|
||||
<animate attributeName="r" values="6;14" dur="1.5s" begin="0.75s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.5;0" dur="1.5s" begin="0.75s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx={statusCx} cy={cy} r={BADGE_R} fill="#C0392B" />
|
||||
<path d={`M${statusCx} ${cy - 3} v4 M${statusCx} ${cy + 2.5} v0.5`} fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round" />
|
||||
</g>
|
||||
);
|
||||
slot++;
|
||||
}
|
||||
|
||||
// Tap badge (before status)
|
||||
if (showTap) {
|
||||
const tapCx = statusCx - slot * (BADGE_D + BADGE_GAP);
|
||||
badges.push(
|
||||
<g key="tap">
|
||||
<circle cx={tapCx} cy={cy} r={BADGE_R} fill="#7C3AED" />
|
||||
<g transform={`translate(${tapCx - 5}, ${cy - 5})`} stroke="white" strokeWidth={1.4} fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 1 C5 1 2 4.5 2 6.5a3 3 0 006 0C8 4.5 5 1 5 1z" />
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
slot++;
|
||||
}
|
||||
|
||||
// Trace badge (leftmost)
|
||||
if (showTrace) {
|
||||
const traceCx = statusCx - slot * (BADGE_D + BADGE_GAP);
|
||||
const tracePulse = overlayActive && executionState?.hasTraceData;
|
||||
const traceHasData = executionState?.hasTraceData;
|
||||
badges.push(
|
||||
<g key="trace">
|
||||
{tracePulse && (
|
||||
<>
|
||||
<circle cx={w - 10} cy={TOP_BAR_HEIGHT + 8} r={6} fill="#3D7C47" />
|
||||
<path
|
||||
d={`M${w - 13} ${TOP_BAR_HEIGHT + 8} l2 2 4-4`}
|
||||
fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isFailed && (
|
||||
<>
|
||||
<circle cx={w - 10} cy={TOP_BAR_HEIGHT + 8} r={6} fill="#C0392B" />
|
||||
<path
|
||||
d={`M${w - 10} ${TOP_BAR_HEIGHT + 5} v4 M${w - 10} ${TOP_BAR_HEIGHT + 10.5} v0.5`}
|
||||
fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round"
|
||||
/>
|
||||
<circle cx={traceCx} cy={cy} r={BADGE_R} fill="none" stroke="#1A7F8E" strokeWidth={2} opacity={0.5}>
|
||||
<animate attributeName="r" values={`${BADGE_R};${BADGE_R + 8}`} dur="1.5s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.5;0" dur="1.5s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx={traceCx} cy={cy} r={BADGE_R} fill="none" stroke="#1A7F8E" strokeWidth={2} opacity={0.5}>
|
||||
<animate attributeName="r" values={`${BADGE_R};${BADGE_R + 8}`} dur="1.5s" begin="0.75s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.5;0" dur="1.5s" begin="0.75s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</>
|
||||
)}
|
||||
<circle cx={traceCx} cy={cy} r={BADGE_R} fill={traceHasData ? '#1A7F8E' : '#1A7F8E'} opacity={traceHasData ? 1 : 0.2} />
|
||||
<g transform={`translate(${traceCx - 5}, ${cy - 5}) scale(${10/24})`} stroke={traceHasData ? 'white' : '#1A7F8E'} strokeWidth={2.4} fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 16v-2.38C4 11.5 2.97 10.5 3 8c.03-2.72 1.49-6 4.5-6C9.37 2 10 3.8 10 5.5c0 3.11-2 5.66-2 8.68V16a2 2 0 1 1-4 0Z" />
|
||||
<path d="M20 20v-2.38c0-2.12 1.03-3.12 1-5.62-.03-2.72-1.49-6-4.5-6C14.63 6 14 7.8 14 9.5c0 3.11 2 5.66 2 8.68V20a2 2 0 1 0 4 0Z" />
|
||||
<path d="M16 17h4" />
|
||||
<path d="M4 13h4" />
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{badges}</>;
|
||||
})()}
|
||||
|
||||
{/* Execution overlay: duration text at bottom-right */}
|
||||
{executionState && statusColor && (
|
||||
@@ -195,6 +277,20 @@ export function DiagramNode({
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Heatmap: avg duration label at bottom-right */}
|
||||
{heatmapEntry && !overlayActive && !executionState && (
|
||||
<text
|
||||
x={w - 6}
|
||||
y={h - 4}
|
||||
textAnchor="end"
|
||||
fill={heatmapBorderColor(heatmapEntry.pctOfRoute)}
|
||||
fontSize={9}
|
||||
fontWeight={600}
|
||||
>
|
||||
{formatDuration(heatmapEntry.avgDurationMs)}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Sub-route failure: drill-down arrow at bottom-left */}
|
||||
{isFailed && executionState?.subRouteFailed && (
|
||||
<g transform={`translate(4, ${h - 14})`}>
|
||||
|
||||
@@ -34,6 +34,7 @@ export function ProcessDiagram({
|
||||
iterationState,
|
||||
onIterationChange,
|
||||
centerOnNodeId,
|
||||
latencyHeatmap,
|
||||
}: ProcessDiagramProps) {
|
||||
// Route stack for drill-down navigation
|
||||
const [routeStack, setRouteStack] = useState<string[]>([routeId]);
|
||||
@@ -338,6 +339,7 @@ export function ProcessDiagram({
|
||||
overlayActive={overlayActive}
|
||||
iterationState={iterationState}
|
||||
onIterationChange={onIterationChange}
|
||||
latencyHeatmap={latencyHeatmap}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeEnter={toolbar.onNodeEnter}
|
||||
@@ -354,6 +356,7 @@ export function ProcessDiagram({
|
||||
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
||||
executionState={getNodeExecutionState(node.id, node.type)}
|
||||
overlayActive={overlayActive}
|
||||
heatmapEntry={node.id ? latencyHeatmap?.get(node.id) : undefined}
|
||||
onClick={() => node.id && handleNodeClick(node.id)}
|
||||
onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)}
|
||||
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}
|
||||
|
||||
@@ -16,6 +16,13 @@ export interface DiagramSection {
|
||||
variant?: 'error' | 'completion';
|
||||
}
|
||||
|
||||
export interface LatencyHeatmapEntry {
|
||||
avgDurationMs: number;
|
||||
p99DurationMs: number;
|
||||
/** Percentage of total route time this processor consumes (0-100) */
|
||||
pctOfRoute: number;
|
||||
}
|
||||
|
||||
export interface ProcessDiagramProps {
|
||||
application: string;
|
||||
routeId: string;
|
||||
@@ -39,4 +46,7 @@ export interface ProcessDiagramProps {
|
||||
onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void;
|
||||
/** When set, the diagram pans to center this node in the viewport */
|
||||
centerOnNodeId?: string;
|
||||
/** Latency heatmap: maps processor ID → aggregate performance data.
|
||||
* When provided, nodes are colored green→yellow→red by relative latency. */
|
||||
latencyHeatmap?: Map<string, LatencyHeatmapEntry>;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Column } from '@cameleer/design-system';
|
||||
import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database';
|
||||
|
||||
export default function DatabaseAdminPage() {
|
||||
const { data: status } = useDatabaseStatus();
|
||||
const { data: status, isError: statusError } = useDatabaseStatus();
|
||||
const unreachable = statusError || (status && !status.connected);
|
||||
const { data: pool } = useConnectionPool();
|
||||
const { data: tables } = useDatabaseTables();
|
||||
const { data: queries } = useActiveQueries();
|
||||
@@ -34,7 +35,7 @@ export default function DatabaseAdminPage() {
|
||||
<h2 style={{ marginBottom: '1rem' }}>Database Administration</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<StatCard label="Status" value={status?.connected ? 'Connected' : 'Disconnected'} accent={status?.connected ? 'success' : 'error'} />
|
||||
<StatCard label="Status" value={unreachable ? 'Disconnected' : status ? 'Connected' : '\u2014'} accent={unreachable ? 'error' : status ? 'success' : undefined} />
|
||||
<StatCard label="Version" value={status?.version ?? '—'} />
|
||||
<StatCard label="TimescaleDB" value={status?.timescaleDb ? 'Enabled' : 'Disabled'} />
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useOpenSearchStatus, usePipelineStats, useOpenSearchIndices, useOpenSea
|
||||
import styles from './OpenSearchAdminPage.module.css';
|
||||
|
||||
export default function OpenSearchAdminPage() {
|
||||
const { data: status } = useOpenSearchStatus();
|
||||
const { data: status, isError: statusError } = useOpenSearchStatus();
|
||||
const { data: pipeline } = usePipelineStats();
|
||||
const { data: perf } = useOpenSearchPerformance();
|
||||
const { data: execIndices } = useOpenSearchIndices(0, 50, '', 'executions');
|
||||
const { data: logIndices } = useOpenSearchIndices(0, 50, '', 'logs');
|
||||
const unreachable = statusError || (status && !status.reachable);
|
||||
const deleteIndex = useDeleteIndex();
|
||||
|
||||
const indexColumns: Column<any>[] = [
|
||||
@@ -22,7 +23,7 @@ export default function OpenSearchAdminPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard label="Status" value={status?.reachable ? 'Connected' : 'Disconnected'} accent={status?.reachable ? 'success' : 'error'} />
|
||||
<StatCard label="Status" value={unreachable ? 'Disconnected' : status ? 'Connected' : '\u2014'} accent={unreachable ? 'error' : status ? 'success' : undefined} />
|
||||
<StatCard label="Health" value={status?.clusterHealth ?? '\u2014'} accent={status?.clusterHealth === 'green' ? 'success' : 'warning'} />
|
||||
<StatCard label="Version" value={status?.version ?? '\u2014'} />
|
||||
<StatCard label="Nodes" value={status?.nodeCount ?? 0} />
|
||||
|
||||
@@ -2,15 +2,14 @@ import { useState, useMemo, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { ExternalLink, RefreshCw, Pencil } from 'lucide-react';
|
||||
import {
|
||||
StatCard, StatusDot, Badge, MonoText, ProgressBar,
|
||||
GroupCard, DataTable, LineChart, EventFeed, DetailPanel,
|
||||
StatCard, StatusDot, Badge, MonoText,
|
||||
GroupCard, DataTable, EventFeed,
|
||||
LogViewer, ButtonGroup, SectionHeader, Toggle, useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column, FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system';
|
||||
import styles from './AgentHealth.module.css';
|
||||
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
||||
import { useApplicationLogs } from '../../api/queries/logs';
|
||||
import { useAgentMetrics } from '../../api/queries/agent-metrics';
|
||||
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
||||
import type { AgentInstance } from '../../api/types';
|
||||
|
||||
@@ -96,132 +95,6 @@ function appHealth(group: AppGroup): 'success' | 'warning' | 'error' {
|
||||
|
||||
// ── Detail sub-components ────────────────────────────────────────────────────
|
||||
|
||||
function AgentOverviewContent({ agent }: { agent: AgentInstance }) {
|
||||
const { data: memMetrics } = useAgentMetrics(
|
||||
agent.id,
|
||||
['jvm.memory.heap.used', 'jvm.memory.heap.max'],
|
||||
1,
|
||||
);
|
||||
const { data: cpuMetrics } = useAgentMetrics(agent.id, ['jvm.cpu.process'], 1);
|
||||
|
||||
const cpuValue = cpuMetrics?.metrics?.['jvm.cpu.process']?.[0]?.value;
|
||||
const heapUsed = memMetrics?.metrics?.['jvm.memory.heap.used']?.[0]?.value;
|
||||
const heapMax = memMetrics?.metrics?.['jvm.memory.heap.max']?.[0]?.value;
|
||||
|
||||
const heapPercent =
|
||||
heapUsed != null && heapMax != null && heapMax > 0
|
||||
? Math.round((heapUsed / heapMax) * 100)
|
||||
: undefined;
|
||||
const cpuPercent = cpuValue != null ? Math.round(cpuValue * 100) : undefined;
|
||||
|
||||
const ns = normalizeStatus(agent.status);
|
||||
|
||||
return (
|
||||
<div className={styles.detailContent}>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Status</span>
|
||||
<Badge label={agent.status} color={statusColor(ns)} variant="filled" />
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Application</span>
|
||||
<MonoText size="xs">{agent.application}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Uptime</span>
|
||||
<MonoText size="xs">{formatUptime(agent.uptimeSeconds)}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Last Seen</span>
|
||||
<MonoText size="xs">{timeAgo(agent.lastHeartbeat)}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Throughput</span>
|
||||
<MonoText size="xs">{agent.tps != null ? `${agent.tps.toFixed(1)}/s` : '\u2014'}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Errors</span>
|
||||
<MonoText size="xs" className={agent.errorRate ? styles.instanceError : undefined}>
|
||||
{formatErrorRate(agent.errorRate)}
|
||||
</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Routes</span>
|
||||
<span>{agent.activeRoutes ?? 0}/{agent.totalRoutes ?? 0} active</span>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Heap Memory</span>
|
||||
{heapPercent != null ? (
|
||||
<div className={styles.detailProgress}>
|
||||
<ProgressBar
|
||||
value={heapPercent}
|
||||
variant={heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'}
|
||||
size="sm"
|
||||
/>
|
||||
<MonoText size="xs">{heapPercent}%</MonoText>
|
||||
</div>
|
||||
) : (
|
||||
<MonoText size="xs">N/A</MonoText>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>CPU</span>
|
||||
{cpuPercent != null ? (
|
||||
<div className={styles.detailProgress}>
|
||||
<ProgressBar
|
||||
value={cpuPercent}
|
||||
variant={cpuPercent > 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'}
|
||||
size="sm"
|
||||
/>
|
||||
<MonoText size="xs">{cpuPercent}%</MonoText>
|
||||
</div>
|
||||
) : (
|
||||
<MonoText size="xs">N/A</MonoText>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPerformanceContent({ agent }: { agent: AgentInstance }) {
|
||||
const { data: tpsMetrics } = useAgentMetrics(agent.id, ['cameleer.tps'], 60);
|
||||
const { data: errMetrics } = useAgentMetrics(agent.id, ['cameleer.error.rate'], 60);
|
||||
|
||||
const tpsSeries = useMemo(() => {
|
||||
const raw = tpsMetrics?.metrics?.['cameleer.tps'] ?? [];
|
||||
return [{ label: 'TPS', data: raw.map((p) => ({ x: new Date(p.time), y: p.value })) }];
|
||||
}, [tpsMetrics]);
|
||||
|
||||
const errSeries = useMemo(() => {
|
||||
const raw = errMetrics?.metrics?.['cameleer.error.rate'] ?? [];
|
||||
return [{
|
||||
label: 'Error Rate',
|
||||
data: raw.map((p) => ({ x: new Date(p.time), y: p.value * 100 })),
|
||||
color: 'var(--error)',
|
||||
}];
|
||||
}, [errMetrics]);
|
||||
|
||||
return (
|
||||
<div className={styles.detailContent}>
|
||||
<div className={styles.chartPanel}>
|
||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
||||
{tpsSeries[0].data.length > 0 ? (
|
||||
<LineChart series={tpsSeries} height={160} yLabel="msg/s" />
|
||||
) : (
|
||||
<div className={styles.emptyChart}>No data available</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.chartPanel}>
|
||||
<div className={styles.chartTitle}>Error Rate (%)</div>
|
||||
{errSeries[0].data.length > 0 ? (
|
||||
<LineChart series={errSeries} height={160} yLabel="%" />
|
||||
) : (
|
||||
<div className={styles.emptyChart}>No data available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [
|
||||
{ value: 'error', label: 'Error', color: 'var(--error)' },
|
||||
{ value: 'warn', label: 'Warn', color: 'var(--warning)' },
|
||||
@@ -301,9 +174,6 @@ export default function AgentHealth() {
|
||||
.filter((l) => logLevels.size === 0 || logLevels.has(l.level))
|
||||
.filter((l) => !logSearchLower || l.message.toLowerCase().includes(logSearchLower));
|
||||
|
||||
const [selectedInstance, setSelectedInstance] = useState<AgentInstance | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
|
||||
const agentList = agents ?? [];
|
||||
|
||||
const groups = useMemo(() => groupByApp(agentList), [agentList]);
|
||||
@@ -428,26 +298,9 @@ export default function AgentHealth() {
|
||||
);
|
||||
|
||||
function handleInstanceClick(inst: AgentInstance) {
|
||||
setSelectedInstance(inst);
|
||||
setPanelOpen(true);
|
||||
navigate(`/runtime/${inst.application}/${inst.id}`);
|
||||
}
|
||||
|
||||
// Detail panel tabs
|
||||
const detailTabs = selectedInstance
|
||||
? [
|
||||
{
|
||||
label: 'Overview',
|
||||
value: 'overview',
|
||||
content: <AgentOverviewContent agent={selectedInstance} />,
|
||||
},
|
||||
{
|
||||
label: 'Performance',
|
||||
value: 'performance',
|
||||
content: <AgentPerformanceContent agent={selectedInstance} />,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const isFullWidth = !!appId;
|
||||
|
||||
return (
|
||||
@@ -677,7 +530,6 @@ export default function AgentHealth() {
|
||||
columns={instanceColumns}
|
||||
data={group.instances}
|
||||
onRowClick={handleInstanceClick}
|
||||
selectedId={panelOpen ? selectedInstance?.id : undefined}
|
||||
pageSize={50}
|
||||
flush
|
||||
/>
|
||||
@@ -758,15 +610,6 @@ export default function AgentHealth() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail panel — auto-portals to AppShell level via design system */}
|
||||
{selectedInstance && (
|
||||
<DetailPanel
|
||||
open={panelOpen}
|
||||
onClose={() => { setPanelOpen(false); setSelectedInstance(null); }}
|
||||
title={selectedInstance.name ?? selectedInstance.id}
|
||||
tabs={detailTabs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router'
|
||||
import { AlertTriangle, X, Search, Footprints } from 'lucide-react'
|
||||
import { AlertTriangle, X, Search, Footprints, RotateCcw } from 'lucide-react'
|
||||
import {
|
||||
DataTable,
|
||||
StatusDot,
|
||||
@@ -79,6 +79,7 @@ function buildBaseColumns(): Column<Row>[] {
|
||||
<StatusDot variant={statusToVariant(row.status)} />
|
||||
<MonoText size="xs">{statusLabel(row.status)}</MonoText>
|
||||
{row.hasTraceData && <Footprints size={11} color="#3D7C47" style={{ marginLeft: 2, flexShrink: 0 }} />}
|
||||
{row.isReplay && <RotateCcw size={11} color="var(--amber)" style={{ marginLeft: 2, flexShrink: 0 }} />}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
466
ui/src/pages/DashboardTab/DashboardL1.tsx
Normal file
466
ui/src/pages/DashboardTab/DashboardL1.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
KpiStrip,
|
||||
DataTable,
|
||||
AreaChart,
|
||||
LineChart,
|
||||
Card,
|
||||
Sparkline,
|
||||
MonoText,
|
||||
StatusDot,
|
||||
Badge,
|
||||
} from '@cameleer/design-system';
|
||||
import type { KpiItem, Column } from '@cameleer/design-system';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
import { useRouteMetrics } from '../../api/queries/catalog';
|
||||
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
|
||||
import { useTimeseriesByApp, useTopErrors, useAllAppSettings, usePunchcard } from '../../api/queries/dashboard';
|
||||
import type { AppSettings } from '../../api/queries/dashboard';
|
||||
import { Treemap } from './Treemap';
|
||||
import type { TreemapItem } from './Treemap';
|
||||
import { PunchcardHeatmap } from './PunchcardHeatmap';
|
||||
import type { RouteMetrics } from '../../api/types';
|
||||
import {
|
||||
computeHealthDot,
|
||||
formatThroughput,
|
||||
formatSlaCompliance,
|
||||
trendIndicator,
|
||||
type HealthStatus,
|
||||
} from './dashboard-utils';
|
||||
import styles from './DashboardTab.module.css';
|
||||
|
||||
// ── Row type for application health table ───────────────────────────────────
|
||||
|
||||
interface AppRow {
|
||||
id: string;
|
||||
appId: string;
|
||||
health: HealthStatus;
|
||||
throughput: number;
|
||||
throughputLabel: string;
|
||||
successRate: number;
|
||||
p99DurationMs: number;
|
||||
slaCompliance: number;
|
||||
errorCount: number;
|
||||
sparkline: number[];
|
||||
}
|
||||
|
||||
// ── Table columns ───────────────────────────────────────────────────────────
|
||||
|
||||
const APP_COLUMNS: Column<AppRow>[] = [
|
||||
{
|
||||
key: 'health',
|
||||
header: '',
|
||||
render: (_, row) => <StatusDot variant={row.health} />,
|
||||
},
|
||||
{
|
||||
key: 'appId',
|
||||
header: 'Application',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<span className={styles.appNameCell}>{row.appId}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'throughput',
|
||||
header: 'Throughput',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.throughputLabel}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'successRate',
|
||||
header: 'Success %',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const pct = row.successRate;
|
||||
const cls = pct >= 99 ? styles.rateGood : pct >= 97 ? styles.rateWarn : styles.rateBad;
|
||||
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'p99DurationMs',
|
||||
header: 'P99',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'slaCompliance',
|
||||
header: 'SLA %',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const cls = row.slaCompliance >= 99 ? styles.rateGood : row.slaCompliance >= 95 ? styles.rateWarn : styles.rateBad;
|
||||
return <MonoText size="sm" className={cls}>{formatSlaCompliance(row.slaCompliance)}</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'errorCount',
|
||||
header: 'Errors',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const cls = row.errorCount > 10 ? styles.rateBad : row.errorCount > 0 ? styles.rateWarn : styles.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{row.errorCount.toLocaleString()}</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sparkline',
|
||||
header: 'Trend',
|
||||
render: (_, row) => (
|
||||
<Sparkline data={row.sparkline} width={80} height={24} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ── Aggregate RouteMetrics by appId ─────────────────────────────────────────
|
||||
|
||||
function aggregateByApp(
|
||||
metrics: RouteMetrics[],
|
||||
windowSeconds: number,
|
||||
settingsMap: Map<string, AppSettings>,
|
||||
): AppRow[] {
|
||||
const grouped = new Map<string, RouteMetrics[]>();
|
||||
for (const m of metrics) {
|
||||
const list = grouped.get(m.appId) ?? [];
|
||||
list.push(m);
|
||||
grouped.set(m.appId, list);
|
||||
}
|
||||
|
||||
const rows: AppRow[] = [];
|
||||
for (const [appId, routes] of grouped) {
|
||||
const totalExchanges = routes.reduce((s, r) => s + r.exchangeCount, 0);
|
||||
const totalFailed = routes.reduce((s, r) => s + r.exchangeCount * r.errorRate, 0);
|
||||
const successRate = totalExchanges > 0 ? ((totalExchanges - totalFailed) / totalExchanges) * 100 : 100;
|
||||
const errorRate = totalExchanges > 0 ? totalFailed / totalExchanges : 0;
|
||||
|
||||
// Weighted average p99 by exchange count
|
||||
const p99Sum = routes.reduce((s, r) => s + r.p99DurationMs * r.exchangeCount, 0);
|
||||
const p99DurationMs = totalExchanges > 0 ? p99Sum / totalExchanges : 0;
|
||||
|
||||
// SLA compliance: weighted average of per-route slaCompliance from backend
|
||||
const appSettings = settingsMap.get(appId);
|
||||
const slaWeightedSum = routes.reduce((s, r) => s + (r.slaCompliance ?? 100) * r.exchangeCount, 0);
|
||||
const slaCompliance = totalExchanges > 0 ? slaWeightedSum / totalExchanges : 100;
|
||||
|
||||
const errorCount = Math.round(totalFailed);
|
||||
|
||||
// Merge sparklines: sum across routes per bucket position
|
||||
const maxLen = Math.max(...routes.map((r) => (r.sparkline ?? []).length), 0);
|
||||
const sparkline: number[] = [];
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
sparkline.push(routes.reduce((s, r) => s + ((r.sparkline ?? [])[i] ?? 0), 0));
|
||||
}
|
||||
|
||||
rows.push({
|
||||
id: appId,
|
||||
appId,
|
||||
health: computeHealthDot(errorRate, slaCompliance, appSettings),
|
||||
throughput: totalExchanges,
|
||||
throughputLabel: formatThroughput(totalExchanges, windowSeconds),
|
||||
successRate,
|
||||
p99DurationMs,
|
||||
slaCompliance,
|
||||
errorCount,
|
||||
sparkline,
|
||||
});
|
||||
}
|
||||
|
||||
return rows.sort((a, b) => {
|
||||
const order: Record<HealthStatus, number> = { error: 0, warning: 1, success: 2 };
|
||||
return order[a.health] - order[b.health];
|
||||
});
|
||||
}
|
||||
|
||||
// ── Build KPI items ─────────────────────────────────────────────────────────
|
||||
|
||||
function buildKpiItems(
|
||||
stats: {
|
||||
totalCount: number;
|
||||
failedCount: number;
|
||||
p99LatencyMs: number;
|
||||
prevTotalCount: number;
|
||||
prevFailedCount: number;
|
||||
prevP99LatencyMs: number;
|
||||
} | undefined,
|
||||
windowSeconds: number,
|
||||
slaCompliance: number,
|
||||
activeErrorCount: number,
|
||||
throughputSparkline: number[],
|
||||
successSparkline: number[],
|
||||
latencySparkline: number[],
|
||||
slaSparkline: number[],
|
||||
errorSparkline: number[],
|
||||
): KpiItem[] {
|
||||
const totalCount = stats?.totalCount ?? 0;
|
||||
const failedCount = stats?.failedCount ?? 0;
|
||||
const prevTotalCount = stats?.prevTotalCount ?? 0;
|
||||
const prevFailedCount = stats?.prevFailedCount ?? 0;
|
||||
const p99Ms = stats?.p99LatencyMs ?? 0;
|
||||
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
|
||||
|
||||
// Throughput
|
||||
const throughput = windowSeconds > 0 ? totalCount / windowSeconds : 0;
|
||||
const prevThroughput = windowSeconds > 0 ? prevTotalCount / windowSeconds : 0;
|
||||
const throughputTrend = trendIndicator(throughput, prevThroughput);
|
||||
|
||||
// Success Rate
|
||||
const successPct = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100;
|
||||
const prevSuccessPct = prevTotalCount > 0
|
||||
? ((prevTotalCount - prevFailedCount) / prevTotalCount) * 100
|
||||
: 100;
|
||||
const successTrend = trendIndicator(successPct, prevSuccessPct);
|
||||
|
||||
// P99 Latency
|
||||
const p99Trend = trendIndicator(p99Ms, prevP99Ms);
|
||||
|
||||
// SLA compliance trend — higher is better, so invert the variant
|
||||
const slaTrend = trendIndicator(slaCompliance, 100);
|
||||
|
||||
// Active Errors
|
||||
const prevErrorRate = prevTotalCount > 0 ? (prevFailedCount / prevTotalCount) * 100 : 0;
|
||||
const currentErrorRate = totalCount > 0 ? (failedCount / totalCount) * 100 : 0;
|
||||
const errorTrend = trendIndicator(currentErrorRate, prevErrorRate);
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Throughput',
|
||||
value: formatThroughput(totalCount, windowSeconds),
|
||||
trend: {
|
||||
label: throughputTrend.label,
|
||||
variant: throughputTrend.direction === 'up' ? 'success' as const : throughputTrend.direction === 'down' ? 'error' as const : 'muted' as const,
|
||||
},
|
||||
subtitle: `${totalCount.toLocaleString()} msg total`,
|
||||
sparkline: throughputSparkline,
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
{
|
||||
label: 'Success Rate',
|
||||
value: `${successPct.toFixed(1)}%`,
|
||||
trend: {
|
||||
label: successTrend.label,
|
||||
variant: successPct >= 99 ? 'success' as const : successPct >= 97 ? 'warning' as const : 'error' as const,
|
||||
},
|
||||
subtitle: `${(totalCount - failedCount).toLocaleString()} succeeded`,
|
||||
sparkline: successSparkline,
|
||||
borderColor: successPct >= 99 ? 'var(--success)' : 'var(--error)',
|
||||
},
|
||||
{
|
||||
label: 'P99 Latency',
|
||||
value: `${Math.round(p99Ms)}ms`,
|
||||
trend: {
|
||||
label: p99Trend.label,
|
||||
variant: p99Ms > 300 ? 'error' as const : p99Ms > 200 ? 'warning' as const : 'success' as const,
|
||||
},
|
||||
subtitle: `prev ${Math.round(prevP99Ms)}ms`,
|
||||
sparkline: latencySparkline,
|
||||
borderColor: p99Ms > 300 ? 'var(--warning)' : 'var(--success)',
|
||||
},
|
||||
{
|
||||
label: 'SLA Compliance',
|
||||
value: formatSlaCompliance(slaCompliance),
|
||||
trend: {
|
||||
label: slaTrend.label,
|
||||
variant: slaCompliance >= 99 ? 'success' as const : slaCompliance >= 95 ? 'warning' as const : 'error' as const,
|
||||
},
|
||||
subtitle: 'P99 within threshold',
|
||||
sparkline: slaSparkline,
|
||||
borderColor: slaCompliance >= 99 ? 'var(--success)' : 'var(--warning)',
|
||||
},
|
||||
{
|
||||
label: 'Active Errors',
|
||||
value: String(activeErrorCount),
|
||||
trend: {
|
||||
label: errorTrend.label,
|
||||
variant: activeErrorCount === 0 ? 'success' as const : 'error' as const,
|
||||
},
|
||||
subtitle: `${failedCount.toLocaleString()} failures total`,
|
||||
sparkline: errorSparkline,
|
||||
borderColor: activeErrorCount === 0 ? 'var(--success)' : 'var(--error)',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DashboardL1() {
|
||||
const navigate = useNavigate();
|
||||
const { timeRange } = useGlobalFilters();
|
||||
const timeFrom = timeRange.start.toISOString();
|
||||
const timeTo = timeRange.end.toISOString();
|
||||
const windowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
|
||||
|
||||
const { data: metrics } = useRouteMetrics(timeFrom, timeTo);
|
||||
const { data: stats } = useExecutionStats(timeFrom, timeTo);
|
||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo);
|
||||
const { data: timeseriesByApp } = useTimeseriesByApp(timeFrom, timeTo);
|
||||
const { data: topErrors } = useTopErrors(timeFrom, timeTo);
|
||||
const { data: punchcardData } = usePunchcard();
|
||||
const { data: allAppSettings } = useAllAppSettings();
|
||||
|
||||
// Build settings lookup map
|
||||
const settingsMap = useMemo(() => {
|
||||
const map = new Map<string, AppSettings>();
|
||||
for (const s of allAppSettings ?? []) {
|
||||
map.set(s.appId, s);
|
||||
}
|
||||
return map;
|
||||
}, [allAppSettings]);
|
||||
|
||||
// Aggregate route metrics by appId for the table
|
||||
const appRows = useMemo(
|
||||
() => aggregateByApp(metrics ?? [], windowSeconds, settingsMap),
|
||||
[metrics, windowSeconds, settingsMap],
|
||||
);
|
||||
|
||||
// Global SLA compliance from backend stats (exact calculation from executions table)
|
||||
const globalSlaCompliance = stats?.slaCompliance ?? -1;
|
||||
const effectiveSlaCompliance = globalSlaCompliance >= 0 ? globalSlaCompliance : 100;
|
||||
|
||||
// Active error count = distinct error types
|
||||
const activeErrorCount = useMemo(
|
||||
() => (topErrors ?? []).length,
|
||||
[topErrors],
|
||||
);
|
||||
|
||||
// KPI sparklines from timeseries buckets
|
||||
const throughputSparkline = useMemo(
|
||||
() => (timeseries?.buckets ?? []).map((b) => b.totalCount),
|
||||
[timeseries],
|
||||
);
|
||||
const successSparkline = useMemo(
|
||||
() => (timeseries?.buckets ?? []).map((b) =>
|
||||
b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
|
||||
),
|
||||
[timeseries],
|
||||
);
|
||||
const latencySparkline = useMemo(
|
||||
() => (timeseries?.buckets ?? []).map((b) => b.p99DurationMs),
|
||||
[timeseries],
|
||||
);
|
||||
const slaSparkline = useMemo(
|
||||
() => (timeseries?.buckets ?? []).map((b) =>
|
||||
b.p99DurationMs <= 300 ? 100 : 0,
|
||||
),
|
||||
[timeseries],
|
||||
);
|
||||
const errorSparkline = useMemo(
|
||||
() => (timeseries?.buckets ?? []).map((b) => b.failedCount),
|
||||
[timeseries],
|
||||
);
|
||||
|
||||
const kpiItems = useMemo(
|
||||
() => buildKpiItems(
|
||||
stats,
|
||||
windowSeconds,
|
||||
effectiveSlaCompliance,
|
||||
activeErrorCount,
|
||||
throughputSparkline,
|
||||
successSparkline,
|
||||
latencySparkline,
|
||||
slaSparkline,
|
||||
errorSparkline,
|
||||
),
|
||||
[stats, windowSeconds, effectiveSlaCompliance, activeErrorCount,
|
||||
throughputSparkline, successSparkline, latencySparkline, slaSparkline, errorSparkline],
|
||||
);
|
||||
|
||||
// ── Per-app chart series (throughput stacked area) ──────────────────────
|
||||
const throughputByAppSeries = useMemo(() => {
|
||||
if (!timeseriesByApp) return [];
|
||||
return Object.entries(timeseriesByApp).map(([appId, { buckets }]) => ({
|
||||
label: appId,
|
||||
data: buckets.map((b, i) => ({
|
||||
x: i as number,
|
||||
y: b.totalCount,
|
||||
})),
|
||||
}));
|
||||
}, [timeseriesByApp]);
|
||||
|
||||
// ── Per-app chart series (error rate line) ─────────────────────────────
|
||||
const errorRateByAppSeries = useMemo(() => {
|
||||
if (!timeseriesByApp) return [];
|
||||
return Object.entries(timeseriesByApp).map(([appId, { buckets }]) => ({
|
||||
label: appId,
|
||||
data: buckets.map((b, i) => ({
|
||||
x: i as number,
|
||||
y: b.totalCount > 0 ? (b.failedCount / b.totalCount) * 100 : 0,
|
||||
})),
|
||||
}));
|
||||
}, [timeseriesByApp]);
|
||||
|
||||
// Treemap items: one per app, sized by exchange count, colored by SLA
|
||||
const treemapItems: TreemapItem[] = useMemo(
|
||||
() => appRows.map(r => ({ id: r.appId, label: r.appId, value: r.throughput, slaCompliance: r.slaCompliance })),
|
||||
[appRows],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.refreshIndicator}>
|
||||
<span className={styles.refreshDot} />
|
||||
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
||||
</div>
|
||||
|
||||
{/* KPI header cards */}
|
||||
<KpiStrip items={kpiItems} />
|
||||
|
||||
{/* Application Health table */}
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Application Health</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>{appRows.length} applications</span>
|
||||
<Badge label="ALL" color="auto" />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={APP_COLUMNS}
|
||||
data={appRows}
|
||||
sortable
|
||||
onRowClick={(row) => navigate(`/dashboard/${row.appId}`)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Side-by-side charts */}
|
||||
{throughputByAppSeries.length > 0 && (
|
||||
<div className={styles.chartGrid}>
|
||||
<Card title="Throughput by Application (msg/s)">
|
||||
<AreaChart
|
||||
series={throughputByAppSeries}
|
||||
yLabel="msg/s"
|
||||
height={200}
|
||||
className={styles.chart}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Error Rate by Application (%)">
|
||||
<LineChart
|
||||
series={errorRateByAppSeries}
|
||||
yLabel="%"
|
||||
height={200}
|
||||
className={styles.chart}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Treemap + Punchcard heatmaps side by side */}
|
||||
{treemapItems.length > 0 && (
|
||||
<div className={styles.vizRow}>
|
||||
<Card title="Application Volume vs SLA Compliance">
|
||||
<Treemap
|
||||
items={treemapItems}
|
||||
onItemClick={(id) => navigate(`/dashboard/${id}`)}
|
||||
/>
|
||||
</Card>
|
||||
<Card title="7-Day Pattern">
|
||||
<PunchcardHeatmap cells={punchcardData ?? []} />
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
447
ui/src/pages/DashboardTab/DashboardL2.tsx
Normal file
447
ui/src/pages/DashboardTab/DashboardL2.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import {
|
||||
KpiStrip,
|
||||
DataTable,
|
||||
AreaChart,
|
||||
LineChart,
|
||||
Card,
|
||||
Sparkline,
|
||||
MonoText,
|
||||
Badge,
|
||||
} from '@cameleer/design-system';
|
||||
import type { KpiItem, Column } from '@cameleer/design-system';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
import { useRouteMetrics } from '../../api/queries/catalog';
|
||||
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
|
||||
import {
|
||||
useTimeseriesByRoute,
|
||||
useTopErrors,
|
||||
useAppSettings,
|
||||
usePunchcard,
|
||||
} from '../../api/queries/dashboard';
|
||||
import type { TopError } from '../../api/queries/dashboard';
|
||||
import { Treemap } from './Treemap';
|
||||
import type { TreemapItem } from './Treemap';
|
||||
import { PunchcardHeatmap } from './PunchcardHeatmap';
|
||||
import type { RouteMetrics } from '../../api/types';
|
||||
import {
|
||||
trendArrow,
|
||||
trendIndicator,
|
||||
formatThroughput,
|
||||
formatSlaCompliance,
|
||||
formatRelativeTime,
|
||||
} from './dashboard-utils';
|
||||
import styles from './DashboardTab.module.css';
|
||||
|
||||
// ── Route table row type ────────────────────────────────────────────────────
|
||||
|
||||
interface RouteRow {
|
||||
id: string;
|
||||
routeId: string;
|
||||
exchangeCount: number;
|
||||
successRate: number;
|
||||
avgDurationMs: number;
|
||||
p99DurationMs: number;
|
||||
slaCompliance: number;
|
||||
sparkline: number[];
|
||||
}
|
||||
|
||||
// ── Route performance columns ───────────────────────────────────────────────
|
||||
|
||||
const ROUTE_COLUMNS: Column<RouteRow>[] = [
|
||||
{
|
||||
key: 'routeId',
|
||||
header: 'Route ID',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.routeId}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'exchangeCount',
|
||||
header: 'Throughput',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.exchangeCount.toLocaleString()}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'successRate',
|
||||
header: 'Success%',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const pct = row.successRate * 100;
|
||||
const cls = pct >= 99 ? styles.rateGood : pct >= 97 ? styles.rateWarn : styles.rateBad;
|
||||
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'avgDurationMs',
|
||||
header: 'Avg(ms)',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{Math.round(row.avgDurationMs)}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'p99DurationMs',
|
||||
header: 'P99(ms)',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'slaCompliance',
|
||||
header: 'SLA%',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const cls = row.slaCompliance >= 99 ? styles.rateGood : row.slaCompliance >= 95 ? styles.rateWarn : styles.rateBad;
|
||||
return <MonoText size="sm" className={cls}>{formatSlaCompliance(row.slaCompliance)}</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sparkline',
|
||||
header: 'Sparkline',
|
||||
render: (_, row) => (
|
||||
<Sparkline data={row.sparkline} width={80} height={24} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ── Top errors columns ──────────────────────────────────────────────────────
|
||||
|
||||
type ErrorRow = TopError & { id: string };
|
||||
|
||||
const ERROR_COLUMNS: Column<ErrorRow>[] = [
|
||||
{
|
||||
key: 'errorType',
|
||||
header: 'Error Type',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.errorType}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'routeId',
|
||||
header: 'Route',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.routeId ?? '\u2014'}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'count',
|
||||
header: 'Count',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.count.toLocaleString()}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'velocity',
|
||||
header: 'Velocity',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const arrow = trendArrow(row.trend);
|
||||
const cls = row.trend === 'accelerating' ? styles.rateBad
|
||||
: row.trend === 'decelerating' ? styles.rateGood
|
||||
: styles.rateNeutral;
|
||||
return <MonoText size="sm" className={cls}>{row.velocity.toFixed(1)}/min {arrow}</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'lastSeen',
|
||||
header: 'Last Seen',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{formatRelativeTime(row.lastSeen)}</MonoText>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ── Build KPI items ─────────────────────────────────────────────────────────
|
||||
|
||||
function buildKpiItems(
|
||||
stats: {
|
||||
totalCount: number;
|
||||
failedCount: number;
|
||||
p99LatencyMs: number;
|
||||
prevTotalCount: number;
|
||||
prevFailedCount: number;
|
||||
prevP99LatencyMs: number;
|
||||
} | undefined,
|
||||
slaThresholdMs: number,
|
||||
throughputSparkline: number[],
|
||||
latencySparkline: number[],
|
||||
errors: TopError[] | undefined,
|
||||
windowSeconds: number,
|
||||
): KpiItem[] {
|
||||
const totalCount = stats?.totalCount ?? 0;
|
||||
const failedCount = stats?.failedCount ?? 0;
|
||||
const prevTotalCount = stats?.prevTotalCount ?? 0;
|
||||
const prevFailedCount = stats?.prevFailedCount ?? 0;
|
||||
const p99Ms = stats?.p99LatencyMs ?? 0;
|
||||
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
|
||||
|
||||
// Throughput
|
||||
const throughputTrend = trendIndicator(totalCount, prevTotalCount);
|
||||
|
||||
// Success Rate
|
||||
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100;
|
||||
const prevSuccessRate = prevTotalCount > 0 ? ((prevTotalCount - prevFailedCount) / prevTotalCount) * 100 : 100;
|
||||
const successTrend = trendIndicator(successRate, prevSuccessRate);
|
||||
|
||||
// P99 Latency
|
||||
const latencyTrend = trendIndicator(p99Ms, prevP99Ms);
|
||||
|
||||
// SLA Compliance — percentage of exchanges under threshold
|
||||
// Approximate from p99: if p99 < threshold, ~99%+ are compliant
|
||||
const slaCompliance = p99Ms <= slaThresholdMs ? 99.9 : Math.max(0, 100 - ((p99Ms - slaThresholdMs) / slaThresholdMs) * 10);
|
||||
|
||||
// Error Velocity — aggregate from top errors
|
||||
const errorList = errors ?? [];
|
||||
const totalVelocity = errorList.reduce((sum, e) => sum + e.velocity, 0);
|
||||
const hasAccelerating = errorList.some((e) => e.trend === 'accelerating');
|
||||
const allDecelerating = errorList.length > 0 && errorList.every((e) => e.trend === 'decelerating');
|
||||
const velocityTrendLabel = hasAccelerating ? '\u25B2' : allDecelerating ? '\u25BC' : '\u2500\u2500';
|
||||
const velocityVariant = hasAccelerating ? 'error' as const : allDecelerating ? 'success' as const : 'muted' as const;
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Throughput',
|
||||
value: formatThroughput(totalCount, windowSeconds),
|
||||
trend: {
|
||||
label: throughputTrend.label,
|
||||
variant: throughputTrend.direction === 'up' ? 'success' as const : throughputTrend.direction === 'down' ? 'error' as const : 'muted' as const,
|
||||
},
|
||||
sparkline: throughputSparkline,
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
{
|
||||
label: 'Success Rate',
|
||||
value: `${successRate.toFixed(2)}%`,
|
||||
trend: {
|
||||
label: successTrend.label,
|
||||
variant: successTrend.direction === 'up' ? 'success' as const : successTrend.direction === 'down' ? 'error' as const : 'muted' as const,
|
||||
},
|
||||
borderColor: successRate >= 99 ? 'var(--success)' : successRate >= 95 ? 'var(--warning)' : 'var(--error)',
|
||||
},
|
||||
{
|
||||
label: 'P99 Latency',
|
||||
value: `${Math.round(p99Ms)}ms`,
|
||||
trend: {
|
||||
label: latencyTrend.label,
|
||||
variant: latencyTrend.direction === 'up' ? 'error' as const : latencyTrend.direction === 'down' ? 'success' as const : 'muted' as const,
|
||||
},
|
||||
sparkline: latencySparkline,
|
||||
borderColor: p99Ms > slaThresholdMs ? 'var(--error)' : 'var(--success)',
|
||||
},
|
||||
{
|
||||
label: 'SLA Compliance',
|
||||
value: formatSlaCompliance(slaCompliance),
|
||||
trend: {
|
||||
label: slaCompliance >= 99 ? 'OK' : 'BREACH',
|
||||
variant: slaCompliance >= 99 ? 'success' as const : 'error' as const,
|
||||
},
|
||||
subtitle: `Threshold: ${slaThresholdMs}ms`,
|
||||
borderColor: slaCompliance >= 99 ? 'var(--success)' : slaCompliance >= 95 ? 'var(--warning)' : 'var(--error)',
|
||||
},
|
||||
{
|
||||
label: 'Error Velocity',
|
||||
value: `${totalVelocity.toFixed(1)}/min`,
|
||||
trend: {
|
||||
label: velocityTrendLabel,
|
||||
variant: velocityVariant,
|
||||
},
|
||||
subtitle: `${errorList.length} error type${errorList.length !== 1 ? 's' : ''} tracked`,
|
||||
borderColor: hasAccelerating ? 'var(--error)' : allDecelerating ? 'var(--success)' : 'var(--text-muted)',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DashboardL2() {
|
||||
const { appId } = useParams<{ appId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { timeRange } = useGlobalFilters();
|
||||
const timeFrom = timeRange.start.toISOString();
|
||||
const timeTo = timeRange.end.toISOString();
|
||||
const windowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
|
||||
|
||||
// Data hooks
|
||||
const { data: stats } = useExecutionStats(timeFrom, timeTo, undefined, appId);
|
||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
|
||||
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
|
||||
const { data: timeseriesByRoute } = useTimeseriesByRoute(timeFrom, timeTo, appId);
|
||||
const { data: errors } = useTopErrors(timeFrom, timeTo, appId);
|
||||
const { data: punchcardData } = usePunchcard(appId);
|
||||
const { data: appSettings } = useAppSettings(appId);
|
||||
|
||||
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
|
||||
|
||||
// Route performance table rows
|
||||
const routeRows: RouteRow[] = useMemo(() =>
|
||||
(metrics || []).map((m: RouteMetrics) => ({
|
||||
id: m.routeId,
|
||||
routeId: m.routeId,
|
||||
exchangeCount: m.exchangeCount,
|
||||
successRate: m.successRate,
|
||||
avgDurationMs: m.avgDurationMs,
|
||||
p99DurationMs: m.p99DurationMs,
|
||||
slaCompliance: m.slaCompliance ?? -1,
|
||||
sparkline: m.sparkline ?? [],
|
||||
})),
|
||||
[metrics],
|
||||
);
|
||||
|
||||
// Treemap items: one per route, sized by exchange count, colored by SLA
|
||||
const treemapItems: TreemapItem[] = useMemo(
|
||||
() => routeRows.map(r => ({
|
||||
id: r.routeId, label: r.routeId,
|
||||
value: r.exchangeCount,
|
||||
slaCompliance: r.slaCompliance >= 0 ? r.slaCompliance : 100,
|
||||
})),
|
||||
[routeRows],
|
||||
);
|
||||
|
||||
// KPI sparklines from timeseries
|
||||
const throughputSparkline = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b) => b.totalCount),
|
||||
[timeseries],
|
||||
);
|
||||
const latencySparkline = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b) => b.p99DurationMs),
|
||||
[timeseries],
|
||||
);
|
||||
|
||||
const kpiItems = useMemo(() =>
|
||||
buildKpiItems(stats, slaThresholdMs, throughputSparkline, latencySparkline, errors, windowSeconds),
|
||||
[stats, slaThresholdMs, throughputSparkline, latencySparkline, errors, windowSeconds],
|
||||
);
|
||||
|
||||
// Throughput by Route — stacked area chart series
|
||||
const throughputByRouteSeries = useMemo(() => {
|
||||
if (!timeseriesByRoute) return [];
|
||||
return Object.entries(timeseriesByRoute).map(([routeId, data]) => ({
|
||||
label: routeId,
|
||||
data: (data.buckets || []).map((b, i) => ({
|
||||
x: i as number,
|
||||
y: b.totalCount,
|
||||
})),
|
||||
}));
|
||||
}, [timeseriesByRoute]);
|
||||
|
||||
// Latency percentiles chart — P99 line from app-level timeseries
|
||||
const latencyChartSeries = useMemo(() => {
|
||||
const buckets = timeseries?.buckets || [];
|
||||
return [
|
||||
{
|
||||
label: 'P99',
|
||||
data: buckets.map((b, i) => ({
|
||||
x: i as number,
|
||||
y: b.p99DurationMs,
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Avg',
|
||||
data: buckets.map((b, i) => ({
|
||||
x: i as number,
|
||||
y: b.avgDurationMs,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}, [timeseries]);
|
||||
|
||||
// Error rows with stable identity
|
||||
const errorRows = useMemo(() =>
|
||||
(errors ?? []).map((e, i) => ({ ...e, id: `${e.errorType}-${e.routeId}-${i}` })),
|
||||
[errors],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.refreshIndicator}>
|
||||
<span className={styles.refreshDot} />
|
||||
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
||||
</div>
|
||||
|
||||
{/* KPI Strip */}
|
||||
<KpiStrip items={kpiItems} />
|
||||
|
||||
{/* Route Performance Table */}
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Route Performance</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>{routeRows.length} routes</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={ROUTE_COLUMNS}
|
||||
data={routeRows}
|
||||
sortable
|
||||
onRowClick={(row) => navigate(`/dashboard/${appId}/${row.routeId}`)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts: Throughput by Route + Latency Percentiles */}
|
||||
{(timeseries?.buckets?.length ?? 0) > 0 && (
|
||||
<div className={styles.chartGrid}>
|
||||
<Card title="Throughput by Route">
|
||||
<AreaChart
|
||||
series={throughputByRouteSeries}
|
||||
yLabel="msg/s"
|
||||
height={200}
|
||||
className={styles.chart}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Latency Percentiles">
|
||||
<LineChart
|
||||
series={latencyChartSeries}
|
||||
yLabel="ms"
|
||||
threshold={{ value: slaThresholdMs, label: `SLA ${slaThresholdMs}ms` }}
|
||||
height={200}
|
||||
className={styles.chart}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top 5 Errors — hidden when empty */}
|
||||
{errorRows.length > 0 && (
|
||||
<div className={styles.errorsSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Top Errors</span>
|
||||
<span className={styles.tableMeta}>{errorRows.length} error types</span>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={ERROR_COLUMNS}
|
||||
data={errorRows}
|
||||
sortable
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Treemap + Punchcard heatmaps side by side */}
|
||||
{treemapItems.length > 0 && (
|
||||
<div className={styles.vizRow}>
|
||||
<Card title="Route Volume vs SLA Compliance">
|
||||
<Treemap
|
||||
items={treemapItems}
|
||||
onItemClick={(id) => navigate(`/dashboard/${appId}/${id}`)}
|
||||
/>
|
||||
</Card>
|
||||
<Card title="7-Day Pattern">
|
||||
<PunchcardHeatmap cells={punchcardData ?? []} />
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
433
ui/src/pages/DashboardTab/DashboardL3.tsx
Normal file
433
ui/src/pages/DashboardTab/DashboardL3.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import {
|
||||
KpiStrip,
|
||||
DataTable,
|
||||
AreaChart,
|
||||
LineChart,
|
||||
Card,
|
||||
MonoText,
|
||||
Badge,
|
||||
} from '@cameleer/design-system';
|
||||
import type { KpiItem, Column } from '@cameleer/design-system';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
|
||||
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
|
||||
import { useTopErrors, useAppSettings } from '../../api/queries/dashboard';
|
||||
import type { TopError } from '../../api/queries/dashboard';
|
||||
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
||||
import { ProcessDiagram } from '../../components/ProcessDiagram';
|
||||
import {
|
||||
formatRelativeTime,
|
||||
trendArrow,
|
||||
formatThroughput,
|
||||
formatSlaCompliance,
|
||||
trendIndicator,
|
||||
} from './dashboard-utils';
|
||||
import styles from './DashboardTab.module.css';
|
||||
|
||||
// ── Row types ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProcessorRow {
|
||||
id: string;
|
||||
processorId: string;
|
||||
processorType: string;
|
||||
totalCount: number;
|
||||
avgDurationMs: number;
|
||||
p99DurationMs: number;
|
||||
errorRate: number;
|
||||
pctTime: number;
|
||||
}
|
||||
|
||||
interface ErrorRow extends TopError {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// ── Processor table columns ─────────────────────────────────────────────────
|
||||
|
||||
const PROCESSOR_COLUMNS: Column<ProcessorRow>[] = [
|
||||
{
|
||||
key: 'processorId',
|
||||
header: 'Processor ID',
|
||||
sortable: true,
|
||||
render: (_, row) => <MonoText size="sm">{row.processorId}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: 'processorType',
|
||||
header: 'Type',
|
||||
sortable: true,
|
||||
render: (_, row) => <Badge label={row.processorType} color="auto" />,
|
||||
},
|
||||
{
|
||||
key: 'totalCount',
|
||||
header: 'Invocations',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.totalCount.toLocaleString()}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'avgDurationMs',
|
||||
header: 'Avg(ms)',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{Math.round(row.avgDurationMs)}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'p99DurationMs',
|
||||
header: 'P99(ms)',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const cls = row.p99DurationMs > 300
|
||||
? styles.rateBad
|
||||
: row.p99DurationMs > 200
|
||||
? styles.rateWarn
|
||||
: styles.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'errorRate',
|
||||
header: 'Error Rate(%)',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const pct = row.errorRate * 100;
|
||||
const cls = pct > 5 ? styles.rateBad : pct > 1 ? styles.rateWarn : styles.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{pct.toFixed(2)}%</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pctTime',
|
||||
header: '% Time',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.pctTime.toFixed(1)}%</MonoText>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ── Error table columns ─────────────────────────────────────────────────────
|
||||
|
||||
const ERROR_COLUMNS: Column<ErrorRow>[] = [
|
||||
{
|
||||
key: 'errorType',
|
||||
header: 'Error Type',
|
||||
sortable: true,
|
||||
render: (_, row) => <MonoText size="sm">{row.errorType}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: 'processorId',
|
||||
header: 'Processor',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.processorId ?? '\u2014'}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'count',
|
||||
header: 'Count',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.count.toLocaleString()}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'trend',
|
||||
header: 'Velocity',
|
||||
render: (_, row) => (
|
||||
<span>{trendArrow(row.trend)} {row.trend}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastSeen',
|
||||
header: 'Last Seen',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<span>{formatRelativeTime(row.lastSeen)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ── Build KPI items ─────────────────────────────────────────────────────────
|
||||
|
||||
function buildKpiItems(
|
||||
stats: {
|
||||
totalCount: number;
|
||||
failedCount: number;
|
||||
avgDurationMs: number;
|
||||
p99LatencyMs: number;
|
||||
activeCount: number;
|
||||
prevTotalCount: number;
|
||||
prevFailedCount: number;
|
||||
prevP99LatencyMs: number;
|
||||
} | undefined,
|
||||
slaThresholdMs: number,
|
||||
bottleneck: { processorId: string; avgMs: number; pct: number } | null,
|
||||
throughputSparkline: number[],
|
||||
windowSeconds: number,
|
||||
): KpiItem[] {
|
||||
const totalCount = stats?.totalCount ?? 0;
|
||||
const failedCount = stats?.failedCount ?? 0;
|
||||
const prevTotalCount = stats?.prevTotalCount ?? 0;
|
||||
const p99Ms = stats?.p99LatencyMs ?? 0;
|
||||
const avgMs = stats?.avgDurationMs ?? 0;
|
||||
|
||||
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100;
|
||||
const slaCompliance = totalCount > 0
|
||||
? ((totalCount - failedCount) / totalCount) * 100
|
||||
: 100;
|
||||
|
||||
const throughputTrend = trendIndicator(totalCount, prevTotalCount);
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Throughput',
|
||||
value: formatThroughput(totalCount, windowSeconds),
|
||||
trend: {
|
||||
label: throughputTrend.label,
|
||||
variant: throughputTrend.direction === 'up' ? 'success' as const : throughputTrend.direction === 'down' ? 'error' as const : 'muted' as const,
|
||||
},
|
||||
subtitle: `${totalCount.toLocaleString()} total exchanges`,
|
||||
sparkline: throughputSparkline,
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
{
|
||||
label: 'Success Rate',
|
||||
value: `${successRate.toFixed(2)}%`,
|
||||
trend: {
|
||||
label: failedCount > 0 ? `${failedCount} failed` : 'No errors',
|
||||
variant: successRate >= 99 ? 'success' as const : successRate >= 97 ? 'warning' as const : 'error' as const,
|
||||
},
|
||||
subtitle: `${totalCount - failedCount} succeeded / ${totalCount.toLocaleString()} total`,
|
||||
borderColor: successRate >= 99 ? 'var(--success)' : 'var(--error)',
|
||||
},
|
||||
{
|
||||
label: 'P99 Latency',
|
||||
value: `${Math.round(p99Ms)}ms`,
|
||||
trend: {
|
||||
label: p99Ms > slaThresholdMs ? 'BREACH' : 'OK',
|
||||
variant: p99Ms > slaThresholdMs ? 'error' as const : 'success' as const,
|
||||
},
|
||||
subtitle: `SLA threshold: ${slaThresholdMs}ms \u00B7 Avg: ${Math.round(avgMs)}ms`,
|
||||
borderColor: p99Ms > slaThresholdMs ? 'var(--warning)' : 'var(--success)',
|
||||
},
|
||||
{
|
||||
label: 'SLA Compliance',
|
||||
value: formatSlaCompliance(slaCompliance),
|
||||
trend: {
|
||||
label: slaCompliance >= 99.9 ? 'Excellent' : slaCompliance >= 99 ? 'Good' : 'Degraded',
|
||||
variant: slaCompliance >= 99 ? 'success' as const : slaCompliance >= 95 ? 'warning' as const : 'error' as const,
|
||||
},
|
||||
subtitle: `Target: 99.9%`,
|
||||
borderColor: slaCompliance >= 99 ? 'var(--success)' : 'var(--warning)',
|
||||
},
|
||||
{
|
||||
label: 'Bottleneck',
|
||||
value: bottleneck ? `${Math.round(bottleneck.avgMs)}ms` : '\u2014',
|
||||
trend: {
|
||||
label: bottleneck ? `${bottleneck.pct.toFixed(1)}% of total` : '\u2014',
|
||||
variant: bottleneck && bottleneck.pct > 50 ? 'error' as const : 'muted' as const,
|
||||
},
|
||||
subtitle: bottleneck
|
||||
? `${bottleneck.processorId} \u00B7 ${Math.round(bottleneck.avgMs)}ms \u00B7 ${bottleneck.pct.toFixed(1)}% of total`
|
||||
: 'No processor data',
|
||||
borderColor: 'var(--running)',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DashboardL3() {
|
||||
const { appId, routeId } = useParams<{ appId: string; routeId: string }>();
|
||||
const { timeRange } = useGlobalFilters();
|
||||
const timeFrom = timeRange.start.toISOString();
|
||||
const timeTo = timeRange.end.toISOString();
|
||||
const windowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
|
||||
|
||||
// ── Data hooks ──────────────────────────────────────────────────────────
|
||||
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
|
||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
|
||||
const { data: processorMetrics } = useProcessorMetrics(routeId ?? null, appId);
|
||||
const { data: topErrors } = useTopErrors(timeFrom, timeTo, appId, routeId);
|
||||
const { data: diagramLayout } = useDiagramByRoute(appId, routeId);
|
||||
const { data: appSettings } = useAppSettings(appId);
|
||||
|
||||
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
|
||||
|
||||
// ── Bottleneck (processor with highest avgDurationMs) ───────────────────
|
||||
const bottleneck = useMemo(() => {
|
||||
if (!processorMetrics?.length) return null;
|
||||
const routeAvg = stats?.avgDurationMs ?? 0;
|
||||
const sorted = [...processorMetrics].sort(
|
||||
(a: any, b: any) => b.avgDurationMs - a.avgDurationMs,
|
||||
);
|
||||
const top = sorted[0];
|
||||
const pct = routeAvg > 0 ? (top.avgDurationMs / routeAvg) * 100 : 0;
|
||||
return { processorId: top.processorId, avgMs: top.avgDurationMs, pct };
|
||||
}, [processorMetrics, stats]);
|
||||
|
||||
// ── Sparklines from timeseries ──────────────────────────────────────────
|
||||
const throughputSparkline = useMemo(
|
||||
() => (timeseries?.buckets || []).map((b: any) => b.totalCount),
|
||||
[timeseries],
|
||||
);
|
||||
|
||||
// ── KPI strip ───────────────────────────────────────────────────────────
|
||||
const kpiItems = useMemo(
|
||||
() => buildKpiItems(stats, slaThresholdMs, bottleneck, throughputSparkline, windowSeconds),
|
||||
[stats, slaThresholdMs, bottleneck, throughputSparkline, windowSeconds],
|
||||
);
|
||||
|
||||
// ── Chart series ────────────────────────────────────────────────────────
|
||||
const throughputChartSeries = useMemo(() => [{
|
||||
label: 'Throughput',
|
||||
data: (timeseries?.buckets || []).map((b: any, i: number) => ({
|
||||
x: i,
|
||||
y: b.totalCount,
|
||||
})),
|
||||
}], [timeseries]);
|
||||
|
||||
const latencyChartSeries = useMemo(() => [{
|
||||
label: 'P99',
|
||||
data: (timeseries?.buckets || []).map((b: any, i: number) => ({
|
||||
x: i,
|
||||
y: b.p99DurationMs,
|
||||
})),
|
||||
}], [timeseries]);
|
||||
|
||||
const errorRateChartSeries = useMemo(() => [{
|
||||
label: 'Error Rate',
|
||||
data: (timeseries?.buckets || []).map((b: any, i: number) => ({
|
||||
x: i,
|
||||
y: b.totalCount > 0 ? (b.failedCount / b.totalCount) * 100 : 0,
|
||||
})),
|
||||
color: 'var(--error)',
|
||||
}], [timeseries]);
|
||||
|
||||
// ── Processor table rows ────────────────────────────────────────────────
|
||||
const processorRows: ProcessorRow[] = useMemo(() => {
|
||||
if (!processorMetrics?.length) return [];
|
||||
const routeAvg = stats?.avgDurationMs ?? 0;
|
||||
return processorMetrics.map((m: any) => ({
|
||||
id: m.processorId,
|
||||
processorId: m.processorId,
|
||||
processorType: m.processorType,
|
||||
totalCount: m.totalCount,
|
||||
avgDurationMs: m.avgDurationMs,
|
||||
p99DurationMs: m.p99DurationMs,
|
||||
errorRate: m.errorRate,
|
||||
pctTime: routeAvg > 0 ? (m.avgDurationMs / routeAvg) * 100 : 0,
|
||||
}));
|
||||
}, [processorMetrics, stats]);
|
||||
|
||||
// ── Latency heatmap for ProcessDiagram ──────────────────────────────────
|
||||
const latencyHeatmap = useMemo(() => {
|
||||
if (!processorMetrics?.length) return new Map();
|
||||
const totalAvg = processorMetrics.reduce(
|
||||
(sum: number, m: any) => sum + m.avgDurationMs, 0,
|
||||
);
|
||||
const map = new Map<string, { avgDurationMs: number; p99DurationMs: number; pctOfRoute: number }>();
|
||||
for (const m of processorMetrics) {
|
||||
map.set(m.processorId, {
|
||||
avgDurationMs: m.avgDurationMs,
|
||||
p99DurationMs: m.p99DurationMs,
|
||||
pctOfRoute: totalAvg > 0 ? (m.avgDurationMs / totalAvg) * 100 : 0,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [processorMetrics]);
|
||||
|
||||
// ── Error table rows ────────────────────────────────────────────────────
|
||||
const errorRows: ErrorRow[] = useMemo(
|
||||
() => (topErrors || []).map((e, i) => ({ ...e, id: `${e.errorType}-${i}` })),
|
||||
[topErrors],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.refreshIndicator}>
|
||||
<span className={styles.refreshDot} />
|
||||
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
||||
</div>
|
||||
|
||||
{/* KPI Strip */}
|
||||
<KpiStrip items={kpiItems} />
|
||||
|
||||
{/* Charts — 3 in a row */}
|
||||
{(timeseries?.buckets?.length ?? 0) > 0 && (
|
||||
<div className={styles.chartRow}>
|
||||
<Card title="Throughput">
|
||||
<AreaChart
|
||||
series={throughputChartSeries}
|
||||
yLabel="msg/s"
|
||||
height={200}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Latency Percentiles">
|
||||
<LineChart
|
||||
series={latencyChartSeries}
|
||||
yLabel="ms"
|
||||
threshold={{ value: slaThresholdMs, label: `SLA ${slaThresholdMs}ms` }}
|
||||
height={200}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Error Rate">
|
||||
<AreaChart
|
||||
series={errorRateChartSeries}
|
||||
yLabel="%"
|
||||
height={200}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process Diagram with Latency Heatmap */}
|
||||
{appId && routeId && (
|
||||
<div className={styles.diagramSection}>
|
||||
<ProcessDiagram
|
||||
application={appId}
|
||||
routeId={routeId}
|
||||
diagramLayout={diagramLayout}
|
||||
latencyHeatmap={latencyHeatmap}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processor Metrics Table */}
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Processor Metrics</span>
|
||||
<div>
|
||||
<span className={styles.tableMeta}>
|
||||
{processorRows.length} processor{processorRows.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={PROCESSOR_COLUMNS}
|
||||
data={processorRows}
|
||||
sortable
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top 5 Errors — hidden if empty */}
|
||||
{errorRows.length > 0 && (
|
||||
<div className={styles.errorsSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Top 5 Errors</span>
|
||||
<Badge label={`${errorRows.length}`} color="error" />
|
||||
</div>
|
||||
<DataTable
|
||||
columns={ERROR_COLUMNS}
|
||||
data={errorRows}
|
||||
sortable
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,20 @@ import { useParams } from 'react-router';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Spinner } from '@cameleer/design-system';
|
||||
|
||||
const RoutesMetrics = lazy(() => import('../Routes/RoutesMetrics'));
|
||||
const RouteDetail = lazy(() => import('../Routes/RouteDetail'));
|
||||
const DashboardL1 = lazy(() => import('./DashboardL1'));
|
||||
const DashboardL2 = lazy(() => import('./DashboardL2'));
|
||||
const DashboardL3 = lazy(() => import('./DashboardL3'));
|
||||
|
||||
const Fallback = <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { routeId } = useParams<{ appId?: string; routeId?: string }>();
|
||||
const { appId, routeId } = useParams<{ appId?: string; routeId?: string }>();
|
||||
|
||||
if (routeId) {
|
||||
return <Suspense fallback={Fallback}><RouteDetail /></Suspense>;
|
||||
if (routeId && appId) {
|
||||
return <Suspense fallback={Fallback}><DashboardL3 /></Suspense>;
|
||||
}
|
||||
return <Suspense fallback={Fallback}><RoutesMetrics /></Suspense>;
|
||||
if (appId) {
|
||||
return <Suspense fallback={Fallback}><DashboardL2 /></Suspense>;
|
||||
}
|
||||
return <Suspense fallback={Fallback}><DashboardL1 /></Suspense>;
|
||||
}
|
||||
|
||||
181
ui/src/pages/DashboardTab/DashboardTab.module.css
Normal file
181
ui/src/pages/DashboardTab/DashboardTab.module.css
Normal file
@@ -0,0 +1,181 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.refreshIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.refreshDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 4px rgba(61, 124, 71, 0.5);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.refreshText {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.tableSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.tableTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tableMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Charts */
|
||||
.chartGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chartRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Cells */
|
||||
.monoCell {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.appNameCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.appNameCell:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Rate coloring */
|
||||
.rateGood { color: var(--success); }
|
||||
.rateWarn { color: var(--warning); }
|
||||
.rateBad { color: var(--error); }
|
||||
.rateNeutral { color: var(--text-secondary); }
|
||||
|
||||
/* Diagram container */
|
||||
.diagramSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
/* Table right side (meta + badge) */
|
||||
.tableRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Chart fill */
|
||||
.chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Visualization row: treemap left (wider) + punchcards right (stacked) */
|
||||
.vizRow {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.punchcardStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Toggle button row */
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 0 12px 4px;
|
||||
}
|
||||
|
||||
.toggleBtn {
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.toggleBtn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.toggleActive {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-inset);
|
||||
border-color: var(--border);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Errors section */
|
||||
.errorsSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
134
ui/src/pages/DashboardTab/PunchcardHeatmap.tsx
Normal file
134
ui/src/pages/DashboardTab/PunchcardHeatmap.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import styles from './DashboardTab.module.css';
|
||||
|
||||
export interface PunchcardCell {
|
||||
weekday: number;
|
||||
hour: number;
|
||||
totalCount: number;
|
||||
failedCount: number;
|
||||
}
|
||||
|
||||
interface PunchcardHeatmapProps {
|
||||
cells: PunchcardCell[];
|
||||
}
|
||||
|
||||
type Mode = 'transactions' | 'errors';
|
||||
|
||||
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
// Remap: backend DOW 0=Sun..6=Sat → display 0=Mon..6=Sun
|
||||
function toDisplayDay(dow: number): number {
|
||||
return dow === 0 ? 6 : dow - 1;
|
||||
}
|
||||
|
||||
function transactionColor(ratio: number): string {
|
||||
if (ratio === 0) return 'var(--bg-inset)';
|
||||
// Blue scale matching --running hue
|
||||
const alpha = 0.15 + ratio * 0.75;
|
||||
return `hsla(220, 65%, 50%, ${alpha.toFixed(2)})`;
|
||||
}
|
||||
|
||||
function errorColor(ratio: number): string {
|
||||
if (ratio === 0) return 'var(--bg-inset)';
|
||||
const alpha = 0.15 + ratio * 0.75;
|
||||
return `hsla(0, 65%, 50%, ${alpha.toFixed(2)})`;
|
||||
}
|
||||
|
||||
const CELL = 11;
|
||||
const GAP = 2;
|
||||
const LABEL_W = 28;
|
||||
const LABEL_H = 14;
|
||||
|
||||
export function PunchcardHeatmap({ cells }: PunchcardHeatmapProps) {
|
||||
const [mode, setMode] = useState<Mode>('transactions');
|
||||
|
||||
const { grid, maxVal } = useMemo(() => {
|
||||
const cellMap = new Map<string, PunchcardCell>();
|
||||
for (const c of cells) cellMap.set(`${toDisplayDay(c.weekday)}-${c.hour}`, c);
|
||||
|
||||
let max = 0;
|
||||
const g: { day: number; hour: number; value: number }[] = [];
|
||||
for (let d = 0; d < 7; d++) {
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const cell = cellMap.get(`${d}-${h}`);
|
||||
const val = cell ? (mode === 'errors' ? cell.failedCount : cell.totalCount) : 0;
|
||||
if (val > max) max = val;
|
||||
g.push({ day: d, hour: h, value: val });
|
||||
}
|
||||
}
|
||||
return { grid: g, maxVal: Math.max(max, 1) };
|
||||
}, [cells, mode]);
|
||||
|
||||
const cols = 24;
|
||||
const rows = 7;
|
||||
const svgW = LABEL_W + cols * (CELL + GAP);
|
||||
const svgH = LABEL_H + rows * (CELL + GAP);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.toggleRow}>
|
||||
<button
|
||||
className={`${styles.toggleBtn} ${mode === 'transactions' ? styles.toggleActive : ''}`}
|
||||
onClick={() => setMode('transactions')}
|
||||
>
|
||||
Transactions
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.toggleBtn} ${mode === 'errors' ? styles.toggleActive : ''}`}
|
||||
onClick={() => setMode('errors')}
|
||||
>
|
||||
Errors
|
||||
</button>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${svgW} ${svgH}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
|
||||
{/* Hour labels (top, every 4 hours) */}
|
||||
{[0, 4, 8, 12, 16, 20].map(h => (
|
||||
<text
|
||||
key={h}
|
||||
x={LABEL_W + h * (CELL + GAP) + CELL / 2}
|
||||
y={10}
|
||||
textAnchor="middle"
|
||||
fill="var(--text-faint)"
|
||||
fontSize={7}
|
||||
fontFamily="var(--font-mono)"
|
||||
>
|
||||
{String(h).padStart(2, '0')}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Day labels (left) */}
|
||||
{DAYS.map((day, i) => (
|
||||
<text
|
||||
key={day}
|
||||
x={LABEL_W - 4}
|
||||
y={LABEL_H + i * (CELL + GAP) + CELL / 2 + 3}
|
||||
textAnchor="end"
|
||||
fill="var(--text-faint)"
|
||||
fontSize={7}
|
||||
fontFamily="var(--font-mono)"
|
||||
>
|
||||
{day}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Cells */}
|
||||
{grid.map(({ day, hour, value }) => {
|
||||
const ratio = value / maxVal;
|
||||
const fill = mode === 'errors' ? errorColor(ratio) : transactionColor(ratio);
|
||||
return (
|
||||
<rect
|
||||
key={`${day}-${hour}`}
|
||||
x={LABEL_W + hour * (CELL + GAP)}
|
||||
y={LABEL_H + day * (CELL + GAP)}
|
||||
width={CELL}
|
||||
height={CELL}
|
||||
rx={2}
|
||||
fill={fill}
|
||||
>
|
||||
<title>{`${DAYS[day]} ${String(hour).padStart(2, '0')}:00 — ${value.toLocaleString()} ${mode}`}</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
ui/src/pages/DashboardTab/Treemap.tsx
Normal file
129
ui/src/pages/DashboardTab/Treemap.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Treemap as RechartsTreemap, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { rechartsTheme } from '@cameleer/design-system';
|
||||
|
||||
export interface TreemapItem {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
/** 0-100, drives green→yellow→red color */
|
||||
slaCompliance: number;
|
||||
}
|
||||
|
||||
interface TreemapProps {
|
||||
items: TreemapItem[];
|
||||
onItemClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
function slaColor(pct: number): string {
|
||||
if (pct >= 99) return 'hsl(120, 45%, 85%)';
|
||||
if (pct >= 97) return 'hsl(90, 45%, 85%)';
|
||||
if (pct >= 95) return 'hsl(60, 50%, 85%)';
|
||||
if (pct >= 90) return 'hsl(30, 55%, 85%)';
|
||||
return 'hsl(0, 55%, 85%)';
|
||||
}
|
||||
|
||||
function slaBorderColor(pct: number): string {
|
||||
if (pct >= 99) return 'hsl(120, 40%, 45%)';
|
||||
if (pct >= 97) return 'hsl(90, 40%, 50%)';
|
||||
if (pct >= 95) return 'hsl(60, 45%, 45%)';
|
||||
if (pct >= 90) return 'hsl(30, 50%, 45%)';
|
||||
return 'hsl(0, 50%, 45%)';
|
||||
}
|
||||
|
||||
function slaTextColor(pct: number): string {
|
||||
if (pct >= 95) return 'hsl(120, 20%, 25%)';
|
||||
return 'hsl(0, 40%, 30%)';
|
||||
}
|
||||
|
||||
/** Custom cell renderer for the Recharts Treemap */
|
||||
function CustomCell(props: Record<string, unknown>) {
|
||||
const { x, y, width, height, name, slaCompliance, onItemClick } = props as {
|
||||
x: number; y: number; width: number; height: number;
|
||||
name: string; slaCompliance: number; onItemClick?: (id: string) => void;
|
||||
};
|
||||
|
||||
const w = width ?? 0;
|
||||
const h = height ?? 0;
|
||||
if (w < 2 || h < 2) return null;
|
||||
|
||||
const showLabel = w > 40 && h > 20;
|
||||
const showSla = w > 60 && h > 34;
|
||||
const sla = slaCompliance ?? 100;
|
||||
|
||||
return (
|
||||
<g
|
||||
onClick={() => onItemClick?.(name)}
|
||||
style={{ cursor: onItemClick ? 'pointer' : 'default' }}
|
||||
>
|
||||
<rect
|
||||
x={x + 1} y={y + 1} width={w - 2} height={h - 2}
|
||||
rx={3}
|
||||
fill={slaColor(sla)}
|
||||
stroke={slaBorderColor(sla)}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
{showLabel && (
|
||||
<text
|
||||
x={x + 5} y={y + 15}
|
||||
fill={slaTextColor(sla)}
|
||||
fontSize={11} fontWeight={600}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{name.length > w / 6.5 ? name.slice(0, Math.floor(w / 6.5)) + '\u2026' : name}
|
||||
</text>
|
||||
)}
|
||||
{showSla && (
|
||||
<text
|
||||
x={x + 5} y={y + 28}
|
||||
fill={slaTextColor(sla)}
|
||||
fontSize={10} fontWeight={400}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{sla.toFixed(1)}% SLA
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function Treemap({ items, onItemClick }: TreemapProps) {
|
||||
// Recharts Treemap expects { name, size, ...extra }
|
||||
const data = items.map(i => ({
|
||||
name: i.label,
|
||||
size: i.value,
|
||||
slaCompliance: i.slaCompliance,
|
||||
}));
|
||||
|
||||
const renderContent = useCallback(
|
||||
(props: Record<string, unknown>) => <CustomCell {...props} onItemClick={onItemClick} />,
|
||||
[onItemClick],
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return <div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '2rem' }}>No data</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<RechartsTreemap
|
||||
data={data}
|
||||
dataKey="size"
|
||||
nameKey="name"
|
||||
stroke="none"
|
||||
content={renderContent}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
<Tooltip
|
||||
contentStyle={rechartsTheme.tooltip.contentStyle}
|
||||
labelStyle={rechartsTheme.tooltip.labelStyle}
|
||||
itemStyle={rechartsTheme.tooltip.itemStyle}
|
||||
formatter={(value: number, _name: string, entry: { payload?: { slaCompliance?: number } }) => {
|
||||
const sla = entry.payload?.slaCompliance ?? 0;
|
||||
return [`${value.toLocaleString()} exchanges · ${sla.toFixed(1)}% SLA`];
|
||||
}}
|
||||
/>
|
||||
</RechartsTreemap>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
70
ui/src/pages/DashboardTab/dashboard-utils.ts
Normal file
70
ui/src/pages/DashboardTab/dashboard-utils.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { AppSettings } from '../../api/queries/dashboard';
|
||||
|
||||
export type HealthStatus = 'success' | 'warning' | 'error';
|
||||
|
||||
const DEFAULT_SETTINGS: Pick<AppSettings, 'healthErrorWarn' | 'healthErrorCrit' | 'healthSlaWarn' | 'healthSlaCrit'> = {
|
||||
healthErrorWarn: 1.0,
|
||||
healthErrorCrit: 5.0,
|
||||
healthSlaWarn: 99.0,
|
||||
healthSlaCrit: 95.0,
|
||||
};
|
||||
|
||||
export function computeHealthDot(
|
||||
errorRate: number,
|
||||
slaCompliance: number,
|
||||
settings?: Partial<AppSettings> | null,
|
||||
): HealthStatus {
|
||||
const s = { ...DEFAULT_SETTINGS, ...settings };
|
||||
const errorPct = errorRate * 100;
|
||||
|
||||
if (errorPct > s.healthErrorCrit || slaCompliance < s.healthSlaCrit) return 'error';
|
||||
if (errorPct > s.healthErrorWarn || slaCompliance < s.healthSlaWarn) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
export function formatThroughput(count: number, windowSeconds: number): string {
|
||||
if (windowSeconds <= 0) return '0/s';
|
||||
const tps = count / windowSeconds;
|
||||
if (tps >= 1000) return `${(tps / 1000).toFixed(1)}k/s`;
|
||||
if (tps >= 1) return `${tps.toFixed(0)}/s`;
|
||||
return `${tps.toFixed(2)}/s`;
|
||||
}
|
||||
|
||||
export function formatSlaCompliance(pct: number): string {
|
||||
if (pct < 0) return '—';
|
||||
return `${pct.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function trendIndicator(current: number, previous: number): { label: string; direction: 'up' | 'down' | 'flat' } {
|
||||
if (previous === 0) return { label: '—', direction: 'flat' };
|
||||
const delta = ((current - previous) / previous) * 100;
|
||||
if (Math.abs(delta) < 0.5) return { label: '—', direction: 'flat' };
|
||||
return {
|
||||
label: `${delta > 0 ? '+' : ''}${delta.toFixed(1)}%`,
|
||||
direction: delta > 0 ? 'up' : 'down',
|
||||
};
|
||||
}
|
||||
|
||||
export function trendArrow(trend: 'accelerating' | 'stable' | 'decelerating'): string {
|
||||
switch (trend) {
|
||||
case 'accelerating': return '\u25B2';
|
||||
case 'decelerating': return '\u25BC';
|
||||
default: return '\u2500\u2500';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1) return '<1ms';
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
export function formatRelativeTime(isoString: string): string {
|
||||
const diff = Date.now() - new Date(isoString).getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes} min ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} hr ago`;
|
||||
return `${Math.floor(hours / 24)} d ago`;
|
||||
}
|
||||
@@ -185,6 +185,11 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.replayIcon {
|
||||
color: var(--amber);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chainDuration {
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { GitBranch, Server } from 'lucide-react';
|
||||
import { GitBranch, Server, RotateCcw } from 'lucide-react';
|
||||
import { StatusDot, MonoText, Badge } from '@cameleer/design-system';
|
||||
import { useCorrelationChain } from '../../api/queries/correlation';
|
||||
import { useAgents } from '../../api/queries/agents';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import type { ExecutionDetail } from '../../components/ExecutionDiagram/types';
|
||||
import { attributeBadgeColor } from '../../utils/attribute-color';
|
||||
import { RouteControlBar } from './RouteControlBar';
|
||||
import styles from './ExchangeHeader.module.css';
|
||||
|
||||
interface ExchangeHeaderProps {
|
||||
@@ -47,14 +49,22 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }:
|
||||
const showChain = chain && chain.length > 1;
|
||||
const attrs = Object.entries(detail.attributes ?? {});
|
||||
|
||||
// Look up agent state for icon coloring
|
||||
// Look up agent state for icon coloring + route control capability
|
||||
const { data: agents } = useAgents(undefined, detail.applicationName);
|
||||
const agentState = useMemo(() => {
|
||||
if (!agents || !detail.agentId) return undefined;
|
||||
const agent = (agents as any[]).find((a: any) => a.id === detail.agentId);
|
||||
return agent?.state?.toLowerCase() as 'live' | 'stale' | 'dead' | undefined;
|
||||
const { agentState, hasRouteControl, hasReplay } = useMemo(() => {
|
||||
if (!agents) return { agentState: undefined, hasRouteControl: false, hasReplay: false };
|
||||
const agentList = agents as any[];
|
||||
const agent = detail.agentId ? agentList.find((a: any) => a.id === detail.agentId) : undefined;
|
||||
return {
|
||||
agentState: agent?.state?.toLowerCase() as 'live' | 'stale' | 'dead' | undefined,
|
||||
hasRouteControl: agentList.some((a: any) => a.capabilities?.routeControl === true),
|
||||
hasReplay: agentList.some((a: any) => a.capabilities?.replay === true),
|
||||
};
|
||||
}, [agents, detail.agentId]);
|
||||
|
||||
const roles = useAuthStore((s) => s.roles);
|
||||
const canControl = roles.some(r => r === 'OPERATOR' || r === 'ADMIN');
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
{/* Exchange info — always shown */}
|
||||
@@ -92,12 +102,27 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }:
|
||||
<span className={styles.duration}>{formatDuration(detail.durationMs)}</span>
|
||||
</div>
|
||||
|
||||
{/* Route control / replay — only if agent supports it AND user has operator+ role */}
|
||||
{canControl && (hasRouteControl || hasReplay) && (
|
||||
<RouteControlBar
|
||||
application={detail.applicationName}
|
||||
routeId={detail.routeId}
|
||||
hasRouteControl={hasRouteControl}
|
||||
hasReplay={hasReplay}
|
||||
agentId={detail.agentId}
|
||||
exchangeId={detail.exchangeId}
|
||||
inputHeaders={detail.inputHeaders}
|
||||
inputBody={detail.inputBody}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Correlation chain */}
|
||||
<div className={styles.chain}>
|
||||
<span className={styles.chainLabel}>Correlated</span>
|
||||
{showChain ? chain.map((ce: any, i: number) => {
|
||||
const isCurrent = ce.executionId === detail.executionId;
|
||||
const variant = statusVariant(ce.status);
|
||||
const isReplay = !!ce.isReplay;
|
||||
const statusCls =
|
||||
variant === 'success' ? styles.chainNodeSuccess
|
||||
: variant === 'error' ? styles.chainNodeError
|
||||
@@ -113,9 +138,10 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }:
|
||||
onCorrelatedSelect(ce.executionId, ce.applicationName ?? detail.applicationName, ce.routeId);
|
||||
}
|
||||
}}
|
||||
title={`${ce.executionId}\n${ce.routeId} \u2014 ${formatDuration(ce.durationMs)}`}
|
||||
title={`${ce.executionId}\n${ce.routeId} \u2014 ${formatDuration(ce.durationMs)}${isReplay ? '\n(replay)' : ''}`}
|
||||
>
|
||||
<StatusDot variant={variant} />
|
||||
{isReplay && <RotateCcw size={9} className={styles.replayIcon} />}
|
||||
<span className={styles.chainRoute}>{ce.routeId}</span>
|
||||
<span className={styles.chainDuration}>{formatDuration(ce.durationMs)}</span>
|
||||
</button>
|
||||
|
||||
@@ -20,17 +20,35 @@ import type { SelectedExchange } from '../Dashboard/Dashboard';
|
||||
export default function ExchangesPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { appId: scopedAppId, routeId: scopedRouteId } = useParams<{ appId?: string; routeId?: string }>();
|
||||
const { appId: scopedAppId, routeId: scopedRouteId, exchangeId: scopedExchangeId } =
|
||||
useParams<{ appId?: string; routeId?: string; exchangeId?: string }>();
|
||||
|
||||
// Restore selection from browser history state (enables Back/Forward)
|
||||
const stateSelected = (location.state as any)?.selectedExchange as SelectedExchange | undefined;
|
||||
const [selected, setSelectedInternal] = useState<SelectedExchange | null>(stateSelected ?? null);
|
||||
|
||||
// Sync from history state when the user navigates Back/Forward
|
||||
// Derive selection from URL params when no state-based selection exists (Cmd-K, bookmarks)
|
||||
const urlDerivedExchange: SelectedExchange | null =
|
||||
(scopedExchangeId && scopedAppId && scopedRouteId)
|
||||
? { executionId: scopedExchangeId, applicationName: scopedAppId, routeId: scopedRouteId }
|
||||
: null;
|
||||
|
||||
const [selected, setSelectedInternal] = useState<SelectedExchange | null>(stateSelected ?? urlDerivedExchange);
|
||||
|
||||
// Sync selection from history state or URL params on navigation changes
|
||||
useEffect(() => {
|
||||
const restored = (location.state as any)?.selectedExchange as SelectedExchange | undefined;
|
||||
setSelectedInternal(restored ?? null);
|
||||
}, [location.state]);
|
||||
if (restored) {
|
||||
setSelectedInternal(restored);
|
||||
} else if (scopedExchangeId && scopedAppId && scopedRouteId) {
|
||||
setSelectedInternal({
|
||||
executionId: scopedExchangeId,
|
||||
applicationName: scopedAppId,
|
||||
routeId: scopedRouteId,
|
||||
});
|
||||
} else {
|
||||
setSelectedInternal(null);
|
||||
}
|
||||
}, [location.state, scopedExchangeId, scopedAppId, scopedRouteId]);
|
||||
|
||||
const [splitPercent, setSplitPercent] = useState(50);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -52,10 +70,15 @@ export default function ExchangesPage() {
|
||||
});
|
||||
}, [navigate, location.pathname, location.search, location.state]);
|
||||
|
||||
// Clear selection: push a history entry without selection (so Back returns to selected state)
|
||||
// Clear selection: navigate up to route level when URL has exchangeId
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelectedInternal(null);
|
||||
}, []);
|
||||
if (scopedExchangeId && scopedAppId && scopedRouteId) {
|
||||
navigate(`/exchanges/${scopedAppId}/${scopedRouteId}`, {
|
||||
state: { ...location.state, selectedExchange: undefined },
|
||||
});
|
||||
}
|
||||
}, [scopedExchangeId, scopedAppId, scopedRouteId, navigate, location.state]);
|
||||
|
||||
const handleSplitterDown = useCallback((e: React.PointerEvent) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
@@ -152,13 +175,12 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
||||
return map;
|
||||
}, [catalog]);
|
||||
|
||||
// Build nodeConfigs from tracing store + app config (for TRACE/TAP badges)
|
||||
// Build nodeConfigs from app config (for TRACE/TAP badges)
|
||||
const { data: appConfig } = useApplicationConfig(appId);
|
||||
const tracedMap = useTracingStore((s) => s.tracedProcessors[appId]);
|
||||
const nodeConfigs = useMemo(() => {
|
||||
const map = new Map<string, NodeConfig>();
|
||||
if (tracedMap) {
|
||||
for (const pid of Object.keys(tracedMap)) {
|
||||
if (appConfig?.tracedProcessors) {
|
||||
for (const pid of Object.keys(appConfig.tracedProcessors)) {
|
||||
map.set(pid, { traceEnabled: true });
|
||||
}
|
||||
}
|
||||
@@ -171,7 +193,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [tracedMap, appConfig]);
|
||||
}, [appConfig]);
|
||||
|
||||
// Processor options for tap modal dropdown
|
||||
const processorOptions = useMemo(() => {
|
||||
|
||||
81
ui/src/pages/Exchanges/RouteControlBar.module.css
Normal file
81
ui/src/pages/Exchanges/RouteControlBar.module.css
Normal file
@@ -0,0 +1,81 @@
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
margin-right: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group.sending {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.segment:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.segment:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Icon semantic colors */
|
||||
.success svg { color: var(--success); }
|
||||
.danger svg { color: var(--error); }
|
||||
.warning svg { color: var(--amber); }
|
||||
|
||||
/* Preserve icon color on hover */
|
||||
.success:hover:not(:disabled) svg { color: var(--success); }
|
||||
.danger:hover:not(:disabled) svg { color: var(--error); }
|
||||
.warning:hover:not(:disabled) svg { color: var(--amber); }
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 0.8s linear infinite;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
115
ui/src/pages/Exchanges/RouteControlBar.tsx
Normal file
115
ui/src/pages/Exchanges/RouteControlBar.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import { Play, Square, Pause, PlayCircle, RotateCcw, Loader2 } from 'lucide-react';
|
||||
import { useToast } from '@cameleer/design-system';
|
||||
import { useSendRouteCommand, useReplayExchange } from '../../api/queries/commands';
|
||||
import styles from './RouteControlBar.module.css';
|
||||
|
||||
interface RouteControlBarProps {
|
||||
application: string;
|
||||
routeId: string;
|
||||
hasRouteControl: boolean;
|
||||
hasReplay: boolean;
|
||||
agentId?: string;
|
||||
exchangeId?: string;
|
||||
inputHeaders?: string;
|
||||
inputBody?: string;
|
||||
}
|
||||
|
||||
type RouteAction = 'start' | 'stop' | 'suspend' | 'resume';
|
||||
|
||||
const ROUTE_ACTIONS: { action: RouteAction; label: string; icon: typeof Play; colorClass: string }[] = [
|
||||
{ action: 'start', label: 'Start', icon: Play, colorClass: styles.success },
|
||||
{ action: 'stop', label: 'Stop', icon: Square, colorClass: styles.danger },
|
||||
{ action: 'suspend', label: 'Suspend', icon: Pause, colorClass: styles.warning },
|
||||
{ action: 'resume', label: 'Resume', icon: PlayCircle, colorClass: styles.success },
|
||||
];
|
||||
|
||||
export function RouteControlBar({ application, routeId, hasRouteControl, hasReplay, agentId, exchangeId, inputHeaders, inputBody }: RouteControlBarProps) {
|
||||
const { toast } = useToast();
|
||||
const sendRouteCommand = useSendRouteCommand();
|
||||
const replayExchange = useReplayExchange();
|
||||
const [sendingAction, setSendingAction] = useState<string | null>(null);
|
||||
|
||||
const busy = sendingAction !== null;
|
||||
|
||||
function handleRouteAction(action: RouteAction) {
|
||||
setSendingAction(action);
|
||||
sendRouteCommand.mutate(
|
||||
{ application, action, routeId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: `Route ${action} sent`, description: `${routeId} on ${application}`, variant: 'success' });
|
||||
setSendingAction(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: `Route ${action} failed`, description: err.message, variant: 'error' });
|
||||
setSendingAction(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleReplay() {
|
||||
if (!agentId) return;
|
||||
let headers: Record<string, string> = {};
|
||||
try { headers = inputHeaders ? JSON.parse(inputHeaders) : {}; } catch { /* empty */ }
|
||||
setSendingAction('replay');
|
||||
replayExchange.mutate(
|
||||
{ agentId, routeId, headers, body: inputBody ?? '', originalExchangeId: exchangeId },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (result.status === 'SUCCESS') {
|
||||
toast({ title: 'Replay completed', description: result.message ?? `${routeId} on ${agentId}`, variant: 'success' });
|
||||
} else {
|
||||
toast({ title: 'Replay failed', description: result.message ?? 'Agent reported failure', variant: 'error' });
|
||||
}
|
||||
setSendingAction(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: 'Replay failed', description: err.message, variant: 'error' });
|
||||
setSendingAction(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.bar}>
|
||||
<span className={styles.label}>Route</span>
|
||||
{hasRouteControl && (
|
||||
<div className={`${styles.group} ${busy ? styles.sending : ''}`}>
|
||||
{ROUTE_ACTIONS.map(({ action, label, icon: Icon, colorClass }) => (
|
||||
<button
|
||||
key={action}
|
||||
className={`${styles.segment} ${colorClass}`}
|
||||
disabled={busy}
|
||||
onClick={() => handleRouteAction(action)}
|
||||
title={`${label} route ${routeId}`}
|
||||
>
|
||||
{sendingAction === action
|
||||
? <Loader2 size={12} className={styles.spinner} />
|
||||
: <Icon size={12} />}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasRouteControl && hasReplay && <span className={styles.divider} />}
|
||||
{hasReplay && (
|
||||
<div className={`${styles.group} ${busy ? styles.sending : ''}`}>
|
||||
<button
|
||||
className={`${styles.segment} ${styles.success}`}
|
||||
disabled={busy || !agentId}
|
||||
onClick={handleReplay}
|
||||
title={`Replay exchange on ${agentId ?? 'agent'}`}
|
||||
>
|
||||
{sendingAction === 'replay'
|
||||
? <Loader2 size={12} className={styles.spinner} />
|
||||
: <RotateCcw size={12} />}
|
||||
Replay
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user