30 Commits

Author SHA1 Message Date
hsiegeln
ebe768711b fix: Cmd-K exchange selection reads exchangeId from URL params
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m1s
CI / docker (push) Successful in 57s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 36s
ExchangesPage ignored the exchangeId URL parameter, so selecting an
exchange from the command palette navigated to the right URL but never
displayed the execution overlay. Now derives selection from URL params
as fallback, and LayoutShell passes selectedExchange in state for
exchange/attribute results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:26:36 +02:00
hsiegeln
af45f93854 fix: add missing isReplay parameter to test constructors
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m1s
CI / docker (push) Successful in 57s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
The ExecutionDocument and ExecutionRecord records gained an isReplay
field but the integration tests were not updated, breaking CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:08:12 +02:00
hsiegeln
da1d74309e fix: detect replay via replayExchangeId field, not just header
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 1m4s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
The X-Cameleer-Replay header is only available when inputSnapshot is
captured (DETAILED/DEEP engine level). The agent always sets
replayExchangeId on RouteExecution, so check that first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:57:59 +02:00
hsiegeln
7a4d7b6915 fix: resolve 8 SonarQube reliability bugs
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 1m2s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
- ElkDiagramRenderer: guard against null containingNode before getElkRoot()
- OpenSearchAdminController: return 503/502 instead of 200 on errors
- DatabaseAdminController: return 503 instead of 200 on connection failure
- SpaForwardController: replace unbound {path} variables with /** wildcards
- WriteBuffer: check offer() return value and log on unexpected rejection
- ApiExceptionHandler: extract getReason() to local var for null safety
- Admin UI pages: handle isError state for disconnected service display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:39:54 +02:00
hsiegeln
ab7031e6ed feat: add is_replay flag to execution pipeline and UI
Detect replayed exchanges via X-Cameleer-Replay header during ingestion,
persist the flag through PostgreSQL and OpenSearch, and surface it in
the dashboard (amber replay icon) and exchange detail chain view.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:39:40 +02:00
hsiegeln
cf3cec0164 feat: show replay marker on correlated chain entries
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m46s
CI / docker (push) Successful in 1m52s
CI / deploy (push) Successful in 51s
CI / deploy-feature (push) Has been skipped
SonarQube / sonarqube (push) Failing after 1m16s
Exchanges with a _replay attribute now display a small amber
RotateCcw icon between the status dot and route name in the
correlation chain. Tooltip also indicates (replay).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:26:55 +02:00
hsiegeln
79762c3f0d fix: audit replay with actual outcome, not premature SUCCESS
All checks were successful
CI / build (push) Successful in 2m8s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 1m7s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 42s
Replay audit log now records the agent's reply status (SUCCESS/FAILURE),
message, and error details. Timeout and internal errors are also logged
as FAILURE with the cause.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:14:36 +02:00
hsiegeln
715cbc1894 feat: synchronous replay endpoint with agent response status
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m8s
CI / docker (push) Successful in 56s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s
Add dedicated POST /agents/{id}/replay endpoint that uses
addCommandWithReply to wait for the agent ACK (30s timeout).
Returns the actual replay result (status, message, data) instead
of just a delivery confirmation.

Frontend toast now reflects the agent's response: "Replay completed"
on success, agent error message on failure, timeout message if the
agent doesn't respond.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:48:02 +02:00
hsiegeln
dd398178f0 docs: add route-control command to HOWTO and CLAUDE.md
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m35s
CI / docker (push) Successful in 13s
CI / deploy (push) Successful in 49s
CI / deploy-feature (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:44:12 +02:00
hsiegeln
8b0d473fcd feat: add route control bar and fix replay protocol compliance
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m4s
CI / docker (push) Successful in 1m0s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Has been cancelled
Add ROUTE_CONTROL command type and route-control mapping in
AgentCommandController. New RouteControlBar component in the exchange
header shows Start/Stop/Suspend/Resume actions (grouped pill bar) and
a Replay button, gated by agent capabilities and OPERATOR/ADMIN role.

Fix useReplayExchange hook to match protocol section 16: payload now
uses { routeId, exchange: { body, headers }, originalExchangeId, nonce }
instead of the flat { headers, body } format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:42:06 +02:00
hsiegeln
30e9b55379 fix: detail panel respects iteration filtering
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m35s
CI / docker (push) Successful in 1m12s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 48s
- findProcessorInTree now skips non-selected iteration wrappers so
  the returned ProcessorNode has data from the correct iteration
- Gate selectedProcessor on overlay presence so processors not
  executed in the current iteration don't show in the detail panel
- Header shows "Exchange Details" or "Processor Details" contextually

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:25:28 +02:00
hsiegeln
3091754b0f fix: dim compound containers when no descendants executed in overlay
CompoundNode (circuit breaker, choice, etc.) now renders at 0.35
opacity when the overlay is active but neither the compound itself
nor any of its diagram descendants appear in the execution overlay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:13:40 +02:00
hsiegeln
26de222884 refactor: move config badges inline, fix trace config from server
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m1s
CI / docker (push) Successful in 56s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 40s
- Render hasTrace/hasTap/status badges inside the node card in both
  raw diagram and overlay modes (consistent positioning)
- Pulse only on trace badge in overlay mode when hasTraceData is true
- Fix nodeConfigs to read tracedProcessors from appConfig instead of
  never-synced tracing store

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:08:40 +02:00
hsiegeln
2f2f93f37e fix: move useCallback before early returns to fix hooks order
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:47:17 +02:00
hsiegeln
1b9a3b84a0 feat: add JSON download button to execution diagram
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:43:02 +02:00
hsiegeln
c77de4a232 fix: simplify detail panel header to just "Details"
Remove redundant processor name, status, ID, and duration from the
header bar — all visible in the Info tab and diagram overlay already.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:40:18 +02:00
hsiegeln
15b8c09e17 fix: position resolved URI directly below text lines in diagram overlay
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:35:28 +02:00
hsiegeln
77e87504d6 feat: agent row click navigates to detail page instead of slide-in
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m2s
CI / docker (push) Successful in 55s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 40s
Replace DetailPanel overlay with direct navigation to
/runtime/:appId/:instanceId on row click. Removes the slide-in panel,
AgentOverviewContent, and AgentPerformanceContent helper components.
The full AgentInstance page already provides all the same data plus
more (charts, routes, logs).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:28:12 +02:00
hsiegeln
d8a21f0724 feat: GitHub-style contribution grid for punchcard heatmap
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m3s
CI / docker (push) Successful in 54s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 36s
Replace Recharts ScatterChart with compact SVG grid of small rounded
squares (11x11px, 2px gap). 7 rows (Mon-Sun) x 24 columns (hours).
Color intensity = value relative to max. Transactions = blue scale,
Errors = red scale. Toggle switches between modes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:49:45 +02:00
hsiegeln
4a91ca0774 feat: consolidate punchcard heatmaps into single toggle component
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m1s
CI / docker (push) Successful in 54s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s
Replace two separate Transaction/Error punchcard cards with a single
card containing a Transactions/Errors toggle. Uses internal state to
switch between modes without remounting the chart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:45:22 +02:00
hsiegeln
52c22f1eb9 fix: dashboard flickering on poll, animation replay, and scroll
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m3s
CI / docker (push) Successful in 54s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 36s
- Add placeholderData to useRouteMetrics and usePunchcard hooks so data
  stays stable between refetches instead of going undefined → flicker
- Disable Recharts animation on Treemap (isAnimationActive=false)
- Make .content scrollable (overflow-y: auto, flex: 1, min-height: 0)
  so charts below the fold are accessible

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:42:02 +02:00
hsiegeln
a517785050 chore: regenerate OpenAPI types and remove type assertion hacks
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m2s
CI / docker (push) Successful in 56s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 39s
Regenerated schema.d.ts from live backend — now includes slaCompliance
on ExecutionStats/RouteMetrics, filterMatched/duplicateMessage on
ProcessorNode, and all new dashboard endpoints (timeseries/by-app,
timeseries/by-route, punchcard, errors/top, app-settings).

Removed Record<string, unknown> casts that were working around the
stale schema.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:36:44 +02:00
hsiegeln
474738a894 fix: resolve TypeScript strict mode errors failing CI
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m4s
CI / docker (push) Successful in 1m25s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
- StatusDot: status → variant (correct prop name)
- Badge: color="muted" → color="auto" (valid BadgeColor)
- AreaChart: remove stacked prop (not in AreaChartProps)
- DataTable: remove defaultSort prop (not in DataTableProps)
- TopError → ErrorRow with id field (DataTable requires T extends {id})
- slaCompliance: type assertion for runtime field not in TS schema
- PunchcardHeatmap Scatter shape: proper typing for custom renderer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:26:26 +02:00
hsiegeln
41397ae067 feat: migrate Treemap and PunchcardHeatmap to Recharts
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 31s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Replace custom SVG chart implementations with Recharts components:
- Treemap: uses Recharts Treemap with custom content renderer for
  SLA-colored cells, labels, and click navigation
- PunchcardHeatmap: uses Recharts ScatterChart with custom Rectangle
  shape for weekday x hour heatmap grid cells

Both use ResponsiveContainer (no more explicit width/height props) and
rechartsTheme from the design system for consistent tooltip styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:20:29 +02:00
hsiegeln
dd91a4989b chore: update @cameleer/design-system to v0.1.21
Some checks failed
CI / build (push) Failing after 43s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
New exports: rechartsTheme (pre-configured Recharts prop objects matching
design system styling), CHART_COLORS (series color palette), and properly
exported ChartSeries/DataPoint interfaces. No breaking changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:03:27 +02:00
hsiegeln
f06f5f2bb1 docs: add CSS variable rule to CLAUDE.md
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 26s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Always use design system CSS variables for colors, never hardcode hex.
Applies to CSS modules, inline styles, and SVG fill/stroke attributes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:00:53 +02:00
hsiegeln
c8caf3dc44 fix: use CSS variables directly for gate state colors
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 25s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Use var(--amber) and var(--amber-bg) in SVG fill/stroke attributes
instead of hardcoded hex values. SVG presentation attributes resolve
CSS variables correctly, and this respects dark mode theme switching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:59:07 +02:00
hsiegeln
2de10f6eb0 fix: use theme amber colors for gate state instead of arbitrary hex
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 26s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Use --amber (#C6820E) and --amber-bg (#FDF6E9) from the design system
theme instead of hardcoded #D97706/#FFFBEB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:55:59 +02:00
hsiegeln
e2c0f203f9 feat: amber container for filter/idempotent gate state + red pulse on failed badge
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 29s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
When a filter processor rejects a message (filterMatched=false) or an
idempotent consumer detects a duplicate (duplicateMessage=true), the
compound container turns amber (header, border, body tint).

Also adds red pulsing rings on the failed processor badge (same SMIL
pattern as the teal hasTraceData pulse).

Backend: ProcessorNode gains filterMatched/duplicateMessage fields,
threaded from ProcessorExecution JSON path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:53:57 +02:00
hsiegeln
a383b9bcf4 feat: add red pulse effect to failed processor badges in diagram overlay
Failed processor nodes now show expanding/fading red rings around the
error badge (same SMIL animation pattern as the teal hasTraceData pulse).
Two staggered circles expand from r=6 to r=14 over 1.5s, making failures
immediately visible in complex route diagrams.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:42:35 +02:00
56 changed files with 1837 additions and 6034 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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");
};
}
}

View File

@@ -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"));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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});

View File

@@ -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
) {}

View File

@@ -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
) {}

View File

@@ -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"))
);
}

View File

@@ -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")

View File

@@ -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(

View File

@@ -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";

View File

@@ -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';

View File

@@ -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();

View File

@@ -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(

View File

@@ -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));
}
}

View File

@@ -8,5 +8,6 @@ public enum CommandType {
DEEP_TRACE,
REPLAY,
SET_TRACED_PROCESSORS,
TEST_EXPRESSION
TEST_EXPRESSION,
ROUTE_CONTROL
}

View File

@@ -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
));
}

View File

@@ -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); }
}

View File

@@ -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();

View File

@@ -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
);
}

View File

@@ -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;
}

View File

@@ -34,6 +34,7 @@ public record ExecutionSummary(
String diagramContentHash,
String highlight,
Map<String, String> attributes,
boolean hasTraceData
boolean hasTraceData,
boolean isReplay
) {
}

View File

@@ -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(

View File

@@ -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
View File

@@ -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",

View File

@@ -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

View File

@@ -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,
});
}

View File

@@ -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>
},
})
}

View File

@@ -106,7 +106,7 @@ export function usePunchcard(application?: string) {
return useQuery({
queryKey: ['dashboard', 'punchcard', application],
queryFn: () => fetchJson<PunchcardCell[]>('/search/stats/punchcard', { application }),
placeholderData: (prev: PunchcardCell[] | undefined) => prev,
placeholderData: (prev: PunchcardCell[] | undefined) => prev ?? [],
refetchInterval,
});
}

487
ui/src/api/schema.d.ts vendored
View File

@@ -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;

View File

@@ -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 */}

View File

@@ -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);

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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]);

View File

@@ -66,6 +66,15 @@ export function CompoundNode({
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 (
@@ -80,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}
@@ -101,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} />
@@ -118,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}
@@ -128,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)`}>
@@ -262,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;
}

View File

@@ -1,8 +1,8 @@
import React from 'react';
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
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;
@@ -158,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>
</>
@@ -176,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 && (
<>
<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"
/>
</>
)}
// 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={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 && (

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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>
);
}

View File

@@ -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>
),
},

View File

@@ -51,7 +51,7 @@ const APP_COLUMNS: Column<AppRow>[] = [
{
key: 'health',
header: '',
render: (_, row) => <StatusDot status={row.health} />,
render: (_, row) => <StatusDot variant={row.health} />,
},
{
key: 'appId',
@@ -316,7 +316,7 @@ export default function DashboardL1() {
);
// Global SLA compliance from backend stats (exact calculation from executions table)
const globalSlaCompliance = (stats as Record<string, unknown>)?.slaCompliance as number ?? -1;
const globalSlaCompliance = stats?.slaCompliance ?? -1;
const effectiveSlaCompliance = globalSlaCompliance >= 0 ? globalSlaCompliance : 100;
// Active error count = distinct error types
@@ -413,7 +413,7 @@ export default function DashboardL1() {
<span className={styles.tableTitle}>Application Health</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{appRows.length} applications</span>
<Badge label="ALL" color="muted" />
<Badge label="ALL" color="auto" />
</div>
</div>
<DataTable
@@ -431,7 +431,6 @@ export default function DashboardL1() {
<AreaChart
series={throughputByAppSeries}
yLabel="msg/s"
stacked
height={200}
className={styles.chart}
/>
@@ -454,19 +453,12 @@ export default function DashboardL1() {
<Card title="Application Volume vs SLA Compliance">
<Treemap
items={treemapItems}
width={600}
height={300}
onItemClick={(id) => navigate(`/dashboard/${id}`)}
/>
</Card>
<div className={styles.punchcardStack}>
<Card title="Transactions (7-day pattern)">
<PunchcardHeatmap cells={punchcardData ?? []} mode="transactions" width={400} height={140} />
</Card>
<Card title="Errors (7-day pattern)">
<PunchcardHeatmap cells={punchcardData ?? []} mode="errors" width={400} height={140} />
</Card>
</div>
<Card title="7-Day Pattern">
<PunchcardHeatmap cells={punchcardData ?? []} />
</Card>
</div>
)}
</div>

View File

@@ -113,7 +113,9 @@ const ROUTE_COLUMNS: Column<RouteRow>[] = [
// ── Top errors columns ──────────────────────────────────────────────────────
const ERROR_COLUMNS: Column<TopError>[] = [
type ErrorRow = TopError & { id: string };
const ERROR_COLUMNS: Column<ErrorRow>[] = [
{
key: 'errorType',
header: 'Error Type',
@@ -290,7 +292,7 @@ export default function DashboardL2() {
successRate: m.successRate,
avgDurationMs: m.avgDurationMs,
p99DurationMs: m.p99DurationMs,
slaCompliance: (m as Record<string, unknown>).slaCompliance as number ?? -1,
slaCompliance: m.slaCompliance ?? -1,
sparkline: m.sparkline ?? [],
})),
[metrics],
@@ -394,7 +396,6 @@ export default function DashboardL2() {
<AreaChart
series={throughputByRouteSeries}
yLabel="msg/s"
stacked
height={200}
className={styles.chart}
/>
@@ -433,19 +434,12 @@ export default function DashboardL2() {
<Card title="Route Volume vs SLA Compliance">
<Treemap
items={treemapItems}
width={600}
height={300}
onItemClick={(id) => navigate(`/dashboard/${appId}/${id}`)}
/>
</Card>
<div className={styles.punchcardStack}>
<Card title="Transactions (7-day pattern)">
<PunchcardHeatmap cells={punchcardData ?? []} mode="transactions" width={400} height={140} />
</Card>
<Card title="Errors (7-day pattern)">
<PunchcardHeatmap cells={punchcardData ?? []} mode="errors" width={400} height={140} />
</Card>
</div>
<Card title="7-Day Pattern">
<PunchcardHeatmap cells={punchcardData ?? []} />
</Card>
</div>
)}
</div>

View File

@@ -56,7 +56,7 @@ const PROCESSOR_COLUMNS: Column<ProcessorRow>[] = [
key: 'processorType',
header: 'Type',
sortable: true,
render: (_, row) => <Badge label={row.processorType} color="muted" />,
render: (_, row) => <Badge label={row.processorType} color="auto" />,
},
{
key: 'totalCount',
@@ -411,7 +411,6 @@ export default function DashboardL3() {
columns={PROCESSOR_COLUMNS}
data={processorRows}
sortable
defaultSort={{ key: 'p99DurationMs', direction: 'desc' }}
/>
</div>

View File

@@ -2,6 +2,10 @@
display: flex;
flex-direction: column;
gap: 20px;
flex: 1;
min-height: 0;
overflow-y: auto;
padding-bottom: 20px;
}
.refreshIndicator {
@@ -136,6 +140,37 @@
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);

View File

@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import styles from './DashboardTab.module.css';
export interface PunchcardCell {
weekday: number;
@@ -9,117 +10,125 @@ export interface PunchcardCell {
interface PunchcardHeatmapProps {
cells: PunchcardCell[];
mode: 'transactions' | 'errors';
width: number;
height: number;
}
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const LEFT_MARGIN = 28;
const TOP_MARGIN = 18;
const BOTTOM_MARGIN = 4;
const RIGHT_MARGIN = 4;
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 'hsl(220, 15%, 95%)';
const lightness = 90 - ratio * 55;
return `hsl(220, 60%, ${Math.round(lightness)}%)`;
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 'hsl(0, 10%, 95%)';
const lightness = 90 - ratio * 55;
return `hsl(0, 65%, ${Math.round(lightness)}%)`;
if (ratio === 0) return 'var(--bg-inset)';
const alpha = 0.15 + ratio * 0.75;
return `hsla(0, 65%, 50%, ${alpha.toFixed(2)})`;
}
export function PunchcardHeatmap({ cells, mode, width, height }: PunchcardHeatmapProps) {
const grid = useMemo(() => {
const map = new Map<string, PunchcardCell>();
for (const c of cells) {
map.set(`${c.weekday}-${c.hour}`, c);
}
const CELL = 11;
const GAP = 2;
const LABEL_W = 28;
const LABEL_H = 14;
const values: number[] = [];
for (const c of cells) {
values.push(mode === 'errors' ? c.failedCount : c.totalCount);
}
const maxVal = Math.max(...values, 1);
export function PunchcardHeatmap({ cells }: PunchcardHeatmapProps) {
const [mode, setMode] = useState<Mode>('transactions');
const gridWidth = width - LEFT_MARGIN - RIGHT_MARGIN;
const gridHeight = height - TOP_MARGIN - BOTTOM_MARGIN;
const cellW = gridWidth / 7;
const cellH = gridHeight / 24;
const rects: { x: number; y: number; w: number; h: number; fill: string; value: number; day: string; hour: number }[] = [];
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 = map.get(`${d}-${h}`);
const cell = cellMap.get(`${d}-${h}`);
const val = cell ? (mode === 'errors' ? cell.failedCount : cell.totalCount) : 0;
const ratio = maxVal > 0 ? val / maxVal : 0;
const fill = mode === 'errors' ? errorColor(ratio) : transactionColor(ratio);
rects.push({
x: LEFT_MARGIN + d * cellW,
y: TOP_MARGIN + h * cellH,
w: cellW,
h: cellH,
fill,
value: val,
day: DAYS[d],
hour: h,
});
if (val > max) max = val;
g.push({ day: d, hour: h, value: val });
}
}
return { rects, cellW, cellH };
}, [cells, mode, width, height]);
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 (
<svg viewBox={`0 0 ${width} ${height}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
{/* Day labels (top) */}
{DAYS.map((day, i) => (
<text
key={day}
x={LEFT_MARGIN + i * grid.cellW + grid.cellW / 2}
y={12}
textAnchor="middle"
fill="var(--text-muted)"
fontSize={9}
fontFamily="var(--font-mono)"
<div>
<div className={styles.toggleRow}>
<button
className={`${styles.toggleBtn} ${mode === 'transactions' ? styles.toggleActive : ''}`}
onClick={() => setMode('transactions')}
>
{day}
</text>
))}
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>
))}
{/* Hour labels (left, every 4 hours) */}
{[0, 4, 8, 12, 16, 20].map((h) => (
<text
key={h}
x={LEFT_MARGIN - 4}
y={TOP_MARGIN + h * grid.cellH + grid.cellH / 2 + 3}
textAnchor="end"
fill="var(--text-muted)"
fontSize={8}
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.rects.map((r) => (
<rect
key={`${r.day}-${r.hour}`}
x={r.x + 0.5}
y={r.y + 0.5}
width={Math.max(r.w - 1, 0)}
height={Math.max(r.h - 1, 0)}
rx={1.5}
fill={r.fill}
>
<title>{`${r.day} ${String(r.hour).padStart(2, '0')}:00 — ${r.value.toLocaleString()} ${mode}`}</title>
</rect>
))}
</svg>
{/* 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>
);
}

View File

@@ -1,4 +1,6 @@
import { useMemo } from 'react';
import { useCallback } from 'react';
import { Treemap as RechartsTreemap, ResponsiveContainer, Tooltip } from 'recharts';
import { rechartsTheme } from '@cameleer/design-system';
export interface TreemapItem {
id: string;
@@ -10,19 +12,9 @@ export interface TreemapItem {
interface TreemapProps {
items: TreemapItem[];
width: number;
height: number;
onItemClick?: (id: string) => void;
}
interface LayoutRect {
item: TreemapItem;
x: number;
y: number;
w: number;
h: number;
}
function slaColor(pct: number): string {
if (pct >= 99) return 'hsl(120, 45%, 85%)';
if (pct >= 97) return 'hsl(90, 45%, 85%)';
@@ -44,116 +36,94 @@ function slaTextColor(pct: number): string {
return 'hsl(0, 40%, 30%)';
}
/** Squarified treemap layout */
function layoutTreemap(items: TreemapItem[], x: number, y: number, w: number, h: number): LayoutRect[] {
if (items.length === 0) return [];
const total = items.reduce((s, i) => s + i.value, 0);
if (total === 0) return items.map((item, i) => ({ item, x: x + i, y, w: 1, h: 1 }));
/** 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 sorted = [...items].sort((a, b) => b.value - a.value);
const rects: LayoutRect[] = [];
layoutSlice(sorted, total, x, y, w, h, rects);
return rects;
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>
);
}
function layoutSlice(
items: TreemapItem[], total: number,
x: number, y: number, w: number, h: number,
out: LayoutRect[],
) {
if (items.length === 0) return;
if (items.length === 1) {
out.push({ item: items[0], x, y, w, h });
return;
}
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 isWide = w >= h;
let partialSum = 0;
let splitIndex = 0;
// Find split point closest to half the total area
const halfTotal = total / 2;
for (let i = 0; i < items.length - 1; i++) {
partialSum += items[i].value;
if (partialSum >= halfTotal) {
splitIndex = i + 1;
break;
}
splitIndex = i + 1;
}
const leftTotal = items.slice(0, splitIndex).reduce((s, i) => s + i.value, 0);
const ratio = total > 0 ? leftTotal / total : 0.5;
if (isWide) {
const splitX = x + w * ratio;
layoutSlice(items.slice(0, splitIndex), leftTotal, x, y, w * ratio, h, out);
layoutSlice(items.slice(splitIndex), total - leftTotal, splitX, y, w * (1 - ratio), h, out);
} else {
const splitY = y + h * ratio;
layoutSlice(items.slice(0, splitIndex), leftTotal, x, y, w, h * ratio, out);
layoutSlice(items.slice(splitIndex), total - leftTotal, x, splitY, w, h * (1 - ratio), out);
}
}
export function Treemap({ items, width, height, onItemClick }: TreemapProps) {
const rects = useMemo(() => layoutTreemap(items, 1, 1, width - 2, height - 2), [items, width, height]);
const renderContent = useCallback(
(props: Record<string, unknown>) => <CustomCell {...props} onItemClick={onItemClick} />,
[onItemClick],
);
if (items.length === 0) {
return (
<svg viewBox={`0 0 ${width} ${height}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
<text x={width / 2} y={height / 2} textAnchor="middle" fill="#9CA3AF" fontSize={12}>No data</text>
</svg>
);
return <div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '2rem' }}>No data</div>;
}
return (
<svg viewBox={`0 0 ${width} ${height}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
{rects.map(({ item, x, y, w, h }) => {
const pad = 1;
const rx = x + pad;
const ry = y + pad;
const rw = Math.max(w - pad * 2, 0);
const rh = Math.max(h - pad * 2, 0);
const showLabel = rw > 40 && rh > 20;
const showSla = rw > 60 && rh > 34;
return (
<g
key={item.id}
onClick={() => onItemClick?.(item.id)}
style={{ cursor: onItemClick ? 'pointer' : 'default' }}
>
<rect
x={rx} y={ry} width={rw} height={rh}
rx={3}
fill={slaColor(item.slaCompliance)}
stroke={slaBorderColor(item.slaCompliance)}
strokeWidth={1}
/>
{showLabel && (
<text
x={rx + 4} y={ry + 13}
fill={slaTextColor(item.slaCompliance)}
fontSize={11} fontWeight={600}
style={{ pointerEvents: 'none' }}
>
{item.label.length > rw / 6.5 ? item.label.slice(0, Math.floor(rw / 6.5)) + '\u2026' : item.label}
</text>
)}
{showSla && (
<text
x={rx + 4} y={ry + 26}
fill={slaTextColor(item.slaCompliance)}
fontSize={10} fontWeight={400}
style={{ pointerEvents: 'none' }}
>
{item.slaCompliance.toFixed(1)}% SLA
</text>
)}
</g>
);
})}
</svg>
<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>
);
}

View File

@@ -185,6 +185,11 @@
font-weight: 500;
}
.replayIcon {
color: var(--amber);
flex-shrink: 0;
}
.chainDuration {
color: var(--text-muted);
font-size: 9px;

View File

@@ -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>

View File

@@ -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(() => {

View 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);
}

View 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>
);
}