35 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
hsiegeln
6aeba1fe83 fix: side-by-side layout for treemap and punchcard heatmaps
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
Treemap on left (3fr), two punchcards stacked on right (2fr) using
new .vizRow grid layout. Replaces full-width stacked arrangement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:32:23 +02:00
hsiegeln
7a1625c297 fix: make treemap and punchcard responsive with viewBox scaling
Replaced hardcoded width/height on SVG elements with viewBox + width:100%
so both components fill their parent container instead of using fixed pixels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:28:29 +02:00
hsiegeln
9d2d87e7e1 feat: add treemap and punchcard heatmap to dashboard L1/L2 (#94)
Treemap: rectangle area = transaction volume, color = SLA compliance
(green→red). Shows apps at L1, routes at L2. Click navigates deeper.

Punchcard heatmap: 7-day rolling weekday x 24-hour grid showing
transaction volume and error patterns. Two side-by-side views
(transactions + errors) reveal temporal clustering.

Backend: new GET /search/stats/punchcard endpoint aggregating
stats_1m_all/app by DOW x hour over rolling 7 days.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:26:26 +02:00
hsiegeln
b5c19b6774 feat: latency heatmap overlay on process diagram (#94)
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
SonarQube / sonarqube (push) Failing after 1m10s
Add latencyHeatmap prop to ProcessDiagram that colors nodes green→yellow→red
based on their relative contribution to route latency (pctOfRoute). Shows avg
duration label on each node. Threaded through CompoundNode for nested EIP
patterns. Heatmap is active only when no execution overlay is present.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:32:42 +02:00
hsiegeln
213aa86c47 feat: progressive drill-down dashboard with RED metrics and SLA compliance (#94)
Three-level dashboard driven by sidebar selection:
- L1 (no selection): all-apps overview with health table, per-app charts
- L2 (app selected): route performance table, error velocity, top errors
- L3 (route selected): processor table, latency heatmap data, bottleneck KPI

Backend: 3 new endpoints (timeseries/by-app, timeseries/by-route, errors/top),
per-app SLA settings (app_settings table, V12 migration), exact SLA compliance
from executions hypertable, error velocity with acceleration detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:29:20 +02:00
74 changed files with 4376 additions and 5825 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 - Spring Boot 3.4.3 parent POM
- Depends on `com.cameleer3:cameleer3-common` from Gitea Maven registry - Depends on `com.cameleer3:cameleer3-common` from Gitea Maven registry
- Jackson `JavaTimeModule` for `Instant` deserialization - 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 - Maintains agent instance registry with states: LIVE → STALE → DEAD
- Storage: PostgreSQL (TimescaleDB) for structured data, OpenSearch for full-text search and application log storage - 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 - 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` - 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 - 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 ## Disabled Skills
- Do NOT use any `gsd:*` skills in this project. This includes all `/gsd:` prefixed commands. - 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" \ -H "Authorization: Bearer $TOKEN" \
-d '{"type":"deep-trace","payload":{"routeId":"route-1","durationSeconds":60}}' -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 # Broadcast command to all live agents
curl -s -X POST http://localhost:8081/api/v1/agents/commands \ curl -s -X POST http://localhost:8081/api/v1/agents/commands \
-H "Content-Type: application/json" \ -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. **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. **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.CommandBroadcastResponse;
import com.cameleer3.server.app.dto.CommandRequest; import com.cameleer3.server.app.dto.CommandRequest;
import com.cameleer3.server.app.dto.CommandSingleResponse; 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.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult; import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService; 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.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService; import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.agent.AgentState; import com.cameleer3.server.core.agent.AgentState;
import com.cameleer3.server.core.agent.CommandReply;
import com.cameleer3.server.core.agent.CommandType; import com.cameleer3.server.core.agent.CommandType;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -32,7 +35,14 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; 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. * Command push endpoints for sending commands to agents via SSE.
@@ -184,6 +194,75 @@ public class AgentCommandController {
return ResponseEntity.ok().build(); 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) { private CommandType mapCommandType(String typeStr) {
return switch (typeStr) { return switch (typeStr) {
case "config-update" -> CommandType.CONFIG_UPDATE; case "config-update" -> CommandType.CONFIG_UPDATE;
@@ -191,8 +270,9 @@ public class AgentCommandController {
case "replay" -> CommandType.REPLAY; case "replay" -> CommandType.REPLAY;
case "set-traced-processors" -> CommandType.SET_TRACED_PROCESSORS; case "set-traced-processors" -> CommandType.SET_TRACED_PROCESSORS;
case "test-expression" -> CommandType.TEST_EXPRESSION; case "test-expression" -> CommandType.TEST_EXPRESSION;
case "route-control" -> CommandType.ROUTE_CONTROL;
default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, 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) @ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) { public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
String reason = ex.getReason();
return ResponseEntity.status(ex.getStatusCode()) 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

@@ -0,0 +1,79 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.AppSettingsRequest;
import com.cameleer3.server.core.admin.AppSettings;
import com.cameleer3.server.core.admin.AppSettingsRepository;
import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/admin/app-settings")
@PreAuthorize("hasAnyRole('ADMIN', 'OPERATOR')")
@Tag(name = "App Settings", description = "Per-application dashboard settings (ADMIN/OPERATOR)")
public class AppSettingsController {
private final AppSettingsRepository repository;
private final AuditService auditService;
public AppSettingsController(AppSettingsRepository repository, AuditService auditService) {
this.repository = repository;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "List all application settings")
public ResponseEntity<List<AppSettings>> getAll() {
return ResponseEntity.ok(repository.findAll());
}
@GetMapping("/{appId}")
@Operation(summary = "Get settings for a specific application (returns defaults if not configured)")
public ResponseEntity<AppSettings> getByAppId(@PathVariable String appId) {
AppSettings settings = repository.findByAppId(appId).orElse(AppSettings.defaults(appId));
return ResponseEntity.ok(settings);
}
@PutMapping("/{appId}")
@Operation(summary = "Create or update settings for an application")
public ResponseEntity<AppSettings> update(@PathVariable String appId,
@Valid @RequestBody AppSettingsRequest request,
HttpServletRequest httpRequest) {
List<String> errors = request.validate();
if (!errors.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors));
}
AppSettings saved = repository.save(request.toSettings(appId));
auditService.log("update_app_settings", AuditCategory.CONFIG, appId,
Map.of("settings", saved), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(saved);
}
@DeleteMapping("/{appId}")
@Operation(summary = "Delete application settings (reverts to defaults)")
public ResponseEntity<Void> delete(@PathVariable String appId, HttpServletRequest httpRequest) {
repository.delete(appId);
auditService.log("delete_app_settings", AuditCategory.CONFIG, appId,
Map.of(), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
}

View File

@@ -59,7 +59,8 @@ public class DatabaseAdminController {
String host = extractHost(dataSource); String host = extractHost(dataSource);
return ResponseEntity.ok(new DatabaseStatusResponse(true, version, host, schema, timescaleDb)); return ResponseEntity.ok(new DatabaseStatusResponse(true, version, host, schema, timescaleDb));
} catch (Exception e) { } 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(), health.numberOfNodes(),
opensearchUrl)); opensearchUrl));
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.ok(new OpenSearchStatusResponse( return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new OpenSearchStatusResponse(
false, "UNREACHABLE", null, 0, opensearchUrl)); false, "UNREACHABLE", null, 0, opensearchUrl));
} }
} }
@@ -149,7 +150,8 @@ public class OpenSearchAdminController {
pageItems, totalIndices, totalDocs, pageItems, totalIndices, totalDocs,
humanSize(totalBytes), page, size, totalPages)); humanSize(totalBytes), page, size, totalPages));
} catch (Exception e) { } 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)); List.of(), 0, 0, "0 B", page, size, 0));
} }
} }
@@ -234,7 +236,8 @@ public class OpenSearchAdminController {
searchLatency, indexingLatency, searchLatency, indexingLatency,
heapUsed, heapMax)); heapUsed, heapMax));
} catch (Exception e) { } 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

@@ -2,6 +2,9 @@ package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.ProcessorMetrics; import com.cameleer3.server.app.dto.ProcessorMetrics;
import com.cameleer3.server.app.dto.RouteMetrics; import com.cameleer3.server.app.dto.RouteMetrics;
import com.cameleer3.server.core.admin.AppSettings;
import com.cameleer3.server.core.admin.AppSettingsRepository;
import com.cameleer3.server.core.storage.StatsStore;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -18,6 +21,7 @@ import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/v1/routes") @RequestMapping("/api/v1/routes")
@@ -25,9 +29,14 @@ import java.util.List;
public class RouteMetricsController { public class RouteMetricsController {
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final StatsStore statsStore;
private final AppSettingsRepository appSettingsRepository;
public RouteMetricsController(JdbcTemplate jdbc) { public RouteMetricsController(JdbcTemplate jdbc, StatsStore statsStore,
AppSettingsRepository appSettingsRepository) {
this.jdbc = jdbc; this.jdbc = jdbc;
this.statsStore = statsStore;
this.appSettingsRepository = appSettingsRepository;
} }
@GetMapping("/metrics") @GetMapping("/metrics")
@@ -78,7 +87,7 @@ public class RouteMetricsController {
routeKeys.add(new RouteKey(applicationName, routeId)); routeKeys.add(new RouteKey(applicationName, routeId));
return new RouteMetrics(routeId, applicationName, total, successRate, return new RouteMetrics(routeId, applicationName, total, successRate,
avgDur, p99Dur, errorRate, tps, List.of()); avgDur, p99Dur, errorRate, tps, List.of(), -1.0);
}, params.toArray()); }, params.toArray());
// Fetch sparklines (12 buckets over the time window) // Fetch sparklines (12 buckets over the time window)
@@ -100,13 +109,34 @@ public class RouteMetricsController {
m.appId(), m.routeId()); m.appId(), m.routeId());
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(), metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
m.successRate(), m.avgDurationMs(), m.p99DurationMs(), m.successRate(), m.avgDurationMs(), m.p99DurationMs(),
m.errorRate(), m.throughputPerSec(), sparkline)); m.errorRate(), m.throughputPerSec(), sparkline, m.slaCompliance()));
} catch (Exception e) { } catch (Exception e) {
// Leave sparkline empty on error // Leave sparkline empty on error
} }
} }
} }
// Enrich with SLA compliance per route
if (!metrics.isEmpty()) {
// Determine SLA threshold (per-app or default)
String effectiveAppId = appId != null ? appId : (metrics.isEmpty() ? null : metrics.get(0).appId());
int threshold = appSettingsRepository.findByAppId(effectiveAppId != null ? effectiveAppId : "")
.map(AppSettings::slaThresholdMs).orElse(300);
Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant,
effectiveAppId, threshold);
for (int i = 0; i < metrics.size(); i++) {
RouteMetrics m = metrics.get(i);
long[] counts = slaCounts.get(m.routeId());
double sla = (counts != null && counts[1] > 0)
? counts[0] * 100.0 / counts[1] : 100.0;
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
m.successRate(), m.avgDurationMs(), m.p99DurationMs(),
m.errorRate(), m.throughputPerSec(), m.sparkline(), sla));
}
}
return ResponseEntity.ok(metrics); return ResponseEntity.ok(metrics);
} }

View File

@@ -1,5 +1,7 @@
package com.cameleer3.server.app.controller; package com.cameleer3.server.app.controller;
import com.cameleer3.server.core.admin.AppSettings;
import com.cameleer3.server.core.admin.AppSettingsRepository;
import com.cameleer3.server.core.agent.AgentInfo; import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService; import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.search.ExecutionStats; import com.cameleer3.server.core.search.ExecutionStats;
@@ -8,6 +10,8 @@ import com.cameleer3.server.core.search.SearchRequest;
import com.cameleer3.server.core.search.SearchResult; import com.cameleer3.server.core.search.SearchResult;
import com.cameleer3.server.core.search.SearchService; import com.cameleer3.server.core.search.SearchService;
import com.cameleer3.server.core.search.StatsTimeseries; import com.cameleer3.server.core.search.StatsTimeseries;
import com.cameleer3.server.core.search.TopError;
import com.cameleer3.server.core.storage.StatsStore;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -20,6 +24,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* Search endpoints for querying route executions. * Search endpoints for querying route executions.
@@ -34,10 +39,13 @@ public class SearchController {
private final SearchService searchService; private final SearchService searchService;
private final AgentRegistryService registryService; private final AgentRegistryService registryService;
private final AppSettingsRepository appSettingsRepository;
public SearchController(SearchService searchService, AgentRegistryService registryService) { public SearchController(SearchService searchService, AgentRegistryService registryService,
AppSettingsRepository appSettingsRepository) {
this.searchService = searchService; this.searchService = searchService;
this.registryService = registryService; this.registryService = registryService;
this.appSettingsRepository = appSettingsRepository;
} }
@GetMapping("/executions") @GetMapping("/executions")
@@ -87,21 +95,29 @@ public class SearchController {
} }
@GetMapping("/stats") @GetMapping("/stats")
@Operation(summary = "Aggregate execution stats (P99 latency, active count)") @Operation(summary = "Aggregate execution stats (P99 latency, active count, SLA compliance)")
public ResponseEntity<ExecutionStats> stats( public ResponseEntity<ExecutionStats> stats(
@RequestParam Instant from, @RequestParam Instant from,
@RequestParam(required = false) Instant to, @RequestParam(required = false) Instant to,
@RequestParam(required = false) String routeId, @RequestParam(required = false) String routeId,
@RequestParam(required = false) String application) { @RequestParam(required = false) String application) {
Instant end = to != null ? to : Instant.now(); Instant end = to != null ? to : Instant.now();
ExecutionStats stats;
if (routeId == null && application == null) { if (routeId == null && application == null) {
return ResponseEntity.ok(searchService.stats(from, end)); stats = searchService.stats(from, end);
} else if (routeId == null) {
stats = searchService.statsForApp(from, end, application);
} else {
List<String> agentIds = resolveApplicationToAgentIds(application);
stats = searchService.stats(from, end, routeId, agentIds);
} }
if (routeId == null) {
return ResponseEntity.ok(searchService.statsForApp(from, end, application)); // Enrich with SLA compliance
} int threshold = appSettingsRepository
List<String> agentIds = resolveApplicationToAgentIds(application); .findByAppId(application != null ? application : "")
return ResponseEntity.ok(searchService.stats(from, end, routeId, agentIds)); .map(AppSettings::slaThresholdMs).orElse(300);
double sla = searchService.slaCompliance(from, end, threshold, application, routeId);
return ResponseEntity.ok(stats.withSlaCompliance(sla));
} }
@GetMapping("/stats/timeseries") @GetMapping("/stats/timeseries")
@@ -126,6 +142,48 @@ public class SearchController {
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, routeId, agentIds)); return ResponseEntity.ok(searchService.timeseries(from, end, buckets, routeId, agentIds));
} }
@GetMapping("/stats/timeseries/by-app")
@Operation(summary = "Timeseries grouped by application")
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByApp(
@RequestParam Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "24") int buckets) {
Instant end = to != null ? to : Instant.now();
return ResponseEntity.ok(searchService.timeseriesGroupedByApp(from, end, buckets));
}
@GetMapping("/stats/timeseries/by-route")
@Operation(summary = "Timeseries grouped by route for an application")
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByRoute(
@RequestParam Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "24") int buckets,
@RequestParam String application) {
Instant end = to != null ? to : Instant.now();
return ResponseEntity.ok(searchService.timeseriesGroupedByRoute(from, end, buckets, application));
}
@GetMapping("/stats/punchcard")
@Operation(summary = "Transaction punchcard: weekday x hour grid (rolling 7 days)")
public ResponseEntity<List<StatsStore.PunchcardCell>> punchcard(
@RequestParam(required = false) String application) {
Instant to = Instant.now();
Instant from = to.minus(java.time.Duration.ofDays(7));
return ResponseEntity.ok(searchService.punchcard(from, to, application));
}
@GetMapping("/errors/top")
@Operation(summary = "Top N errors with velocity trend")
public ResponseEntity<List<TopError>> topErrors(
@RequestParam Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(required = false) String application,
@RequestParam(required = false) String routeId,
@RequestParam(defaultValue = "5") int limit) {
Instant end = to != null ? to : Instant.now();
return ResponseEntity.ok(searchService.topErrors(from, end, application, routeId, limit));
}
/** /**
* Resolve an application name to agent IDs. * Resolve an application name to agent IDs.
* Returns null if application is null/blank (no filtering). * Returns null if application is null/blank (no filtering).

View File

@@ -415,12 +415,13 @@ public class ElkDiagramRenderer implements DiagramRenderer {
for (ElkEdge elkEdge : allEdges) { for (ElkEdge elkEdge : allEdges) {
String sourceId = elkEdge.getSources().isEmpty() ? "" : elkEdge.getSources().get(0).getIdentifier(); String sourceId = elkEdge.getSources().isEmpty() ? "" : elkEdge.getSources().get(0).getIdentifier();
String targetId = elkEdge.getTargets().isEmpty() ? "" : elkEdge.getTargets().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<>(); List<double[]> points = new ArrayList<>();
for (ElkEdgeSection section : elkEdge.getSections()) { for (ElkEdgeSection section : elkEdge.getSections()) {
double cx = getAbsoluteX(elkEdge.getContainingNode(), edgeRoot); double cx = containingNode != null ? getAbsoluteX(containingNode, edgeRoot) : 0;
double cy = getAbsoluteY(elkEdge.getContainingNode(), edgeRoot); double cy = containingNode != null ? getAbsoluteY(containingNode, edgeRoot) : 0;
points.add(new double[]{section.getStartX() + cx, section.getStartY() + cy}); points.add(new double[]{section.getStartX() + cx, section.getStartY() + cy});
for (ElkBendPoint bp : section.getBendPoints()) { for (ElkBendPoint bp : section.getBendPoints()) {
points.add(new double[]{bp.getX() + cx, bp.getY() + cy}); points.add(new double[]{bp.getX() + cx, bp.getY() + cy});

View File

@@ -0,0 +1,54 @@
package com.cameleer3.server.app.dto;
import com.cameleer3.server.core.admin.AppSettings;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Schema(description = "Per-application dashboard settings")
public record AppSettingsRequest(
@NotNull @Min(1)
@Schema(description = "SLA duration threshold in milliseconds")
Integer slaThresholdMs,
@NotNull @Min(0) @Max(100)
@Schema(description = "Error rate % threshold for warning (yellow) health dot")
Double healthErrorWarn,
@NotNull @Min(0) @Max(100)
@Schema(description = "Error rate % threshold for critical (red) health dot")
Double healthErrorCrit,
@NotNull @Min(0) @Max(100)
@Schema(description = "SLA compliance % threshold for warning (yellow) health dot")
Double healthSlaWarn,
@NotNull @Min(0) @Max(100)
@Schema(description = "SLA compliance % threshold for critical (red) health dot")
Double healthSlaCrit
) {
public AppSettings toSettings(String appId) {
Instant now = Instant.now();
return new AppSettings(appId, slaThresholdMs, healthErrorWarn, healthErrorCrit,
healthSlaWarn, healthSlaCrit, now, now);
}
public List<String> validate() {
List<String> errors = new ArrayList<>();
if (healthErrorWarn != null && healthErrorCrit != null
&& healthErrorWarn > healthErrorCrit) {
errors.add("healthErrorWarn must be <= healthErrorCrit");
}
if (healthSlaWarn != null && healthSlaCrit != null
&& healthSlaWarn < healthSlaCrit) {
errors.add("healthSlaWarn must be >= healthSlaCrit (higher SLA = healthier)");
}
return errors;
}
}

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

@@ -15,5 +15,6 @@ public record RouteMetrics(
@NotNull double p99DurationMs, @NotNull double p99DurationMs,
@NotNull double errorRate, @NotNull double errorRate,
@NotNull double throughputPerSec, @NotNull double throughputPerSec,
@NotNull List<Double> sparkline @NotNull List<Double> sparkline,
double slaCompliance
) {} ) {}

View File

@@ -362,6 +362,7 @@ public class OpenSearchIndex implements SearchIndex {
}).toList()); }).toList());
} }
map.put("has_trace_data", doc.hasTraceData()); map.put("has_trace_data", doc.hasTraceData());
map.put("is_replay", doc.isReplay());
return map; return map;
} }
@@ -399,7 +400,8 @@ public class OpenSearchIndex implements SearchIndex {
null, // diagramContentHash not stored in index null, // diagramContentHash not stored in index
extractHighlight(hit), extractHighlight(hit),
attributes, 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/*/commands").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/agents/groups/*/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/commands").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/agents/*/replay").hasAnyRole("OPERATOR", "ADMIN")
// Search endpoints // Search endpoints
.requestMatchers(HttpMethod.GET, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT") .requestMatchers(HttpMethod.GET, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT")

View File

@@ -0,0 +1,67 @@
package com.cameleer3.server.app.storage;
import com.cameleer3.server.core.admin.AppSettings;
import com.cameleer3.server.core.admin.AppSettingsRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class PostgresAppSettingsRepository implements AppSettingsRepository {
private final JdbcTemplate jdbc;
private static final RowMapper<AppSettings> ROW_MAPPER = (rs, rowNum) -> new AppSettings(
rs.getString("app_id"),
rs.getInt("sla_threshold_ms"),
rs.getDouble("health_error_warn"),
rs.getDouble("health_error_crit"),
rs.getDouble("health_sla_warn"),
rs.getDouble("health_sla_crit"),
rs.getTimestamp("created_at").toInstant(),
rs.getTimestamp("updated_at").toInstant());
public PostgresAppSettingsRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public Optional<AppSettings> findByAppId(String appId) {
List<AppSettings> results = jdbc.query(
"SELECT * FROM app_settings WHERE app_id = ?", ROW_MAPPER, appId);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public List<AppSettings> findAll() {
return jdbc.query("SELECT * FROM app_settings ORDER BY app_id", ROW_MAPPER);
}
@Override
public AppSettings save(AppSettings settings) {
jdbc.update("""
INSERT INTO app_settings (app_id, sla_threshold_ms, health_error_warn,
health_error_crit, health_sla_warn, health_sla_crit, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, now(), now())
ON CONFLICT (app_id) DO UPDATE SET
sla_threshold_ms = EXCLUDED.sla_threshold_ms,
health_error_warn = EXCLUDED.health_error_warn,
health_error_crit = EXCLUDED.health_error_crit,
health_sla_warn = EXCLUDED.health_sla_warn,
health_sla_crit = EXCLUDED.health_sla_crit,
updated_at = now()
""",
settings.appId(), settings.slaThresholdMs(),
settings.healthErrorWarn(), settings.healthErrorCrit(),
settings.healthSlaWarn(), settings.healthSlaCrit());
return findByAppId(settings.appId()).orElseThrow();
}
@Override
public void delete(String appId) {
jdbc.update("DELETE FROM app_settings WHERE app_id = ?", appId);
}
}

View File

@@ -31,10 +31,10 @@ public class PostgresExecutionStore implements ExecutionStore {
attributes, attributes,
error_type, error_category, root_cause_type, root_cause_message, error_type, error_category, root_cause_type, root_cause_message,
trace_id, span_id, trace_id, span_id,
processors_json, has_trace_data, processors_json, has_trace_data, is_replay,
created_at, updated_at) created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb,
?, ?, ?, ?, ?, ?, ?::jsonb, ?, now(), now()) ?, ?, ?, ?, ?, ?, ?::jsonb, ?, ?, now(), now())
ON CONFLICT (execution_id, start_time) DO UPDATE SET ON CONFLICT (execution_id, start_time) DO UPDATE SET
status = CASE status = CASE
WHEN EXCLUDED.status IN ('COMPLETED', 'FAILED') WHEN EXCLUDED.status IN ('COMPLETED', 'FAILED')
@@ -62,6 +62,7 @@ public class PostgresExecutionStore implements ExecutionStore {
span_id = COALESCE(EXCLUDED.span_id, executions.span_id), span_id = COALESCE(EXCLUDED.span_id, executions.span_id),
processors_json = COALESCE(EXCLUDED.processors_json, executions.processors_json), processors_json = COALESCE(EXCLUDED.processors_json, executions.processors_json),
has_trace_data = EXCLUDED.has_trace_data OR executions.has_trace_data, has_trace_data = EXCLUDED.has_trace_data OR executions.has_trace_data,
is_replay = EXCLUDED.is_replay OR executions.is_replay,
updated_at = now() updated_at = now()
""", """,
execution.executionId(), execution.routeId(), execution.agentId(), execution.executionId(), execution.routeId(), execution.agentId(),
@@ -78,7 +79,7 @@ public class PostgresExecutionStore implements ExecutionStore {
execution.errorType(), execution.errorCategory(), execution.errorType(), execution.errorCategory(),
execution.rootCauseType(), execution.rootCauseMessage(), execution.rootCauseType(), execution.rootCauseMessage(),
execution.traceId(), execution.spanId(), execution.traceId(), execution.spanId(),
execution.processorsJson(), execution.hasTraceData()); execution.processorsJson(), execution.hasTraceData(), execution.isReplay());
} }
@Override @Override
@@ -180,7 +181,8 @@ public class PostgresExecutionStore implements ExecutionStore {
rs.getString("root_cause_type"), rs.getString("root_cause_message"), rs.getString("root_cause_type"), rs.getString("root_cause_message"),
rs.getString("trace_id"), rs.getString("span_id"), rs.getString("trace_id"), rs.getString("span_id"),
rs.getString("processors_json"), 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) -> private static final RowMapper<ProcessorRecord> PROCESSOR_MAPPER = (rs, rowNum) ->
new ProcessorRecord( new ProcessorRecord(

View File

@@ -3,6 +3,7 @@ package com.cameleer3.server.app.storage;
import com.cameleer3.server.core.search.ExecutionStats; import com.cameleer3.server.core.search.ExecutionStats;
import com.cameleer3.server.core.search.StatsTimeseries; import com.cameleer3.server.core.search.StatsTimeseries;
import com.cameleer3.server.core.search.StatsTimeseries.TimeseriesBucket; import com.cameleer3.server.core.search.StatsTimeseries.TimeseriesBucket;
import com.cameleer3.server.core.search.TopError;
import com.cameleer3.server.core.storage.StatsStore; import com.cameleer3.server.core.storage.StatsStore;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -12,7 +13,9 @@ import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
@Repository @Repository
public class PostgresStatsStore implements StatsStore { public class PostgresStatsStore implements StatsStore {
@@ -184,4 +187,242 @@ public class PostgresStatsStore implements StatsStore {
return new StatsTimeseries(buckets); return new StatsTimeseries(buckets);
} }
// ── Grouped timeseries ────────────────────────────────────────────────
@Override
public Map<String, StatsTimeseries> timeseriesGroupedByApp(Instant from, Instant to, int bucketCount) {
return queryGroupedTimeseries("stats_1m_app", "application_name", from, to,
bucketCount, List.of());
}
@Override
public Map<String, StatsTimeseries> timeseriesGroupedByRoute(Instant from, Instant to,
int bucketCount, String applicationName) {
return queryGroupedTimeseries("stats_1m_route", "route_id", from, to,
bucketCount, List.of(new Filter("application_name", applicationName)));
}
private Map<String, StatsTimeseries> queryGroupedTimeseries(
String view, String groupCol, Instant from, Instant to,
int bucketCount, List<Filter> filters) {
long intervalSeconds = Duration.between(from, to).toSeconds() / Math.max(bucketCount, 1);
if (intervalSeconds < 60) intervalSeconds = 60;
String sql = "SELECT time_bucket(? * INTERVAL '1 second', bucket) AS period, " +
groupCol + " AS group_key, " +
"COALESCE(SUM(total_count), 0) AS total_count, " +
"COALESCE(SUM(failed_count), 0) AS failed_count, " +
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum) / SUM(total_count) ELSE 0 END AS avg_duration, " +
"COALESCE(MAX(p99_duration), 0) AS p99_duration, " +
"COALESCE(SUM(running_count), 0) AS active_count " +
"FROM " + view + " WHERE bucket >= ? AND bucket < ?";
List<Object> params = new ArrayList<>();
params.add(intervalSeconds);
params.add(Timestamp.from(from));
params.add(Timestamp.from(to));
for (Filter f : filters) {
sql += " AND " + f.column() + " = ?";
params.add(f.value());
}
sql += " GROUP BY period, group_key ORDER BY period, group_key";
Map<String, List<TimeseriesBucket>> grouped = new LinkedHashMap<>();
jdbc.query(sql, (rs) -> {
String key = rs.getString("group_key");
TimeseriesBucket bucket = new TimeseriesBucket(
rs.getTimestamp("period").toInstant(),
rs.getLong("total_count"), rs.getLong("failed_count"),
rs.getLong("avg_duration"), rs.getLong("p99_duration"),
rs.getLong("active_count"));
grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(bucket);
}, params.toArray());
Map<String, StatsTimeseries> result = new LinkedHashMap<>();
grouped.forEach((key, buckets) -> result.put(key, new StatsTimeseries(buckets)));
return result;
}
// ── SLA compliance ────────────────────────────────────────────────────
@Override
public double slaCompliance(Instant from, Instant to, int thresholdMs,
String applicationName, String routeId) {
String sql = "SELECT " +
"COUNT(*) FILTER (WHERE duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
"COUNT(*) FILTER (WHERE status != 'RUNNING') AS total " +
"FROM executions WHERE start_time >= ? AND start_time < ?";
List<Object> params = new ArrayList<>();
params.add(thresholdMs);
params.add(Timestamp.from(from));
params.add(Timestamp.from(to));
if (applicationName != null) {
sql += " AND application_name = ?";
params.add(applicationName);
}
if (routeId != null) {
sql += " AND route_id = ?";
params.add(routeId);
}
return jdbc.query(sql, (rs, rowNum) -> {
long total = rs.getLong("total");
if (total == 0) return 1.0;
return rs.getLong("compliant") * 100.0 / total;
}, params.toArray()).stream().findFirst().orElse(1.0);
}
@Override
public Map<String, long[]> slaCountsByApp(Instant from, Instant to, int defaultThresholdMs) {
String sql = "SELECT application_name, " +
"COUNT(*) FILTER (WHERE duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
"COUNT(*) FILTER (WHERE status != 'RUNNING') AS total " +
"FROM executions WHERE start_time >= ? AND start_time < ? " +
"GROUP BY application_name";
Map<String, long[]> result = new LinkedHashMap<>();
jdbc.query(sql, (rs) -> {
result.put(rs.getString("application_name"),
new long[]{rs.getLong("compliant"), rs.getLong("total")});
}, defaultThresholdMs, Timestamp.from(from), Timestamp.from(to));
return result;
}
@Override
public Map<String, long[]> slaCountsByRoute(Instant from, Instant to,
String applicationName, int thresholdMs) {
String sql = "SELECT route_id, " +
"COUNT(*) FILTER (WHERE duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
"COUNT(*) FILTER (WHERE status != 'RUNNING') AS total " +
"FROM executions WHERE start_time >= ? AND start_time < ? " +
"AND application_name = ? GROUP BY route_id";
Map<String, long[]> result = new LinkedHashMap<>();
jdbc.query(sql, (rs) -> {
result.put(rs.getString("route_id"),
new long[]{rs.getLong("compliant"), rs.getLong("total")});
}, thresholdMs, Timestamp.from(from), Timestamp.from(to), applicationName);
return result;
}
// ── Top errors ────────────────────────────────────────────────────────
@Override
public List<TopError> topErrors(Instant from, Instant to, String applicationName,
String routeId, int limit) {
StringBuilder where = new StringBuilder(
"status = 'FAILED' AND start_time >= ? AND start_time < ?");
List<Object> params = new ArrayList<>();
params.add(Timestamp.from(from));
params.add(Timestamp.from(to));
if (applicationName != null) {
where.append(" AND application_name = ?");
params.add(applicationName);
}
String table;
String groupId;
if (routeId != null) {
// L3: attribute errors to processors
table = "processor_executions";
groupId = "processor_id";
where.append(" AND route_id = ?");
params.add(routeId);
} else {
// L1/L2: attribute errors to routes
table = "executions";
groupId = "route_id";
}
Instant fiveMinAgo = Instant.now().minus(5, ChronoUnit.MINUTES);
Instant tenMinAgo = Instant.now().minus(10, ChronoUnit.MINUTES);
String sql = "WITH counted AS (" +
" SELECT COALESCE(error_type, LEFT(error_message, 200)) AS error_key, " +
" " + groupId + " AS group_id, " +
" COUNT(*) AS cnt, MAX(start_time) AS last_seen " +
" FROM " + table + " WHERE " + where +
" GROUP BY error_key, group_id ORDER BY cnt DESC LIMIT ?" +
"), velocity AS (" +
" SELECT COALESCE(error_type, LEFT(error_message, 200)) AS error_key, " +
" COUNT(*) FILTER (WHERE start_time >= ?) AS recent_5m, " +
" COUNT(*) FILTER (WHERE start_time >= ? AND start_time < ?) AS prev_5m " +
" FROM " + table + " WHERE " + where +
" GROUP BY error_key" +
") SELECT c.error_key, c.group_id, c.cnt, c.last_seen, " +
" COALESCE(v.recent_5m, 0) / 5.0 AS velocity, " +
" CASE " +
" WHEN COALESCE(v.recent_5m, 0) > COALESCE(v.prev_5m, 0) * 1.2 THEN 'accelerating' " +
" WHEN COALESCE(v.recent_5m, 0) < COALESCE(v.prev_5m, 0) * 0.8 THEN 'decelerating' " +
" ELSE 'stable' END AS trend " +
"FROM counted c LEFT JOIN velocity v ON c.error_key = v.error_key " +
"ORDER BY c.cnt DESC";
// Build full params: counted-where params + limit + velocity timestamps + velocity-where params
List<Object> fullParams = new ArrayList<>(params);
fullParams.add(limit);
fullParams.add(Timestamp.from(fiveMinAgo));
fullParams.add(Timestamp.from(tenMinAgo));
fullParams.add(Timestamp.from(fiveMinAgo));
fullParams.addAll(params); // same where clause for velocity CTE
return jdbc.query(sql, (rs, rowNum) -> {
String errorKey = rs.getString("error_key");
String gid = rs.getString("group_id");
return new TopError(
errorKey,
routeId != null ? routeId : gid, // routeId
routeId != null ? gid : null, // processorId (only at L3)
rs.getLong("cnt"),
rs.getDouble("velocity"),
rs.getString("trend"),
rs.getTimestamp("last_seen").toInstant());
}, fullParams.toArray());
}
@Override
public int activeErrorTypes(Instant from, Instant to, String applicationName) {
String sql = "SELECT COUNT(DISTINCT COALESCE(error_type, LEFT(error_message, 200))) " +
"FROM executions WHERE status = 'FAILED' AND start_time >= ? AND start_time < ?";
List<Object> params = new ArrayList<>();
params.add(Timestamp.from(from));
params.add(Timestamp.from(to));
if (applicationName != null) {
sql += " AND application_name = ?";
params.add(applicationName);
}
Integer count = jdbc.queryForObject(sql, Integer.class, params.toArray());
return count != null ? count : 0;
}
// ── Punchcard ─────────────────────────────────────────────────────────
@Override
public List<PunchcardCell> punchcard(Instant from, Instant to, String applicationName) {
String view = applicationName != null ? "stats_1m_app" : "stats_1m_all";
String sql = "SELECT EXTRACT(DOW FROM bucket) AS weekday, " +
"EXTRACT(HOUR FROM bucket) AS hour, " +
"COALESCE(SUM(total_count), 0) AS total_count, " +
"COALESCE(SUM(failed_count), 0) AS failed_count " +
"FROM " + view + " WHERE bucket >= ? AND bucket < ?";
List<Object> params = new ArrayList<>();
params.add(Timestamp.from(from));
params.add(Timestamp.from(to));
if (applicationName != null) {
sql += " AND application_name = ?";
params.add(applicationName);
}
sql += " GROUP BY weekday, hour ORDER BY weekday, hour";
return jdbc.query(sql, (rs, rowNum) -> new PunchcardCell(
rs.getInt("weekday"), rs.getInt("hour"),
rs.getLong("total_count"), rs.getLong("failed_count")),
params.toArray());
}
} }

View File

@@ -16,9 +16,9 @@ public class SpaForwardController {
@GetMapping(value = { @GetMapping(value = {
"/login", "/login",
"/executions", "/executions",
"/executions/{path:[^\\.]*}", "/executions/**",
"/oidc/callback", "/oidc/callback",
"/admin/{path:[^\\.]*}" "/admin/**"
}) })
public String forward() { public String forward() {
return "forward:/index.html"; return "forward:/index.html";

View File

@@ -0,0 +1,11 @@
-- Per-application dashboard settings (SLA thresholds, health dot thresholds)
CREATE TABLE app_settings (
app_id TEXT PRIMARY KEY,
sla_threshold_ms INTEGER NOT NULL DEFAULT 300,
health_error_warn DOUBLE PRECISION NOT NULL DEFAULT 1.0,
health_error_crit DOUBLE PRECISION NOT NULL DEFAULT 5.0,
health_sla_warn DOUBLE PRECISION NOT NULL DEFAULT 99.0,
health_sla_crit DOUBLE PRECISION NOT NULL DEFAULT 95.0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

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, "OrderNotFoundException: order-12345 not found", null,
List.of(new ProcessorDoc("proc-1", "log", "COMPLETED", List.of(new ProcessorDoc("proc-1", "log", "COMPLETED",
null, null, "request body with customer-99", null, null, null, null)), null, null, "request body with customer-99", null, null, null, null)),
null, false); null, false, false);
searchIndex.index(doc); searchIndex.index(doc);
refreshOpenSearchIndices(); refreshOpenSearchIndices();
@@ -62,7 +62,7 @@ class OpenSearchIndexIT extends AbstractPostgresIT {
now, now.plusMillis(50), 50L, null, null, now, now.plusMillis(50), 50L, null, null,
List.of(new ProcessorDoc("proc-1", "bean", "COMPLETED", List.of(new ProcessorDoc("proc-1", "bean", "COMPLETED",
null, null, "UniquePayloadIdentifier12345", null, null, null, null)), null, null, "UniquePayloadIdentifier12345", null, null, null, null)),
null, false); null, false, false);
searchIndex.index(doc); searchIndex.index(doc);
refreshOpenSearchIndices(); refreshOpenSearchIndices();

View File

@@ -27,7 +27,7 @@ class PostgresExecutionStoreIT extends AbstractPostgresIT {
now, now.plusMillis(100), 100L, now, now.plusMillis(100), 100L,
null, null, null, null, null, null,
"REGULAR", null, null, 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); executionStore.upsert(record);
Optional<ExecutionRecord> found = executionStore.findById("exec-1"); Optional<ExecutionRecord> found = executionStore.findById("exec-1");
@@ -45,12 +45,12 @@ class PostgresExecutionStoreIT extends AbstractPostgresIT {
"exec-dup", "route-a", "agent-1", "app-1", "exec-dup", "route-a", "agent-1", "app-1",
"RUNNING", null, null, now, null, null, null, null, null, "RUNNING", null, null, now, null, null, null, null, null,
null, 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( ExecutionRecord second = new ExecutionRecord(
"exec-dup", "route-a", "agent-1", "app-1", "exec-dup", "route-a", "agent-1", "app-1",
"COMPLETED", null, null, now, now.plusMillis(200), 200L, null, null, null, "COMPLETED", null, null, now, now.plusMillis(200), 200L, null, null, null,
"COMPLETE", null, null, 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(first);
executionStore.upsert(second); executionStore.upsert(second);
@@ -68,7 +68,7 @@ class PostgresExecutionStoreIT extends AbstractPostgresIT {
"exec-proc", "route-a", "agent-1", "app-1", "exec-proc", "route-a", "agent-1", "app-1",
"COMPLETED", null, null, now, now.plusMillis(50), 50L, null, null, null, "COMPLETED", null, null, now, now.plusMillis(50), 50L, null, null, null,
"COMPLETE", null, null, 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); executionStore.upsert(exec);
List<ProcessorRecord> processors = List.of( List<ProcessorRecord> processors = List.of(

View File

@@ -61,6 +61,6 @@ class PostgresStatsStoreIT extends AbstractPostgresIT {
startTime, startTime.plusMillis(durationMs), durationMs, startTime, startTime.plusMillis(durationMs), durationMs,
status.equals("FAILED") ? "error" : null, null, null, status.equals("FAILED") ? "error" : null, null, null,
null, 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));
} }
} }

View File

@@ -0,0 +1,19 @@
package com.cameleer3.server.core.admin;
import java.time.Instant;
public record AppSettings(
String appId,
int slaThresholdMs,
double healthErrorWarn,
double healthErrorCrit,
double healthSlaWarn,
double healthSlaCrit,
Instant createdAt,
Instant updatedAt) {
public static AppSettings defaults(String appId) {
Instant now = Instant.now();
return new AppSettings(appId, 300, 1.0, 5.0, 99.0, 95.0, now, now);
}
}

View File

@@ -0,0 +1,11 @@
package com.cameleer3.server.core.admin;
import java.util.List;
import java.util.Optional;
public interface AppSettingsRepository {
Optional<AppSettings> findByAppId(String appId);
List<AppSettings> findAll();
AppSettings save(AppSettings settings);
void delete(String appId);
}

View File

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

View File

@@ -97,6 +97,7 @@ public class DetailService {
p.getRootCauseType(), p.getRootCauseMessage(), p.getRootCauseType(), p.getRootCauseMessage(),
p.getErrorHandlerType(), p.getCircuitBreakerState(), p.getErrorHandlerType(), p.getCircuitBreakerState(),
p.getFallbackTriggered(), p.getFallbackTriggered(),
p.getFilterMatched(), p.getDuplicateMessage(),
hasTrace hasTrace
); );
for (ProcessorNode child : convertProcessors(p.getChildren())) { for (ProcessorNode child : convertProcessors(p.getChildren())) {
@@ -132,6 +133,7 @@ public class DetailService {
p.rootCauseType(), p.rootCauseMessage(), p.rootCauseType(), p.rootCauseMessage(),
p.errorHandlerType(), p.circuitBreakerState(), p.errorHandlerType(), p.circuitBreakerState(),
p.fallbackTriggered(), p.fallbackTriggered(),
null, null, // filterMatched, duplicateMessage (not in flat DB records)
hasTrace hasTrace
)); ));
} }

View File

@@ -35,6 +35,8 @@ public final class ProcessorNode {
private final String errorHandlerType; private final String errorHandlerType;
private final String circuitBreakerState; private final String circuitBreakerState;
private final Boolean fallbackTriggered; private final Boolean fallbackTriggered;
private final Boolean filterMatched;
private final Boolean duplicateMessage;
private final boolean hasTraceData; private final boolean hasTraceData;
private final List<ProcessorNode> children; private final List<ProcessorNode> children;
@@ -50,6 +52,7 @@ public final class ProcessorNode {
String rootCauseType, String rootCauseMessage, String rootCauseType, String rootCauseMessage,
String errorHandlerType, String circuitBreakerState, String errorHandlerType, String circuitBreakerState,
Boolean fallbackTriggered, Boolean fallbackTriggered,
Boolean filterMatched, Boolean duplicateMessage,
boolean hasTraceData) { boolean hasTraceData) {
this.processorId = processorId; this.processorId = processorId;
this.processorType = processorType; this.processorType = processorType;
@@ -73,6 +76,8 @@ public final class ProcessorNode {
this.errorHandlerType = errorHandlerType; this.errorHandlerType = errorHandlerType;
this.circuitBreakerState = circuitBreakerState; this.circuitBreakerState = circuitBreakerState;
this.fallbackTriggered = fallbackTriggered; this.fallbackTriggered = fallbackTriggered;
this.filterMatched = filterMatched;
this.duplicateMessage = duplicateMessage;
this.hasTraceData = hasTraceData; this.hasTraceData = hasTraceData;
this.children = new ArrayList<>(); this.children = new ArrayList<>();
} }
@@ -103,6 +108,8 @@ public final class ProcessorNode {
public String getErrorHandlerType() { return errorHandlerType; } public String getErrorHandlerType() { return errorHandlerType; }
public String getCircuitBreakerState() { return circuitBreakerState; } public String getCircuitBreakerState() { return circuitBreakerState; }
public Boolean getFallbackTriggered() { return fallbackTriggered; } public Boolean getFallbackTriggered() { return fallbackTriggered; }
public Boolean getFilterMatched() { return filterMatched; }
public Boolean getDuplicateMessage() { return duplicateMessage; }
public boolean isHasTraceData() { return hasTraceData; } public boolean isHasTraceData() { return hasTraceData; }
public List<ProcessorNode> getChildren() { return List.copyOf(children); } 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.status(), exec.correlationId(), exec.exchangeId(),
exec.startTime(), exec.endTime(), exec.durationMs(), exec.startTime(), exec.endTime(), exec.durationMs(),
exec.errorMessage(), exec.errorStacktrace(), processorDocs, exec.errorMessage(), exec.errorStacktrace(), processorDocs,
exec.attributes(), exec.hasTraceData())); exec.attributes(), exec.hasTraceData(), exec.isReplay()));
indexedCount.incrementAndGet(); indexedCount.incrementAndGet();
lastIndexedAt = Instant.now(); lastIndexedAt = Instant.now();

View File

@@ -102,6 +102,12 @@ public class IngestionService {
boolean hasTraceData = hasAnyTraceData(exec.getProcessors()); 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( return new ExecutionRecord(
exec.getExchangeId(), exec.getRouteId(), agentId, applicationName, exec.getExchangeId(), exec.getRouteId(), agentId, applicationName,
exec.getStatus() != null ? exec.getStatus().name() : "RUNNING", exec.getStatus() != null ? exec.getStatus().name() : "RUNNING",
@@ -117,7 +123,8 @@ public class IngestionService {
exec.getRootCauseType(), exec.getRootCauseMessage(), exec.getRootCauseType(), exec.getRootCauseMessage(),
exec.getTraceId(), exec.getSpanId(), exec.getTraceId(), exec.getSpanId(),
toJsonObject(exec.getProcessors()), toJsonObject(exec.getProcessors()),
hasTraceData hasTraceData,
isReplay
); );
} }

View File

@@ -1,5 +1,8 @@
package com.cameleer3.server.core.ingestion; package com.cameleer3.server.core.ingestion;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ArrayBlockingQueue;
@@ -16,6 +19,8 @@ import java.util.concurrent.BlockingQueue;
*/ */
public class WriteBuffer<T> { public class WriteBuffer<T> {
private static final Logger log = LoggerFactory.getLogger(WriteBuffer.class);
private final BlockingQueue<T> queue; private final BlockingQueue<T> queue;
private final int capacity; private final int capacity;
@@ -45,7 +50,10 @@ public class WriteBuffer<T> {
return false; return false;
} }
for (T item : items) { 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; return true;
} }

View File

@@ -14,4 +14,23 @@ public record ExecutionStats(
long prevTotalCount, long prevTotalCount,
long prevFailedCount, long prevFailedCount,
long prevAvgDurationMs, long prevAvgDurationMs,
long prevP99LatencyMs) {} long prevP99LatencyMs,
double slaCompliance) {
/** Constructor without SLA compliance (backward-compatible, sets to -1). */
public ExecutionStats(long totalCount, long failedCount, long avgDurationMs,
long p99LatencyMs, long activeCount, long totalToday,
long prevTotalCount, long prevFailedCount,
long prevAvgDurationMs, long prevP99LatencyMs) {
this(totalCount, failedCount, avgDurationMs, p99LatencyMs, activeCount,
totalToday, prevTotalCount, prevFailedCount, prevAvgDurationMs,
prevP99LatencyMs, -1.0);
}
/** Return a copy with the given SLA compliance value. */
public ExecutionStats withSlaCompliance(double slaCompliance) {
return new ExecutionStats(totalCount, failedCount, avgDurationMs, p99LatencyMs,
activeCount, totalToday, prevTotalCount, prevFailedCount,
prevAvgDurationMs, prevP99LatencyMs, slaCompliance);
}
}

View File

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

View File

@@ -5,6 +5,7 @@ import com.cameleer3.server.core.storage.StatsStore;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map;
public class SearchService { public class SearchService {
@@ -48,4 +49,42 @@ public class SearchService {
String routeId, List<String> agentIds) { String routeId, List<String> agentIds) {
return statsStore.timeseriesForRoute(from, to, bucketCount, routeId, agentIds); return statsStore.timeseriesForRoute(from, to, bucketCount, routeId, agentIds);
} }
// ── Dashboard-specific queries ────────────────────────────────────────
public Map<String, StatsTimeseries> timeseriesGroupedByApp(Instant from, Instant to, int bucketCount) {
return statsStore.timeseriesGroupedByApp(from, to, bucketCount);
}
public Map<String, StatsTimeseries> timeseriesGroupedByRoute(Instant from, Instant to,
int bucketCount, String applicationName) {
return statsStore.timeseriesGroupedByRoute(from, to, bucketCount, applicationName);
}
public double slaCompliance(Instant from, Instant to, int thresholdMs,
String applicationName, String routeId) {
return statsStore.slaCompliance(from, to, thresholdMs, applicationName, routeId);
}
public Map<String, long[]> slaCountsByApp(Instant from, Instant to, int defaultThresholdMs) {
return statsStore.slaCountsByApp(from, to, defaultThresholdMs);
}
public Map<String, long[]> slaCountsByRoute(Instant from, Instant to,
String applicationName, int thresholdMs) {
return statsStore.slaCountsByRoute(from, to, applicationName, thresholdMs);
}
public List<TopError> topErrors(Instant from, Instant to, String applicationName,
String routeId, int limit) {
return statsStore.topErrors(from, to, applicationName, routeId, limit);
}
public int activeErrorTypes(Instant from, Instant to, String applicationName) {
return statsStore.activeErrorTypes(from, to, applicationName);
}
public List<StatsStore.PunchcardCell> punchcard(Instant from, Instant to, String applicationName) {
return statsStore.punchcard(from, to, applicationName);
}
} }

View File

@@ -0,0 +1,12 @@
package com.cameleer3.server.core.search;
import java.time.Instant;
public record TopError(
String errorType,
String routeId,
String processorId,
long count,
double velocity,
String trend,
Instant lastSeen) {}

View File

@@ -30,7 +30,8 @@ public interface ExecutionStore {
String rootCauseType, String rootCauseMessage, String rootCauseType, String rootCauseMessage,
String traceId, String spanId, String traceId, String spanId,
String processorsJson, String processorsJson,
boolean hasTraceData boolean hasTraceData,
boolean isReplay
) {} ) {}
record ProcessorRecord( record ProcessorRecord(

View File

@@ -2,9 +2,11 @@ package com.cameleer3.server.core.storage;
import com.cameleer3.server.core.search.ExecutionStats; import com.cameleer3.server.core.search.ExecutionStats;
import com.cameleer3.server.core.search.StatsTimeseries; import com.cameleer3.server.core.search.StatsTimeseries;
import com.cameleer3.server.core.search.TopError;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map;
public interface StatsStore { public interface StatsStore {
@@ -33,4 +35,34 @@ public interface StatsStore {
// Per-processor timeseries // Per-processor timeseries
StatsTimeseries timeseriesForProcessor(Instant from, Instant to, int bucketCount, StatsTimeseries timeseriesForProcessor(Instant from, Instant to, int bucketCount,
String routeId, String processorType); String routeId, String processorType);
// Grouped timeseries by application (for L1 dashboard charts)
Map<String, StatsTimeseries> timeseriesGroupedByApp(Instant from, Instant to, int bucketCount);
// Grouped timeseries by route within an application (for L2 dashboard charts)
Map<String, StatsTimeseries> timeseriesGroupedByRoute(Instant from, Instant to, int bucketCount,
String applicationName);
// SLA compliance: % of completed exchanges with duration <= thresholdMs
double slaCompliance(Instant from, Instant to, int thresholdMs,
String applicationName, String routeId);
// Batch SLA counts by app: {appId -> [compliant, total]}
Map<String, long[]> slaCountsByApp(Instant from, Instant to, int defaultThresholdMs);
// Batch SLA counts by route within an app: {routeId -> [compliant, total]}
Map<String, long[]> slaCountsByRoute(Instant from, Instant to, String applicationName,
int thresholdMs);
// Top N errors with velocity trend
List<TopError> topErrors(Instant from, Instant to, String applicationName,
String routeId, int limit);
// Count of distinct error types in window
int activeErrorTypes(Instant from, Instant to, String applicationName);
// Punchcard: aggregate by weekday (0=Sun..6=Sat) x hour (0-23) over last 7 days
List<PunchcardCell> punchcard(Instant from, Instant to, String applicationName);
record PunchcardCell(int weekday, int hour, long totalCount, long failedCount) {}
} }

View File

@@ -10,7 +10,8 @@ public record ExecutionDocument(
String errorMessage, String errorStacktrace, String errorMessage, String errorStacktrace,
List<ProcessorDoc> processors, List<ProcessorDoc> processors,
String attributes, String attributes,
boolean hasTraceData boolean hasTraceData,
boolean isReplay
) { ) {
public record ProcessorDoc( public record ProcessorDoc(
String processorId, String processorType, String status, String processorId, String processorType, String status,

415
ui/package-lock.json generated
View File

@@ -8,13 +8,14 @@
"name": "ui", "name": "ui",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@cameleer/design-system": "^0.1.20", "@cameleer/design-system": "^0.1.21",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router": "^7.13.1", "react-router": "^7.13.1",
"recharts": "^3.8.1",
"swagger-ui-dist": "^5.32.0", "swagger-ui-dist": "^5.32.0",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
@@ -277,9 +278,9 @@
} }
}, },
"node_modules/@cameleer/design-system": { "node_modules/@cameleer/design-system": {
"version": "0.1.20", "version": "0.1.21",
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.20/design-system-0.1.20.tgz", "resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.21/design-system-0.1.21.tgz",
"integrity": "sha512-3fFW3z3Zg1qjUn6rEYlIeAAhlpEE5z6Udaf5LRPrlcpGCY2kA8EP3QSGQCKZG5HVsr3BtRxfN9TvFHVaZhrw4g==", "integrity": "sha512-8MZKdnwklBPp4kner2Ij0JU8FfjpaaHZp3JD8nYPx0+BfqktlYb2jBrRzmyLKdMvtTcdcl5wFGd/U2HcvN4+Yg==",
"dependencies": { "dependencies": {
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -711,6 +712,42 @@
"node": ">=10" "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": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
@@ -980,6 +1017,18 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0" "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": { "node_modules/@tanstack/query-core": {
"version": "5.91.2", "version": "5.91.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.2.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.2.tgz",
@@ -1017,6 +1066,69 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1061,6 +1173,12 @@
"@types/react": "^19.2.0" "@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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.1", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
@@ -1585,6 +1703,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1679,6 +1806,127 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "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": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1721,6 +1975,16 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1928,6 +2192,12 @@
"node": ">=0.10.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2120,6 +2390,16 @@
"node": ">= 4" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2160,6 +2440,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2944,6 +3233,36 @@
"react": "^19.2.4" "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": { "node_modules/react-router": {
"version": "7.13.2", "version": "7.13.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz",
@@ -2982,6 +3301,51 @@
"react-dom": ">=18" "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": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -2992,6 +3356,12 @@
"node": ">=0.10.0" "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": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3133,6 +3503,12 @@
"@scarf/scarf": "=1.4.0" "@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": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -3290,6 +3666,37 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/vite": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", "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" "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": { "dependencies": {
"@cameleer/design-system": "^0.1.20", "@cameleer/design-system": "^0.1.21",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router": "^7.13.1", "react-router": "^7.13.1",
"recharts": "^3.8.1",
"swagger-ui-dist": "^5.32.0", "swagger-ui-dist": "^5.32.0",
"zustand": "^5.0.11" "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'); if (!res.ok) throw new Error('Failed to load route metrics');
return res.json(); return res.json();
}, },
placeholderData: (prev: unknown) => prev,
refetchInterval, 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 ─────────────────────────────────────────────────────── // ── Replay Exchange ───────────────────────────────────────────────────────
export interface ReplayResult {
status: string
message: string
data?: string
}
export function useReplayExchange() { export function useReplayExchange() {
return useMutation({ return useMutation({
mutationFn: async ({ mutationFn: async ({
agentId, agentId,
routeId,
headers, headers,
body, body,
originalExchangeId,
}: { }: {
agentId: string agentId: string
headers: Record<string, string> routeId: string
headers?: Record<string, string>
body: string body: string
}) => { originalExchangeId?: string
const { data, error } = await api.POST('/agents/{id}/commands', { }): Promise<ReplayResult> => {
params: { path: { id: agentId } }, const res = await authFetch(`/api/v1/agents/${encodeURIComponent(agentId)}/replay`, {
body: { type: 'replay', payload: { headers, body } } as any, 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') if (!res.ok) {
return data! 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

@@ -0,0 +1,161 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
import { useRefreshInterval } from './use-refresh-interval';
function authHeaders() {
const token = useAuthStore.getState().accessToken;
return {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
};
}
async function fetchJson<T>(path: string, params?: Record<string, string | undefined>): Promise<T> {
const qs = new URLSearchParams();
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v != null) qs.set(k, v);
}
}
const url = `${config.apiBaseUrl}${path}${qs.toString() ? `?${qs}` : ''}`;
const res = await fetch(url, { headers: authHeaders() });
if (!res.ok) throw new Error(`Failed to fetch ${path}`);
return res.json();
}
// ── Timeseries by app (L1 charts) ─────────────────────────────────────
export interface TimeseriesBucket {
time: string;
totalCount: number;
failedCount: number;
avgDurationMs: number;
p99DurationMs: number;
activeCount: number;
}
export interface GroupedTimeseries {
[key: string]: { buckets: TimeseriesBucket[] };
}
export function useTimeseriesByApp(from?: string, to?: string) {
const refetchInterval = useRefreshInterval(30_000);
return useQuery({
queryKey: ['dashboard', 'timeseries-by-app', from, to],
queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-app', {
from, to, buckets: '24',
}),
enabled: !!from,
placeholderData: (prev: GroupedTimeseries | undefined) => prev,
refetchInterval,
});
}
// ── Timeseries by route (L2 charts) ───────────────────────────────────
export function useTimeseriesByRoute(from?: string, to?: string, application?: string) {
const refetchInterval = useRefreshInterval(30_000);
return useQuery({
queryKey: ['dashboard', 'timeseries-by-route', from, to, application],
queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-route', {
from, to, application, buckets: '24',
}),
enabled: !!from && !!application,
placeholderData: (prev: GroupedTimeseries | undefined) => prev,
refetchInterval,
});
}
// ── Top errors (L2/L3) ────────────────────────────────────────────────
export interface TopError {
errorType: string;
routeId: string | null;
processorId: string | null;
count: number;
velocity: number;
trend: 'accelerating' | 'stable' | 'decelerating';
lastSeen: string;
}
export function useTopErrors(from?: string, to?: string, application?: string, routeId?: string) {
const refetchInterval = useRefreshInterval(10_000);
return useQuery({
queryKey: ['dashboard', 'top-errors', from, to, application, routeId],
queryFn: () => fetchJson<TopError[]>('/search/errors/top', {
from, to, application, routeId, limit: '5',
}),
enabled: !!from,
placeholderData: (prev: TopError[] | undefined) => prev,
refetchInterval,
});
}
// ── Punchcard (weekday x hour heatmap, rolling 7 days) ────────────────
export interface PunchcardCell {
weekday: number;
hour: number;
totalCount: number;
failedCount: number;
}
export function usePunchcard(application?: string) {
const refetchInterval = useRefreshInterval(60_000);
return useQuery({
queryKey: ['dashboard', 'punchcard', application],
queryFn: () => fetchJson<PunchcardCell[]>('/search/stats/punchcard', { application }),
placeholderData: (prev: PunchcardCell[] | undefined) => prev ?? [],
refetchInterval,
});
}
// ── App settings ──────────────────────────────────────────────────────
export interface AppSettings {
appId: string;
slaThresholdMs: number;
healthErrorWarn: number;
healthErrorCrit: number;
healthSlaWarn: number;
healthSlaCrit: number;
createdAt: string;
updatedAt: string;
}
export function useAppSettings(appId?: string) {
return useQuery({
queryKey: ['app-settings', appId],
queryFn: () => fetchJson<AppSettings>(`/admin/app-settings/${appId}`),
enabled: !!appId,
staleTime: 60_000,
});
}
export function useAllAppSettings() {
return useQuery({
queryKey: ['app-settings', 'all'],
queryFn: () => fetchJson<AppSettings[]>('/admin/app-settings'),
staleTime: 60_000,
});
}
export function useUpdateAppSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ appId, settings }: { appId: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
const token = useAuthStore.getState().accessToken;
const res = await fetch(`${config.apiBaseUrl}/admin/app-settings/${appId}`, {
method: 'PUT',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
if (!res.ok) throw new Error('Failed to update app settings');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['app-settings'] });
},
});
}

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

@@ -122,6 +122,25 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/search/executions": {
parameters: { parameters: {
query?: never; query?: never;
@@ -594,7 +613,7 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** Aggregate execution stats (P99 latency, active count) */ /** Aggregate execution stats (P99 latency, active count, SLA compliance) */
get: operations["stats"]; get: operations["stats"];
put?: never; put?: never;
post?: never; post?: never;
@@ -621,6 +640,74 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/routes/metrics": {
parameters: { parameters: {
query?: never; query?: never;
@@ -725,7 +812,7 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** Get exchange snapshot for a specific processor */ /** Get exchange snapshot for a specific processor by index */
get: operations["getProcessorSnapshot"]; get: operations["getProcessorSnapshot"];
put?: never; put?: never;
post?: never; post?: never;
@@ -742,8 +829,8 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** Get exchange snapshot for a processor by processorId */ /** Get exchange snapshot for a specific processor by processorId */
get: operations["getProcessorSnapshotById"]; get: operations["processorSnapshotById"];
put?: never; put?: never;
post?: never; post?: never;
delete?: never; delete?: never;
@@ -812,6 +899,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/auth/oidc/config": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1092,6 +1199,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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}": { "/admin/opensearch/indices/{name}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1127,7 +1251,7 @@ export interface components {
tracedProcessors?: { tracedProcessors?: {
[key: string]: string; [key: string]: string;
}; };
logForwardingLevel?: string; applicationLogLevel?: string;
taps?: components["schemas"]["TapDefinition"][]; taps?: components["schemas"]["TapDefinition"][];
/** Format: int32 */ /** Format: int32 */
tapVersion?: number; tapVersion?: number;
@@ -1135,6 +1259,10 @@ export interface components {
[key: string]: boolean; [key: string]: boolean;
}; };
compressSuccess?: boolean; compressSuccess?: boolean;
agentLogLevel?: string;
routeSamplingRates?: {
[key: string]: number;
};
}; };
TapDefinition: { TapDefinition: {
tapId?: string; tapId?: string;
@@ -1284,6 +1412,51 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
parentGroupId?: string; 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: { SearchRequest: {
status?: string; status?: string;
/** Format: date-time */ /** Format: date-time */
@@ -1330,7 +1503,8 @@ export interface components {
attributes: { attributes: {
[key: string]: string; [key: string]: string;
}; };
hasTraceData?: boolean; hasTraceData: boolean;
isReplay: boolean;
}; };
SearchResultExecutionSummary: { SearchResultExecutionSummary: {
data: components["schemas"]["ExecutionSummary"][]; data: components["schemas"]["ExecutionSummary"][];
@@ -1508,6 +1682,8 @@ export interface components {
prevAvgDurationMs: number; prevAvgDurationMs: number;
/** Format: int64 */ /** Format: int64 */
prevP99LatencyMs: number; prevP99LatencyMs: number;
/** Format: double */
slaCompliance: number;
}; };
StatsTimeseries: { StatsTimeseries: {
buckets: components["schemas"]["TimeseriesBucket"][]; buckets: components["schemas"]["TimeseriesBucket"][];
@@ -1526,6 +1702,28 @@ export interface components {
/** Format: int64 */ /** Format: int64 */
activeCount: number; 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 */ /** @description Aggregated route performance metrics */
RouteMetrics: { RouteMetrics: {
routeId: string; routeId: string;
@@ -1543,6 +1741,8 @@ export interface components {
/** Format: double */ /** Format: double */
throughputPerSec: number; throughputPerSec: number;
sparkline: number[]; sparkline: number[];
/** Format: double */
slaCompliance: number;
}; };
ProcessorMetrics: { ProcessorMetrics: {
processorId: string; processorId: string;
@@ -1586,6 +1786,8 @@ export interface components {
exchangeCount: number; exchangeCount: number;
/** Format: date-time */ /** Format: date-time */
lastSeen: string; lastSeen: string;
/** @description The from() endpoint URI, e.g. 'direct:processOrder' */
fromEndpointUri: string;
}; };
/** @description Application log entry from OpenSearch */ /** @description Application log entry from OpenSearch */
LogEntryResponse: { LogEntryResponse: {
@@ -1627,12 +1829,12 @@ export interface components {
attributes: { attributes: {
[key: string]: string; [key: string]: string;
}; };
errorType?: string; errorType: string;
errorCategory?: string; errorCategory: string;
rootCauseType?: string; rootCauseType: string;
rootCauseMessage?: string; rootCauseMessage: string;
traceId?: string; traceId: string;
spanId?: string; spanId: string;
}; };
ProcessorNode: { ProcessorNode: {
processorId: string; processorId: string;
@@ -1644,30 +1846,32 @@ export interface components {
endTime: string; endTime: string;
/** Format: int64 */ /** Format: int64 */
durationMs: number; durationMs: number;
/** Format: int32 */
loopIndex?: number;
/** Format: int32 */
loopSize?: number;
/** Format: int32 */
splitIndex?: number;
/** Format: int32 */
splitSize?: number;
/** Format: int32 */
multicastIndex?: number;
errorMessage: string; errorMessage: string;
errorStackTrace: string; errorStackTrace: string;
attributes: { attributes: {
[key: string]: string; [key: string]: string;
}; };
resolvedEndpointUri?: string; /** Format: int32 */
errorType?: string; loopIndex: number;
errorCategory?: string; /** Format: int32 */
rootCauseType?: string; loopSize: number;
rootCauseMessage?: string; /** Format: int32 */
errorHandlerType?: string; splitIndex: number;
circuitBreakerState?: string; /** Format: int32 */
fallbackTriggered?: boolean; splitSize: number;
hasTraceData?: boolean; /** 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"][]; children: components["schemas"]["ProcessorNode"][];
}; };
DiagramLayout: { 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: { searchGet: {
parameters: { parameters: {
query?: { 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: { getMetrics: {
parameters: { parameters: {
query?: { query?: {
@@ -3680,7 +4053,7 @@ export interface operations {
}; };
}; };
}; };
getProcessorSnapshotById: { processorSnapshotById: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -3721,8 +4094,7 @@ export interface operations {
query: { query: {
application: string; application: string;
routeId: string; routeId: string;
/** @description Layout direction: LR (left-to-right) or TB (top-to-bottom) */ direction?: string;
direction?: "LR" | "TB";
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -3753,8 +4125,7 @@ export interface operations {
renderDiagram: { renderDiagram: {
parameters: { parameters: {
query?: { query?: {
/** @description Layout direction: LR (left-to-right) or TB (top-to-bottom) */ direction?: string;
direction?: "LR" | "TB";
}; };
header?: never; header?: never;
path: { 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: { getConfig_2: {
parameters: { parameters: {
query?: never; 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: { deleteIndex: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -28,19 +28,6 @@ const TABS: { key: DetailTab; label: string }[] = [
{ key: 'log', label: 'Log' }, { 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({ export function DetailPanel({
selectedProcessor, selectedProcessor,
executionDetail, executionDetail,
@@ -99,22 +86,11 @@ export function DetailPanel({
if (activeTab === 'output' && !hasOutput) setActiveTab('info'); if (activeTab === 'output' && !hasOutput) setActiveTab('info');
}, [hasHeaders, hasInput, hasOutput, activeTab]); }, [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 ( return (
<div className={styles.detailPanel}> <div className={styles.detailPanel}>
{/* Processor / Exchange header bar */} {/* Header bar */}
<div className={styles.processorHeader}> <div className={styles.processorHeader}>
<span className={styles.processorName}>{headerName}</span> <span className={styles.processorName}>{selectedProcessor ? 'Processor Details' : 'Exchange Details'}</span>
<span className={`${styles.statusBadge} ${statusClass(headerStatus)}`}>
{headerStatus}
</span>
<span className={styles.processorId}>{headerId}</span>
<span className={styles.processorDuration}>{formatDuration(headerDuration)}</span>
</div> </div>
{/* Tab bar */} {/* Tab bar */}

View File

@@ -61,6 +61,28 @@
position: relative; 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 { .splitter {
height: 4px; height: 4px;
background: var(--border, #E4DFD8); background: var(--border, #E4DFD8);

View File

@@ -20,15 +20,46 @@ interface ExecutionDiagramProps {
className?: string; 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( function findProcessorInTree(
nodes: ProcessorNode[] | undefined, nodes: ProcessorNode[] | undefined,
processorId: string | null, processorId: string | null,
iterationState?: Map<string, import('./types').IterationInfo>,
parentId?: string,
): ProcessorNode | null { ): ProcessorNode | null {
if (!nodes || !processorId) return null; if (!nodes || !processorId) return null;
for (const n of nodes) { 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.processorId === processorId) return n;
if (n.children) { if (n.children) {
const found = findProcessorInTree(n.children, processorId); const found = findProcessorInTree(n.children, processorId, iterationState, n.processorId);
if (found) return found; if (found) return found;
} }
} }
@@ -120,6 +151,18 @@ export function ExecutionDiagram({
} }
}, [detail?.processors]); }, [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 // Loading state
if (detailLoading || (detail && diagramLoading)) { if (detailLoading || (detail && diagramLoading)) {
return ( return (
@@ -158,6 +201,13 @@ export function ExecutionDiagram({
<div ref={containerRef} className={`${styles.executionDiagram} ${className ?? ''}`}> <div ref={containerRef} className={`${styles.executionDiagram} ${className ?? ''}`}>
{/* Diagram area */} {/* Diagram area */}
<div className={styles.diagramArea} style={{ height: `${splitPercent}%` }}> <div className={styles.diagramArea} style={{ height: `${splitPercent}%` }}>
<button
className={styles.downloadBtn}
onClick={handleDownloadJson}
title="Download execution JSON"
>
JSON
</button>
<ProcessDiagram <ProcessDiagram
application={detail.applicationName} application={detail.applicationName}
routeId={detail.routeId} routeId={detail.routeId}
@@ -185,7 +235,11 @@ export function ExecutionDiagram({
{/* Detail panel */} {/* Detail panel */}
<div className={styles.detailArea} style={{ height: `${100 - splitPercent}%` }}> <div className={styles.detailArea} style={{ height: `${100 - splitPercent}%` }}>
<DetailPanel <DetailPanel
selectedProcessor={findProcessorInTree(detail.processors, selectedProcessorId || null)} selectedProcessor={
selectedProcessorId && overlay.has(selectedProcessorId)
? findProcessorInTree(detail.processors, selectedProcessorId, iterationState)
: null
}
executionDetail={detail} executionDetail={detail}
executionId={executionId} executionId={executionId}
onSelectProcessor={setSelectedProcessorId} onSelectProcessor={setSelectedProcessorId}

View File

@@ -12,6 +12,10 @@ export interface NodeExecutionState {
hasTraceData?: boolean; hasTraceData?: boolean;
/** Runtime-resolved endpoint URI (for TO_DYNAMIC, etc.) */ /** Runtime-resolved endpoint URI (for TO_DYNAMIC, etc.) */
resolvedEndpointUri?: string; 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 { export interface IterationInfo {

View File

@@ -61,6 +61,8 @@ function buildOverlay(
subRouteFailed: subRouteFailed || undefined, subRouteFailed: subRouteFailed || undefined,
hasTraceData: !!proc.hasTraceData, hasTraceData: !!proc.hasTraceData,
resolvedEndpointUri: proc.resolvedEndpointUri || undefined, resolvedEndpointUri: proc.resolvedEndpointUri || undefined,
filterMatched: proc.filterMatched ?? undefined,
duplicateMessage: proc.duplicateMessage ?? undefined,
}); });
// Recurse into children // Recurse into children

View File

@@ -210,7 +210,21 @@ function LayoutContent() {
const handlePaletteSelect = useCallback((result: any) => { const handlePaletteSelect = useCallback((result: any) => {
if (result.path) { 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); setPaletteOpen(false);
}, [navigate, setPaletteOpen]); }, [navigate, setPaletteOpen]);

View File

@@ -1,5 +1,5 @@
import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams'; import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './types'; import type { NodeConfig, LatencyHeatmapEntry } from './types';
import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types'; import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types';
import { colorForType, isCompoundType, iconForType, type IconElement } from './node-colors'; import { colorForType, isCompoundType, iconForType, type IconElement } from './node-colors';
import { DiagramNode } from './DiagramNode'; import { DiagramNode } from './DiagramNode';
@@ -27,6 +27,7 @@ interface CompoundNodeProps {
iterationState?: Map<string, IterationInfo>; iterationState?: Map<string, IterationInfo>;
/** Called when user changes iteration on a compound stepper */ /** Called when user changes iteration on a compound stepper */
onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void; onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void;
latencyHeatmap?: Map<string, LatencyHeatmapEntry>;
onNodeClick: (nodeId: string) => void; onNodeClick: (nodeId: string) => void;
onNodeDoubleClick?: (nodeId: string) => void; onNodeDoubleClick?: (nodeId: string) => void;
onNodeEnter: (nodeId: string) => void; onNodeEnter: (nodeId: string) => void;
@@ -36,7 +37,7 @@ interface CompoundNodeProps {
export function CompoundNode({ export function CompoundNode({
node, edges, parentX = 0, parentY = 0, node, edges, parentX = 0, parentY = 0,
selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay, selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
overlayActive, iterationState, onIterationChange, overlayActive, iterationState, onIterationChange, latencyHeatmap,
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave, onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
}: CompoundNodeProps) { }: CompoundNodeProps) {
const x = (node.x ?? 0) - parentX; const x = (node.x ?? 0) - parentX;
@@ -61,10 +62,19 @@ export function CompoundNode({
const childProps = { const childProps = {
edges, selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay, edges, selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
overlayActive, iterationState, onIterationChange, overlayActive, iterationState, onIterationChange, latencyHeatmap,
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave, 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 // _TRY_BODY / _CB_MAIN: transparent wrapper — no header, no border, just layout
if (node.type === '_TRY_BODY' || node.type === '_CB_MAIN') { if (node.type === '_TRY_BODY' || node.type === '_CB_MAIN') {
return ( return (
@@ -79,7 +89,7 @@ export function CompoundNode({
if (node.type === '_CB_FALLBACK') { if (node.type === '_CB_FALLBACK') {
const fallbackColor = '#7C3AED'; // EIP purple const fallbackColor = '#7C3AED'; // EIP purple
return ( 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} <rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
fill={fallbackColor} fillOpacity={0.06} /> fill={fallbackColor} fillOpacity={0.06} />
<rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS} <rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
@@ -100,7 +110,7 @@ export function CompoundNode({
: (node.label ? `finally: ${node.label}` : 'finally'); : (node.label ? `finally: ${node.label}` : 'finally');
return ( 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 */} {/* Tinted background */}
<rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS} <rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
fill={color} fillOpacity={0.06} /> fill={color} fillOpacity={0.06} />
@@ -117,9 +127,10 @@ export function CompoundNode({
); );
} }
// Default compound rendering (DO_TRY, EIP_CHOICE, etc.) // Default compound rendering (DO_TRY, EIP_CHOICE, EIP_FILTER, EIP_IDEMPOTENT_CONSUMER, etc.)
const containerFill = isGated ? 'var(--amber-bg)' : 'white';
return ( 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 */} {/* Container body */}
<rect <rect
x={0} x={0}
@@ -127,14 +138,14 @@ export function CompoundNode({
width={w} width={w}
height={h} height={h}
rx={CORNER_RADIUS} rx={CORNER_RADIUS}
fill="white" fill={containerFill}
stroke={color} stroke={effectiveColor}
strokeWidth={1.5} strokeWidth={isGated ? 2 : 1.5}
/> />
{/* Colored header bar */} {/* Colored header bar */}
<rect x={0} y={0} width={w} height={HEADER_HEIGHT} rx={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={color} /> <rect x={CORNER_RADIUS} y={CORNER_RADIUS} width={w - CORNER_RADIUS * 2} height={HEADER_HEIGHT - CORNER_RADIUS} fill={effectiveColor} />
{/* Header icon (left-aligned) */} {/* Header icon (left-aligned) */}
<g transform={`translate(6, ${HEADER_HEIGHT / 2 - 5}) scale(0.417)`}> <g transform={`translate(6, ${HEADER_HEIGHT / 2 - 5}) scale(0.417)`}>
@@ -243,6 +254,7 @@ function renderChildren(
config={child.id ? props.nodeConfigs?.get(child.id) : undefined} config={child.id ? props.nodeConfigs?.get(child.id) : undefined}
executionState={props.executionOverlay?.get(child.id ?? '')} executionState={props.executionOverlay?.get(child.id ?? '')}
overlayActive={props.overlayActive} overlayActive={props.overlayActive}
heatmapEntry={child.id ? props.latencyHeatmap?.get(child.id) : undefined}
onClick={() => child.id && props.onNodeClick(child.id)} onClick={() => child.id && props.onNodeClick(child.id)}
onDoubleClick={() => child.id && props.onNodeDoubleClick?.(child.id)} onDoubleClick={() => child.id && props.onNodeDoubleClick?.(child.id)}
onMouseEnter={() => child.id && props.onNodeEnter(child.id)} onMouseEnter={() => child.id && props.onNodeEnter(child.id)}
@@ -260,3 +272,15 @@ function collectIds(nodes: DiagramNodeType[], set: Set<string>) {
if (n.children) collectIds(n.children, set); 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 { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './types'; import type { NodeConfig, LatencyHeatmapEntry } from './types';
import type { NodeExecutionState } from '../ExecutionDiagram/types'; import type { NodeExecutionState } from '../ExecutionDiagram/types';
import { colorForType, iconForType, type IconElement } from './node-colors'; import { colorForType, iconForType, type IconElement } from './node-colors';
import { ConfigBadge } from './ConfigBadge';
const TOP_BAR_HEIGHT = 6; const TOP_BAR_HEIGHT = 6;
const TEXT_LEFT = 32; const TEXT_LEFT = 32;
@@ -16,12 +16,27 @@ interface DiagramNodeProps {
config?: NodeConfig; config?: NodeConfig;
executionState?: NodeExecutionState; executionState?: NodeExecutionState;
overlayActive?: boolean; overlayActive?: boolean;
heatmapEntry?: LatencyHeatmapEntry;
onClick: () => void; onClick: () => void;
onDoubleClick?: () => void; onDoubleClick?: () => void;
onMouseEnter: () => void; onMouseEnter: () => void;
onMouseLeave: () => void; onMouseLeave: () => void;
} }
/** Interpolate green (120°) → yellow (60°) → red (0°) based on pctOfRoute */
function heatmapColor(pct: number): string {
const clamped = Math.max(0, Math.min(100, pct));
// 0% → hue 120 (green), 50% → hue 60 (yellow), 100% → hue 0 (red)
const hue = 120 - (clamped / 100) * 120;
return `hsl(${Math.round(hue)}, 70%, 92%)`;
}
function heatmapBorderColor(pct: number): string {
const clamped = Math.max(0, Math.min(100, pct));
const hue = 120 - (clamped / 100) * 120;
return `hsl(${Math.round(hue)}, 60%, 50%)`;
}
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`; if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`; return `${(ms / 1000).toFixed(1)}s`;
@@ -29,7 +44,7 @@ function formatDuration(ms: number): string {
export function DiagramNode({ export function DiagramNode({
node, isHovered, isSelected, config, node, isHovered, isSelected, config,
executionState, overlayActive, executionState, overlayActive, heatmapEntry,
onClick, onDoubleClick, onMouseEnter, onMouseLeave, onClick, onDoubleClick, onMouseEnter, onMouseLeave,
}: DiagramNodeProps) { }: DiagramNodeProps) {
const x = node.x ?? 0; const x = node.x ?? 0;
@@ -49,7 +64,7 @@ export function DiagramNode({
const isFailed = executionState?.status === 'FAILED'; const isFailed = executionState?.status === 'FAILED';
const isSkipped = overlayActive && !executionState; const isSkipped = overlayActive && !executionState;
// Colors based on execution state // Colors based on execution state (heatmap takes priority when no execution overlay)
let cardFill = isHovered ? '#F5F0EA' : 'white'; let cardFill = isHovered ? '#F5F0EA' : 'white';
let borderStroke = isHovered || isSelected ? color : '#E4DFD8'; let borderStroke = isHovered || isSelected ? color : '#E4DFD8';
let borderWidth = isHovered || isSelected ? 1.5 : 1; let borderWidth = isHovered || isSelected ? 1.5 : 1;
@@ -67,6 +82,11 @@ export function DiagramNode({
borderWidth = 2; borderWidth = 2;
topBarColor = '#C0392B'; topBarColor = '#C0392B';
labelColor = '#C0392B'; labelColor = '#C0392B';
} else if (heatmapEntry && !overlayActive) {
cardFill = heatmapColor(heatmapEntry.pctOfRoute);
borderStroke = heatmapBorderColor(heatmapEntry.pctOfRoute);
borderWidth = 1.5;
topBarColor = heatmapBorderColor(heatmapEntry.pctOfRoute);
} }
const statusColor = isCompleted ? '#3D7C47' : isFailed ? '#C0392B' : undefined; const statusColor = isCompleted ? '#3D7C47' : isFailed ? '#C0392B' : undefined;
@@ -138,7 +158,7 @@ export function DiagramNode({
{detail} {detail}
</text> </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]} {resolvedUri.split('?')[0]}
</text> </text>
</> </>
@@ -156,30 +176,92 @@ export function DiagramNode({
)} )}
</g> </g>
{/* Config badges */} {/* Inline badges row: hasTrace, hasTap, status — inside card, top-right */}
{(config || executionState?.hasTraceData) && ( {(() => {
<ConfigBadge nodeWidth={w} config={config ?? {}} hasTraceData={executionState?.hasTraceData} /> 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 */} // Status badge (rightmost, only during overlay)
{isCompleted && ( const statusCx = w - BADGE_R - 4;
<> if (isCompleted) {
<circle cx={w - 10} cy={TOP_BAR_HEIGHT + 8} r={6} fill="#3D7C47" /> badges.push(
<path <g key="status">
d={`M${w - 13} ${TOP_BAR_HEIGHT + 8} l2 2 4-4`} <circle cx={statusCx} cy={cy} r={BADGE_R} fill="#3D7C47" />
fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" <path d={`M${statusCx - 3} ${cy} l2 2 4-4`} fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
/> </g>
</> );
)} slot++;
{isFailed && ( } else if (isFailed) {
<> badges.push(
<circle cx={w - 10} cy={TOP_BAR_HEIGHT + 8} r={6} fill="#C0392B" /> <g key="status">
<path <circle cx={statusCx} cy={cy} r={BADGE_R} fill="none" stroke="#C0392B" strokeWidth={2} opacity={0.5}>
d={`M${w - 10} ${TOP_BAR_HEIGHT + 5} v4 M${w - 10} ${TOP_BAR_HEIGHT + 10.5} v0.5`} <animate attributeName="r" values="6;14" dur="1.5s" repeatCount="indefinite" />
fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round" <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 */} {/* Execution overlay: duration text at bottom-right */}
{executionState && statusColor && ( {executionState && statusColor && (
@@ -195,6 +277,20 @@ export function DiagramNode({
</text> </text>
)} )}
{/* Heatmap: avg duration label at bottom-right */}
{heatmapEntry && !overlayActive && !executionState && (
<text
x={w - 6}
y={h - 4}
textAnchor="end"
fill={heatmapBorderColor(heatmapEntry.pctOfRoute)}
fontSize={9}
fontWeight={600}
>
{formatDuration(heatmapEntry.avgDurationMs)}
</text>
)}
{/* Sub-route failure: drill-down arrow at bottom-left */} {/* Sub-route failure: drill-down arrow at bottom-left */}
{isFailed && executionState?.subRouteFailed && ( {isFailed && executionState?.subRouteFailed && (
<g transform={`translate(4, ${h - 14})`}> <g transform={`translate(4, ${h - 14})`}>

View File

@@ -34,6 +34,7 @@ export function ProcessDiagram({
iterationState, iterationState,
onIterationChange, onIterationChange,
centerOnNodeId, centerOnNodeId,
latencyHeatmap,
}: ProcessDiagramProps) { }: ProcessDiagramProps) {
// Route stack for drill-down navigation // Route stack for drill-down navigation
const [routeStack, setRouteStack] = useState<string[]>([routeId]); const [routeStack, setRouteStack] = useState<string[]>([routeId]);
@@ -338,6 +339,7 @@ export function ProcessDiagram({
overlayActive={overlayActive} overlayActive={overlayActive}
iterationState={iterationState} iterationState={iterationState}
onIterationChange={onIterationChange} onIterationChange={onIterationChange}
latencyHeatmap={latencyHeatmap}
onNodeClick={handleNodeClick} onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick} onNodeDoubleClick={handleNodeDoubleClick}
onNodeEnter={toolbar.onNodeEnter} onNodeEnter={toolbar.onNodeEnter}
@@ -354,6 +356,7 @@ export function ProcessDiagram({
config={node.id ? nodeConfigs?.get(node.id) : undefined} config={node.id ? nodeConfigs?.get(node.id) : undefined}
executionState={getNodeExecutionState(node.id, node.type)} executionState={getNodeExecutionState(node.id, node.type)}
overlayActive={overlayActive} overlayActive={overlayActive}
heatmapEntry={node.id ? latencyHeatmap?.get(node.id) : undefined}
onClick={() => node.id && handleNodeClick(node.id)} onClick={() => node.id && handleNodeClick(node.id)}
onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)} onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)}
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)} onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}

View File

@@ -16,6 +16,13 @@ export interface DiagramSection {
variant?: 'error' | 'completion'; variant?: 'error' | 'completion';
} }
export interface LatencyHeatmapEntry {
avgDurationMs: number;
p99DurationMs: number;
/** Percentage of total route time this processor consumes (0-100) */
pctOfRoute: number;
}
export interface ProcessDiagramProps { export interface ProcessDiagramProps {
application: string; application: string;
routeId: string; routeId: string;
@@ -39,4 +46,7 @@ export interface ProcessDiagramProps {
onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void; onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void;
/** When set, the diagram pans to center this node in the viewport */ /** When set, the diagram pans to center this node in the viewport */
centerOnNodeId?: string; centerOnNodeId?: string;
/** Latency heatmap: maps processor ID → aggregate performance data.
* When provided, nodes are colored green→yellow→red by relative latency. */
latencyHeatmap?: Map<string, LatencyHeatmapEntry>;
} }

View File

@@ -3,7 +3,8 @@ import type { Column } from '@cameleer/design-system';
import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database'; import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database';
export default function DatabaseAdminPage() { 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: pool } = useConnectionPool();
const { data: tables } = useDatabaseTables(); const { data: tables } = useDatabaseTables();
const { data: queries } = useActiveQueries(); const { data: queries } = useActiveQueries();
@@ -34,7 +35,7 @@ export default function DatabaseAdminPage() {
<h2 style={{ marginBottom: '1rem' }}>Database Administration</h2> <h2 style={{ marginBottom: '1rem' }}>Database Administration</h2>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}> <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="Version" value={status?.version ?? '—'} />
<StatCard label="TimescaleDB" value={status?.timescaleDb ? 'Enabled' : 'Disabled'} /> <StatCard label="TimescaleDB" value={status?.timescaleDb ? 'Enabled' : 'Disabled'} />
</div> </div>

View File

@@ -4,11 +4,12 @@ import { useOpenSearchStatus, usePipelineStats, useOpenSearchIndices, useOpenSea
import styles from './OpenSearchAdminPage.module.css'; import styles from './OpenSearchAdminPage.module.css';
export default function OpenSearchAdminPage() { export default function OpenSearchAdminPage() {
const { data: status } = useOpenSearchStatus(); const { data: status, isError: statusError } = useOpenSearchStatus();
const { data: pipeline } = usePipelineStats(); const { data: pipeline } = usePipelineStats();
const { data: perf } = useOpenSearchPerformance(); const { data: perf } = useOpenSearchPerformance();
const { data: execIndices } = useOpenSearchIndices(0, 50, '', 'executions'); const { data: execIndices } = useOpenSearchIndices(0, 50, '', 'executions');
const { data: logIndices } = useOpenSearchIndices(0, 50, '', 'logs'); const { data: logIndices } = useOpenSearchIndices(0, 50, '', 'logs');
const unreachable = statusError || (status && !status.reachable);
const deleteIndex = useDeleteIndex(); const deleteIndex = useDeleteIndex();
const indexColumns: Column<any>[] = [ const indexColumns: Column<any>[] = [
@@ -22,7 +23,7 @@ export default function OpenSearchAdminPage() {
return ( return (
<div> <div>
<div className={styles.statStrip}> <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="Health" value={status?.clusterHealth ?? '\u2014'} accent={status?.clusterHealth === 'green' ? 'success' : 'warning'} />
<StatCard label="Version" value={status?.version ?? '\u2014'} /> <StatCard label="Version" value={status?.version ?? '\u2014'} />
<StatCard label="Nodes" value={status?.nodeCount ?? 0} /> <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 { useParams, useNavigate } from 'react-router';
import { ExternalLink, RefreshCw, Pencil } from 'lucide-react'; import { ExternalLink, RefreshCw, Pencil } from 'lucide-react';
import { import {
StatCard, StatusDot, Badge, MonoText, ProgressBar, StatCard, StatusDot, Badge, MonoText,
GroupCard, DataTable, LineChart, EventFeed, DetailPanel, GroupCard, DataTable, EventFeed,
LogViewer, ButtonGroup, SectionHeader, Toggle, useToast, LogViewer, ButtonGroup, SectionHeader, Toggle, useToast,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column, FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system'; import type { Column, FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system';
import styles from './AgentHealth.module.css'; import styles from './AgentHealth.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useApplicationLogs } from '../../api/queries/logs'; import { useApplicationLogs } from '../../api/queries/logs';
import { useAgentMetrics } from '../../api/queries/agent-metrics';
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'; import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
import type { AgentInstance } from '../../api/types'; import type { AgentInstance } from '../../api/types';
@@ -96,132 +95,6 @@ function appHealth(group: AppGroup): 'success' | 'warning' | 'error' {
// ── Detail sub-components ──────────────────────────────────────────────────── // ── 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[] = [ const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [
{ value: 'error', label: 'Error', color: 'var(--error)' }, { value: 'error', label: 'Error', color: 'var(--error)' },
{ value: 'warn', label: 'Warn', color: 'var(--warning)' }, { 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) => logLevels.size === 0 || logLevels.has(l.level))
.filter((l) => !logSearchLower || l.message.toLowerCase().includes(logSearchLower)); .filter((l) => !logSearchLower || l.message.toLowerCase().includes(logSearchLower));
const [selectedInstance, setSelectedInstance] = useState<AgentInstance | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const agentList = agents ?? []; const agentList = agents ?? [];
const groups = useMemo(() => groupByApp(agentList), [agentList]); const groups = useMemo(() => groupByApp(agentList), [agentList]);
@@ -428,26 +298,9 @@ export default function AgentHealth() {
); );
function handleInstanceClick(inst: AgentInstance) { function handleInstanceClick(inst: AgentInstance) {
setSelectedInstance(inst); navigate(`/runtime/${inst.application}/${inst.id}`);
setPanelOpen(true);
} }
// 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; const isFullWidth = !!appId;
return ( return (
@@ -677,7 +530,6 @@ export default function AgentHealth() {
columns={instanceColumns} columns={instanceColumns}
data={group.instances} data={group.instances}
onRowClick={handleInstanceClick} onRowClick={handleInstanceClick}
selectedId={panelOpen ? selectedInstance?.id : undefined}
pageSize={50} pageSize={50}
flush flush
/> />
@@ -758,15 +610,6 @@ export default function AgentHealth() {
</div> </div>
</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> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
import { useState, useMemo, useCallback } from 'react' import { useState, useMemo, useCallback } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router' 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 { import {
DataTable, DataTable,
StatusDot, StatusDot,
@@ -79,6 +79,7 @@ function buildBaseColumns(): Column<Row>[] {
<StatusDot variant={statusToVariant(row.status)} /> <StatusDot variant={statusToVariant(row.status)} />
<MonoText size="xs">{statusLabel(row.status)}</MonoText> <MonoText size="xs">{statusLabel(row.status)}</MonoText>
{row.hasTraceData && <Footprints size={11} color="#3D7C47" style={{ marginLeft: 2, flexShrink: 0 }} />} {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> </span>
), ),
}, },

View File

@@ -0,0 +1,466 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router';
import {
KpiStrip,
DataTable,
AreaChart,
LineChart,
Card,
Sparkline,
MonoText,
StatusDot,
Badge,
} from '@cameleer/design-system';
import type { KpiItem, Column } from '@cameleer/design-system';
import { useGlobalFilters } from '@cameleer/design-system';
import { useRouteMetrics } from '../../api/queries/catalog';
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import { useTimeseriesByApp, useTopErrors, useAllAppSettings, usePunchcard } from '../../api/queries/dashboard';
import type { AppSettings } from '../../api/queries/dashboard';
import { Treemap } from './Treemap';
import type { TreemapItem } from './Treemap';
import { PunchcardHeatmap } from './PunchcardHeatmap';
import type { RouteMetrics } from '../../api/types';
import {
computeHealthDot,
formatThroughput,
formatSlaCompliance,
trendIndicator,
type HealthStatus,
} from './dashboard-utils';
import styles from './DashboardTab.module.css';
// ── Row type for application health table ───────────────────────────────────
interface AppRow {
id: string;
appId: string;
health: HealthStatus;
throughput: number;
throughputLabel: string;
successRate: number;
p99DurationMs: number;
slaCompliance: number;
errorCount: number;
sparkline: number[];
}
// ── Table columns ───────────────────────────────────────────────────────────
const APP_COLUMNS: Column<AppRow>[] = [
{
key: 'health',
header: '',
render: (_, row) => <StatusDot variant={row.health} />,
},
{
key: 'appId',
header: 'Application',
sortable: true,
render: (_, row) => (
<span className={styles.appNameCell}>{row.appId}</span>
),
},
{
key: 'throughput',
header: 'Throughput',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.throughputLabel}</MonoText>
),
},
{
key: 'successRate',
header: 'Success %',
sortable: true,
render: (_, row) => {
const pct = row.successRate;
const cls = pct >= 99 ? styles.rateGood : pct >= 97 ? styles.rateWarn : styles.rateBad;
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
},
},
{
key: 'p99DurationMs',
header: 'P99',
sortable: true,
render: (_, row) => {
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
},
},
{
key: 'slaCompliance',
header: 'SLA %',
sortable: true,
render: (_, row) => {
const cls = row.slaCompliance >= 99 ? styles.rateGood : row.slaCompliance >= 95 ? styles.rateWarn : styles.rateBad;
return <MonoText size="sm" className={cls}>{formatSlaCompliance(row.slaCompliance)}</MonoText>;
},
},
{
key: 'errorCount',
header: 'Errors',
sortable: true,
render: (_, row) => {
const cls = row.errorCount > 10 ? styles.rateBad : row.errorCount > 0 ? styles.rateWarn : styles.rateGood;
return <MonoText size="sm" className={cls}>{row.errorCount.toLocaleString()}</MonoText>;
},
},
{
key: 'sparkline',
header: 'Trend',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
];
// ── Aggregate RouteMetrics by appId ─────────────────────────────────────────
function aggregateByApp(
metrics: RouteMetrics[],
windowSeconds: number,
settingsMap: Map<string, AppSettings>,
): AppRow[] {
const grouped = new Map<string, RouteMetrics[]>();
for (const m of metrics) {
const list = grouped.get(m.appId) ?? [];
list.push(m);
grouped.set(m.appId, list);
}
const rows: AppRow[] = [];
for (const [appId, routes] of grouped) {
const totalExchanges = routes.reduce((s, r) => s + r.exchangeCount, 0);
const totalFailed = routes.reduce((s, r) => s + r.exchangeCount * r.errorRate, 0);
const successRate = totalExchanges > 0 ? ((totalExchanges - totalFailed) / totalExchanges) * 100 : 100;
const errorRate = totalExchanges > 0 ? totalFailed / totalExchanges : 0;
// Weighted average p99 by exchange count
const p99Sum = routes.reduce((s, r) => s + r.p99DurationMs * r.exchangeCount, 0);
const p99DurationMs = totalExchanges > 0 ? p99Sum / totalExchanges : 0;
// SLA compliance: weighted average of per-route slaCompliance from backend
const appSettings = settingsMap.get(appId);
const slaWeightedSum = routes.reduce((s, r) => s + (r.slaCompliance ?? 100) * r.exchangeCount, 0);
const slaCompliance = totalExchanges > 0 ? slaWeightedSum / totalExchanges : 100;
const errorCount = Math.round(totalFailed);
// Merge sparklines: sum across routes per bucket position
const maxLen = Math.max(...routes.map((r) => (r.sparkline ?? []).length), 0);
const sparkline: number[] = [];
for (let i = 0; i < maxLen; i++) {
sparkline.push(routes.reduce((s, r) => s + ((r.sparkline ?? [])[i] ?? 0), 0));
}
rows.push({
id: appId,
appId,
health: computeHealthDot(errorRate, slaCompliance, appSettings),
throughput: totalExchanges,
throughputLabel: formatThroughput(totalExchanges, windowSeconds),
successRate,
p99DurationMs,
slaCompliance,
errorCount,
sparkline,
});
}
return rows.sort((a, b) => {
const order: Record<HealthStatus, number> = { error: 0, warning: 1, success: 2 };
return order[a.health] - order[b.health];
});
}
// ── Build KPI items ─────────────────────────────────────────────────────────
function buildKpiItems(
stats: {
totalCount: number;
failedCount: number;
p99LatencyMs: number;
prevTotalCount: number;
prevFailedCount: number;
prevP99LatencyMs: number;
} | undefined,
windowSeconds: number,
slaCompliance: number,
activeErrorCount: number,
throughputSparkline: number[],
successSparkline: number[],
latencySparkline: number[],
slaSparkline: number[],
errorSparkline: number[],
): KpiItem[] {
const totalCount = stats?.totalCount ?? 0;
const failedCount = stats?.failedCount ?? 0;
const prevTotalCount = stats?.prevTotalCount ?? 0;
const prevFailedCount = stats?.prevFailedCount ?? 0;
const p99Ms = stats?.p99LatencyMs ?? 0;
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
// Throughput
const throughput = windowSeconds > 0 ? totalCount / windowSeconds : 0;
const prevThroughput = windowSeconds > 0 ? prevTotalCount / windowSeconds : 0;
const throughputTrend = trendIndicator(throughput, prevThroughput);
// Success Rate
const successPct = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100;
const prevSuccessPct = prevTotalCount > 0
? ((prevTotalCount - prevFailedCount) / prevTotalCount) * 100
: 100;
const successTrend = trendIndicator(successPct, prevSuccessPct);
// P99 Latency
const p99Trend = trendIndicator(p99Ms, prevP99Ms);
// SLA compliance trend — higher is better, so invert the variant
const slaTrend = trendIndicator(slaCompliance, 100);
// Active Errors
const prevErrorRate = prevTotalCount > 0 ? (prevFailedCount / prevTotalCount) * 100 : 0;
const currentErrorRate = totalCount > 0 ? (failedCount / totalCount) * 100 : 0;
const errorTrend = trendIndicator(currentErrorRate, prevErrorRate);
return [
{
label: 'Throughput',
value: formatThroughput(totalCount, windowSeconds),
trend: {
label: throughputTrend.label,
variant: throughputTrend.direction === 'up' ? 'success' as const : throughputTrend.direction === 'down' ? 'error' as const : 'muted' as const,
},
subtitle: `${totalCount.toLocaleString()} msg total`,
sparkline: throughputSparkline,
borderColor: 'var(--amber)',
},
{
label: 'Success Rate',
value: `${successPct.toFixed(1)}%`,
trend: {
label: successTrend.label,
variant: successPct >= 99 ? 'success' as const : successPct >= 97 ? 'warning' as const : 'error' as const,
},
subtitle: `${(totalCount - failedCount).toLocaleString()} succeeded`,
sparkline: successSparkline,
borderColor: successPct >= 99 ? 'var(--success)' : 'var(--error)',
},
{
label: 'P99 Latency',
value: `${Math.round(p99Ms)}ms`,
trend: {
label: p99Trend.label,
variant: p99Ms > 300 ? 'error' as const : p99Ms > 200 ? 'warning' as const : 'success' as const,
},
subtitle: `prev ${Math.round(prevP99Ms)}ms`,
sparkline: latencySparkline,
borderColor: p99Ms > 300 ? 'var(--warning)' : 'var(--success)',
},
{
label: 'SLA Compliance',
value: formatSlaCompliance(slaCompliance),
trend: {
label: slaTrend.label,
variant: slaCompliance >= 99 ? 'success' as const : slaCompliance >= 95 ? 'warning' as const : 'error' as const,
},
subtitle: 'P99 within threshold',
sparkline: slaSparkline,
borderColor: slaCompliance >= 99 ? 'var(--success)' : 'var(--warning)',
},
{
label: 'Active Errors',
value: String(activeErrorCount),
trend: {
label: errorTrend.label,
variant: activeErrorCount === 0 ? 'success' as const : 'error' as const,
},
subtitle: `${failedCount.toLocaleString()} failures total`,
sparkline: errorSparkline,
borderColor: activeErrorCount === 0 ? 'var(--success)' : 'var(--error)',
},
];
}
// ── Component ───────────────────────────────────────────────────────────────
export default function DashboardL1() {
const navigate = useNavigate();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const windowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
const { data: metrics } = useRouteMetrics(timeFrom, timeTo);
const { data: stats } = useExecutionStats(timeFrom, timeTo);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo);
const { data: timeseriesByApp } = useTimeseriesByApp(timeFrom, timeTo);
const { data: topErrors } = useTopErrors(timeFrom, timeTo);
const { data: punchcardData } = usePunchcard();
const { data: allAppSettings } = useAllAppSettings();
// Build settings lookup map
const settingsMap = useMemo(() => {
const map = new Map<string, AppSettings>();
for (const s of allAppSettings ?? []) {
map.set(s.appId, s);
}
return map;
}, [allAppSettings]);
// Aggregate route metrics by appId for the table
const appRows = useMemo(
() => aggregateByApp(metrics ?? [], windowSeconds, settingsMap),
[metrics, windowSeconds, settingsMap],
);
// Global SLA compliance from backend stats (exact calculation from executions table)
const globalSlaCompliance = stats?.slaCompliance ?? -1;
const effectiveSlaCompliance = globalSlaCompliance >= 0 ? globalSlaCompliance : 100;
// Active error count = distinct error types
const activeErrorCount = useMemo(
() => (topErrors ?? []).length,
[topErrors],
);
// KPI sparklines from timeseries buckets
const throughputSparkline = useMemo(
() => (timeseries?.buckets ?? []).map((b) => b.totalCount),
[timeseries],
);
const successSparkline = useMemo(
() => (timeseries?.buckets ?? []).map((b) =>
b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
),
[timeseries],
);
const latencySparkline = useMemo(
() => (timeseries?.buckets ?? []).map((b) => b.p99DurationMs),
[timeseries],
);
const slaSparkline = useMemo(
() => (timeseries?.buckets ?? []).map((b) =>
b.p99DurationMs <= 300 ? 100 : 0,
),
[timeseries],
);
const errorSparkline = useMemo(
() => (timeseries?.buckets ?? []).map((b) => b.failedCount),
[timeseries],
);
const kpiItems = useMemo(
() => buildKpiItems(
stats,
windowSeconds,
effectiveSlaCompliance,
activeErrorCount,
throughputSparkline,
successSparkline,
latencySparkline,
slaSparkline,
errorSparkline,
),
[stats, windowSeconds, effectiveSlaCompliance, activeErrorCount,
throughputSparkline, successSparkline, latencySparkline, slaSparkline, errorSparkline],
);
// ── Per-app chart series (throughput stacked area) ──────────────────────
const throughputByAppSeries = useMemo(() => {
if (!timeseriesByApp) return [];
return Object.entries(timeseriesByApp).map(([appId, { buckets }]) => ({
label: appId,
data: buckets.map((b, i) => ({
x: i as number,
y: b.totalCount,
})),
}));
}, [timeseriesByApp]);
// ── Per-app chart series (error rate line) ─────────────────────────────
const errorRateByAppSeries = useMemo(() => {
if (!timeseriesByApp) return [];
return Object.entries(timeseriesByApp).map(([appId, { buckets }]) => ({
label: appId,
data: buckets.map((b, i) => ({
x: i as number,
y: b.totalCount > 0 ? (b.failedCount / b.totalCount) * 100 : 0,
})),
}));
}, [timeseriesByApp]);
// Treemap items: one per app, sized by exchange count, colored by SLA
const treemapItems: TreemapItem[] = useMemo(
() => appRows.map(r => ({ id: r.appId, label: r.appId, value: r.throughput, slaCompliance: r.slaCompliance })),
[appRows],
);
return (
<div className={styles.content}>
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* KPI header cards */}
<KpiStrip items={kpiItems} />
{/* Application Health table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Application Health</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{appRows.length} applications</span>
<Badge label="ALL" color="auto" />
</div>
</div>
<DataTable
columns={APP_COLUMNS}
data={appRows}
sortable
onRowClick={(row) => navigate(`/dashboard/${row.appId}`)}
/>
</div>
{/* Side-by-side charts */}
{throughputByAppSeries.length > 0 && (
<div className={styles.chartGrid}>
<Card title="Throughput by Application (msg/s)">
<AreaChart
series={throughputByAppSeries}
yLabel="msg/s"
height={200}
className={styles.chart}
/>
</Card>
<Card title="Error Rate by Application (%)">
<LineChart
series={errorRateByAppSeries}
yLabel="%"
height={200}
className={styles.chart}
/>
</Card>
</div>
)}
{/* Treemap + Punchcard heatmaps side by side */}
{treemapItems.length > 0 && (
<div className={styles.vizRow}>
<Card title="Application Volume vs SLA Compliance">
<Treemap
items={treemapItems}
onItemClick={(id) => navigate(`/dashboard/${id}`)}
/>
</Card>
<Card title="7-Day Pattern">
<PunchcardHeatmap cells={punchcardData ?? []} />
</Card>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,447 @@
import { useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import {
KpiStrip,
DataTable,
AreaChart,
LineChart,
Card,
Sparkline,
MonoText,
Badge,
} from '@cameleer/design-system';
import type { KpiItem, Column } from '@cameleer/design-system';
import { useGlobalFilters } from '@cameleer/design-system';
import { useRouteMetrics } from '../../api/queries/catalog';
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import {
useTimeseriesByRoute,
useTopErrors,
useAppSettings,
usePunchcard,
} from '../../api/queries/dashboard';
import type { TopError } from '../../api/queries/dashboard';
import { Treemap } from './Treemap';
import type { TreemapItem } from './Treemap';
import { PunchcardHeatmap } from './PunchcardHeatmap';
import type { RouteMetrics } from '../../api/types';
import {
trendArrow,
trendIndicator,
formatThroughput,
formatSlaCompliance,
formatRelativeTime,
} from './dashboard-utils';
import styles from './DashboardTab.module.css';
// ── Route table row type ────────────────────────────────────────────────────
interface RouteRow {
id: string;
routeId: string;
exchangeCount: number;
successRate: number;
avgDurationMs: number;
p99DurationMs: number;
slaCompliance: number;
sparkline: number[];
}
// ── Route performance columns ───────────────────────────────────────────────
const ROUTE_COLUMNS: Column<RouteRow>[] = [
{
key: 'routeId',
header: 'Route ID',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.routeId}</MonoText>
),
},
{
key: 'exchangeCount',
header: 'Throughput',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.exchangeCount.toLocaleString()}</MonoText>
),
},
{
key: 'successRate',
header: 'Success%',
sortable: true,
render: (_, row) => {
const pct = row.successRate * 100;
const cls = pct >= 99 ? styles.rateGood : pct >= 97 ? styles.rateWarn : styles.rateBad;
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
},
},
{
key: 'avgDurationMs',
header: 'Avg(ms)',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{Math.round(row.avgDurationMs)}</MonoText>
),
},
{
key: 'p99DurationMs',
header: 'P99(ms)',
sortable: true,
render: (_, row) => {
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}</MonoText>;
},
},
{
key: 'slaCompliance',
header: 'SLA%',
sortable: true,
render: (_, row) => {
const cls = row.slaCompliance >= 99 ? styles.rateGood : row.slaCompliance >= 95 ? styles.rateWarn : styles.rateBad;
return <MonoText size="sm" className={cls}>{formatSlaCompliance(row.slaCompliance)}</MonoText>;
},
},
{
key: 'sparkline',
header: 'Sparkline',
render: (_, row) => (
<Sparkline data={row.sparkline} width={80} height={24} />
),
},
];
// ── Top errors columns ──────────────────────────────────────────────────────
type ErrorRow = TopError & { id: string };
const ERROR_COLUMNS: Column<ErrorRow>[] = [
{
key: 'errorType',
header: 'Error Type',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.errorType}</MonoText>
),
},
{
key: 'routeId',
header: 'Route',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.routeId ?? '\u2014'}</MonoText>
),
},
{
key: 'count',
header: 'Count',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.count.toLocaleString()}</MonoText>
),
},
{
key: 'velocity',
header: 'Velocity',
sortable: true,
render: (_, row) => {
const arrow = trendArrow(row.trend);
const cls = row.trend === 'accelerating' ? styles.rateBad
: row.trend === 'decelerating' ? styles.rateGood
: styles.rateNeutral;
return <MonoText size="sm" className={cls}>{row.velocity.toFixed(1)}/min {arrow}</MonoText>;
},
},
{
key: 'lastSeen',
header: 'Last Seen',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{formatRelativeTime(row.lastSeen)}</MonoText>
),
},
];
// ── Build KPI items ─────────────────────────────────────────────────────────
function buildKpiItems(
stats: {
totalCount: number;
failedCount: number;
p99LatencyMs: number;
prevTotalCount: number;
prevFailedCount: number;
prevP99LatencyMs: number;
} | undefined,
slaThresholdMs: number,
throughputSparkline: number[],
latencySparkline: number[],
errors: TopError[] | undefined,
windowSeconds: number,
): KpiItem[] {
const totalCount = stats?.totalCount ?? 0;
const failedCount = stats?.failedCount ?? 0;
const prevTotalCount = stats?.prevTotalCount ?? 0;
const prevFailedCount = stats?.prevFailedCount ?? 0;
const p99Ms = stats?.p99LatencyMs ?? 0;
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
// Throughput
const throughputTrend = trendIndicator(totalCount, prevTotalCount);
// Success Rate
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100;
const prevSuccessRate = prevTotalCount > 0 ? ((prevTotalCount - prevFailedCount) / prevTotalCount) * 100 : 100;
const successTrend = trendIndicator(successRate, prevSuccessRate);
// P99 Latency
const latencyTrend = trendIndicator(p99Ms, prevP99Ms);
// SLA Compliance — percentage of exchanges under threshold
// Approximate from p99: if p99 < threshold, ~99%+ are compliant
const slaCompliance = p99Ms <= slaThresholdMs ? 99.9 : Math.max(0, 100 - ((p99Ms - slaThresholdMs) / slaThresholdMs) * 10);
// Error Velocity — aggregate from top errors
const errorList = errors ?? [];
const totalVelocity = errorList.reduce((sum, e) => sum + e.velocity, 0);
const hasAccelerating = errorList.some((e) => e.trend === 'accelerating');
const allDecelerating = errorList.length > 0 && errorList.every((e) => e.trend === 'decelerating');
const velocityTrendLabel = hasAccelerating ? '\u25B2' : allDecelerating ? '\u25BC' : '\u2500\u2500';
const velocityVariant = hasAccelerating ? 'error' as const : allDecelerating ? 'success' as const : 'muted' as const;
return [
{
label: 'Throughput',
value: formatThroughput(totalCount, windowSeconds),
trend: {
label: throughputTrend.label,
variant: throughputTrend.direction === 'up' ? 'success' as const : throughputTrend.direction === 'down' ? 'error' as const : 'muted' as const,
},
sparkline: throughputSparkline,
borderColor: 'var(--amber)',
},
{
label: 'Success Rate',
value: `${successRate.toFixed(2)}%`,
trend: {
label: successTrend.label,
variant: successTrend.direction === 'up' ? 'success' as const : successTrend.direction === 'down' ? 'error' as const : 'muted' as const,
},
borderColor: successRate >= 99 ? 'var(--success)' : successRate >= 95 ? 'var(--warning)' : 'var(--error)',
},
{
label: 'P99 Latency',
value: `${Math.round(p99Ms)}ms`,
trend: {
label: latencyTrend.label,
variant: latencyTrend.direction === 'up' ? 'error' as const : latencyTrend.direction === 'down' ? 'success' as const : 'muted' as const,
},
sparkline: latencySparkline,
borderColor: p99Ms > slaThresholdMs ? 'var(--error)' : 'var(--success)',
},
{
label: 'SLA Compliance',
value: formatSlaCompliance(slaCompliance),
trend: {
label: slaCompliance >= 99 ? 'OK' : 'BREACH',
variant: slaCompliance >= 99 ? 'success' as const : 'error' as const,
},
subtitle: `Threshold: ${slaThresholdMs}ms`,
borderColor: slaCompliance >= 99 ? 'var(--success)' : slaCompliance >= 95 ? 'var(--warning)' : 'var(--error)',
},
{
label: 'Error Velocity',
value: `${totalVelocity.toFixed(1)}/min`,
trend: {
label: velocityTrendLabel,
variant: velocityVariant,
},
subtitle: `${errorList.length} error type${errorList.length !== 1 ? 's' : ''} tracked`,
borderColor: hasAccelerating ? 'var(--error)' : allDecelerating ? 'var(--success)' : 'var(--text-muted)',
},
];
}
// ── Component ───────────────────────────────────────────────────────────────
export default function DashboardL2() {
const { appId } = useParams<{ appId: string }>();
const navigate = useNavigate();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const windowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
// Data hooks
const { data: stats } = useExecutionStats(timeFrom, timeTo, undefined, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
const { data: timeseriesByRoute } = useTimeseriesByRoute(timeFrom, timeTo, appId);
const { data: errors } = useTopErrors(timeFrom, timeTo, appId);
const { data: punchcardData } = usePunchcard(appId);
const { data: appSettings } = useAppSettings(appId);
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
// Route performance table rows
const routeRows: RouteRow[] = useMemo(() =>
(metrics || []).map((m: RouteMetrics) => ({
id: m.routeId,
routeId: m.routeId,
exchangeCount: m.exchangeCount,
successRate: m.successRate,
avgDurationMs: m.avgDurationMs,
p99DurationMs: m.p99DurationMs,
slaCompliance: m.slaCompliance ?? -1,
sparkline: m.sparkline ?? [],
})),
[metrics],
);
// Treemap items: one per route, sized by exchange count, colored by SLA
const treemapItems: TreemapItem[] = useMemo(
() => routeRows.map(r => ({
id: r.routeId, label: r.routeId,
value: r.exchangeCount,
slaCompliance: r.slaCompliance >= 0 ? r.slaCompliance : 100,
})),
[routeRows],
);
// KPI sparklines from timeseries
const throughputSparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.totalCount),
[timeseries],
);
const latencySparkline = useMemo(() =>
(timeseries?.buckets || []).map((b) => b.p99DurationMs),
[timeseries],
);
const kpiItems = useMemo(() =>
buildKpiItems(stats, slaThresholdMs, throughputSparkline, latencySparkline, errors, windowSeconds),
[stats, slaThresholdMs, throughputSparkline, latencySparkline, errors, windowSeconds],
);
// Throughput by Route — stacked area chart series
const throughputByRouteSeries = useMemo(() => {
if (!timeseriesByRoute) return [];
return Object.entries(timeseriesByRoute).map(([routeId, data]) => ({
label: routeId,
data: (data.buckets || []).map((b, i) => ({
x: i as number,
y: b.totalCount,
})),
}));
}, [timeseriesByRoute]);
// Latency percentiles chart — P99 line from app-level timeseries
const latencyChartSeries = useMemo(() => {
const buckets = timeseries?.buckets || [];
return [
{
label: 'P99',
data: buckets.map((b, i) => ({
x: i as number,
y: b.p99DurationMs,
})),
},
{
label: 'Avg',
data: buckets.map((b, i) => ({
x: i as number,
y: b.avgDurationMs,
})),
},
];
}, [timeseries]);
// Error rows with stable identity
const errorRows = useMemo(() =>
(errors ?? []).map((e, i) => ({ ...e, id: `${e.errorType}-${e.routeId}-${i}` })),
[errors],
);
return (
<div className={styles.content}>
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* KPI Strip */}
<KpiStrip items={kpiItems} />
{/* Route Performance Table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Route Performance</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{routeRows.length} routes</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={ROUTE_COLUMNS}
data={routeRows}
sortable
onRowClick={(row) => navigate(`/dashboard/${appId}/${row.routeId}`)}
/>
</div>
{/* Charts: Throughput by Route + Latency Percentiles */}
{(timeseries?.buckets?.length ?? 0) > 0 && (
<div className={styles.chartGrid}>
<Card title="Throughput by Route">
<AreaChart
series={throughputByRouteSeries}
yLabel="msg/s"
height={200}
className={styles.chart}
/>
</Card>
<Card title="Latency Percentiles">
<LineChart
series={latencyChartSeries}
yLabel="ms"
threshold={{ value: slaThresholdMs, label: `SLA ${slaThresholdMs}ms` }}
height={200}
className={styles.chart}
/>
</Card>
</div>
)}
{/* Top 5 Errors — hidden when empty */}
{errorRows.length > 0 && (
<div className={styles.errorsSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Top Errors</span>
<span className={styles.tableMeta}>{errorRows.length} error types</span>
</div>
<DataTable
columns={ERROR_COLUMNS}
data={errorRows}
sortable
/>
</div>
)}
{/* Treemap + Punchcard heatmaps side by side */}
{treemapItems.length > 0 && (
<div className={styles.vizRow}>
<Card title="Route Volume vs SLA Compliance">
<Treemap
items={treemapItems}
onItemClick={(id) => navigate(`/dashboard/${appId}/${id}`)}
/>
</Card>
<Card title="7-Day Pattern">
<PunchcardHeatmap cells={punchcardData ?? []} />
</Card>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,433 @@
import { useMemo } from 'react';
import { useParams } from 'react-router';
import {
KpiStrip,
DataTable,
AreaChart,
LineChart,
Card,
MonoText,
Badge,
} from '@cameleer/design-system';
import type { KpiItem, Column } from '@cameleer/design-system';
import { useGlobalFilters } from '@cameleer/design-system';
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
import { useTopErrors, useAppSettings } from '../../api/queries/dashboard';
import type { TopError } from '../../api/queries/dashboard';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import { ProcessDiagram } from '../../components/ProcessDiagram';
import {
formatRelativeTime,
trendArrow,
formatThroughput,
formatSlaCompliance,
trendIndicator,
} from './dashboard-utils';
import styles from './DashboardTab.module.css';
// ── Row types ───────────────────────────────────────────────────────────────
interface ProcessorRow {
id: string;
processorId: string;
processorType: string;
totalCount: number;
avgDurationMs: number;
p99DurationMs: number;
errorRate: number;
pctTime: number;
}
interface ErrorRow extends TopError {
id: string;
}
// ── Processor table columns ─────────────────────────────────────────────────
const PROCESSOR_COLUMNS: Column<ProcessorRow>[] = [
{
key: 'processorId',
header: 'Processor ID',
sortable: true,
render: (_, row) => <MonoText size="sm">{row.processorId}</MonoText>,
},
{
key: 'processorType',
header: 'Type',
sortable: true,
render: (_, row) => <Badge label={row.processorType} color="auto" />,
},
{
key: 'totalCount',
header: 'Invocations',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.totalCount.toLocaleString()}</MonoText>
),
},
{
key: 'avgDurationMs',
header: 'Avg(ms)',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{Math.round(row.avgDurationMs)}</MonoText>
),
},
{
key: 'p99DurationMs',
header: 'P99(ms)',
sortable: true,
render: (_, row) => {
const cls = row.p99DurationMs > 300
? styles.rateBad
: row.p99DurationMs > 200
? styles.rateWarn
: styles.rateGood;
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}</MonoText>;
},
},
{
key: 'errorRate',
header: 'Error Rate(%)',
sortable: true,
render: (_, row) => {
const pct = row.errorRate * 100;
const cls = pct > 5 ? styles.rateBad : pct > 1 ? styles.rateWarn : styles.rateGood;
return <MonoText size="sm" className={cls}>{pct.toFixed(2)}%</MonoText>;
},
},
{
key: 'pctTime',
header: '% Time',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.pctTime.toFixed(1)}%</MonoText>
),
},
];
// ── Error table columns ─────────────────────────────────────────────────────
const ERROR_COLUMNS: Column<ErrorRow>[] = [
{
key: 'errorType',
header: 'Error Type',
sortable: true,
render: (_, row) => <MonoText size="sm">{row.errorType}</MonoText>,
},
{
key: 'processorId',
header: 'Processor',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.processorId ?? '\u2014'}</MonoText>
),
},
{
key: 'count',
header: 'Count',
sortable: true,
render: (_, row) => (
<MonoText size="sm">{row.count.toLocaleString()}</MonoText>
),
},
{
key: 'trend',
header: 'Velocity',
render: (_, row) => (
<span>{trendArrow(row.trend)} {row.trend}</span>
),
},
{
key: 'lastSeen',
header: 'Last Seen',
sortable: true,
render: (_, row) => (
<span>{formatRelativeTime(row.lastSeen)}</span>
),
},
];
// ── Build KPI items ─────────────────────────────────────────────────────────
function buildKpiItems(
stats: {
totalCount: number;
failedCount: number;
avgDurationMs: number;
p99LatencyMs: number;
activeCount: number;
prevTotalCount: number;
prevFailedCount: number;
prevP99LatencyMs: number;
} | undefined,
slaThresholdMs: number,
bottleneck: { processorId: string; avgMs: number; pct: number } | null,
throughputSparkline: number[],
windowSeconds: number,
): KpiItem[] {
const totalCount = stats?.totalCount ?? 0;
const failedCount = stats?.failedCount ?? 0;
const prevTotalCount = stats?.prevTotalCount ?? 0;
const p99Ms = stats?.p99LatencyMs ?? 0;
const avgMs = stats?.avgDurationMs ?? 0;
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100;
const slaCompliance = totalCount > 0
? ((totalCount - failedCount) / totalCount) * 100
: 100;
const throughputTrend = trendIndicator(totalCount, prevTotalCount);
return [
{
label: 'Throughput',
value: formatThroughput(totalCount, windowSeconds),
trend: {
label: throughputTrend.label,
variant: throughputTrend.direction === 'up' ? 'success' as const : throughputTrend.direction === 'down' ? 'error' as const : 'muted' as const,
},
subtitle: `${totalCount.toLocaleString()} total exchanges`,
sparkline: throughputSparkline,
borderColor: 'var(--amber)',
},
{
label: 'Success Rate',
value: `${successRate.toFixed(2)}%`,
trend: {
label: failedCount > 0 ? `${failedCount} failed` : 'No errors',
variant: successRate >= 99 ? 'success' as const : successRate >= 97 ? 'warning' as const : 'error' as const,
},
subtitle: `${totalCount - failedCount} succeeded / ${totalCount.toLocaleString()} total`,
borderColor: successRate >= 99 ? 'var(--success)' : 'var(--error)',
},
{
label: 'P99 Latency',
value: `${Math.round(p99Ms)}ms`,
trend: {
label: p99Ms > slaThresholdMs ? 'BREACH' : 'OK',
variant: p99Ms > slaThresholdMs ? 'error' as const : 'success' as const,
},
subtitle: `SLA threshold: ${slaThresholdMs}ms \u00B7 Avg: ${Math.round(avgMs)}ms`,
borderColor: p99Ms > slaThresholdMs ? 'var(--warning)' : 'var(--success)',
},
{
label: 'SLA Compliance',
value: formatSlaCompliance(slaCompliance),
trend: {
label: slaCompliance >= 99.9 ? 'Excellent' : slaCompliance >= 99 ? 'Good' : 'Degraded',
variant: slaCompliance >= 99 ? 'success' as const : slaCompliance >= 95 ? 'warning' as const : 'error' as const,
},
subtitle: `Target: 99.9%`,
borderColor: slaCompliance >= 99 ? 'var(--success)' : 'var(--warning)',
},
{
label: 'Bottleneck',
value: bottleneck ? `${Math.round(bottleneck.avgMs)}ms` : '\u2014',
trend: {
label: bottleneck ? `${bottleneck.pct.toFixed(1)}% of total` : '\u2014',
variant: bottleneck && bottleneck.pct > 50 ? 'error' as const : 'muted' as const,
},
subtitle: bottleneck
? `${bottleneck.processorId} \u00B7 ${Math.round(bottleneck.avgMs)}ms \u00B7 ${bottleneck.pct.toFixed(1)}% of total`
: 'No processor data',
borderColor: 'var(--running)',
},
];
}
// ── Component ───────────────────────────────────────────────────────────────
export default function DashboardL3() {
const { appId, routeId } = useParams<{ appId: string; routeId: string }>();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const windowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
// ── Data hooks ──────────────────────────────────────────────────────────
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
const { data: processorMetrics } = useProcessorMetrics(routeId ?? null, appId);
const { data: topErrors } = useTopErrors(timeFrom, timeTo, appId, routeId);
const { data: diagramLayout } = useDiagramByRoute(appId, routeId);
const { data: appSettings } = useAppSettings(appId);
const slaThresholdMs = appSettings?.slaThresholdMs ?? 300;
// ── Bottleneck (processor with highest avgDurationMs) ───────────────────
const bottleneck = useMemo(() => {
if (!processorMetrics?.length) return null;
const routeAvg = stats?.avgDurationMs ?? 0;
const sorted = [...processorMetrics].sort(
(a: any, b: any) => b.avgDurationMs - a.avgDurationMs,
);
const top = sorted[0];
const pct = routeAvg > 0 ? (top.avgDurationMs / routeAvg) * 100 : 0;
return { processorId: top.processorId, avgMs: top.avgDurationMs, pct };
}, [processorMetrics, stats]);
// ── Sparklines from timeseries ──────────────────────────────────────────
const throughputSparkline = useMemo(
() => (timeseries?.buckets || []).map((b: any) => b.totalCount),
[timeseries],
);
// ── KPI strip ───────────────────────────────────────────────────────────
const kpiItems = useMemo(
() => buildKpiItems(stats, slaThresholdMs, bottleneck, throughputSparkline, windowSeconds),
[stats, slaThresholdMs, bottleneck, throughputSparkline, windowSeconds],
);
// ── Chart series ────────────────────────────────────────────────────────
const throughputChartSeries = useMemo(() => [{
label: 'Throughput',
data: (timeseries?.buckets || []).map((b: any, i: number) => ({
x: i,
y: b.totalCount,
})),
}], [timeseries]);
const latencyChartSeries = useMemo(() => [{
label: 'P99',
data: (timeseries?.buckets || []).map((b: any, i: number) => ({
x: i,
y: b.p99DurationMs,
})),
}], [timeseries]);
const errorRateChartSeries = useMemo(() => [{
label: 'Error Rate',
data: (timeseries?.buckets || []).map((b: any, i: number) => ({
x: i,
y: b.totalCount > 0 ? (b.failedCount / b.totalCount) * 100 : 0,
})),
color: 'var(--error)',
}], [timeseries]);
// ── Processor table rows ────────────────────────────────────────────────
const processorRows: ProcessorRow[] = useMemo(() => {
if (!processorMetrics?.length) return [];
const routeAvg = stats?.avgDurationMs ?? 0;
return processorMetrics.map((m: any) => ({
id: m.processorId,
processorId: m.processorId,
processorType: m.processorType,
totalCount: m.totalCount,
avgDurationMs: m.avgDurationMs,
p99DurationMs: m.p99DurationMs,
errorRate: m.errorRate,
pctTime: routeAvg > 0 ? (m.avgDurationMs / routeAvg) * 100 : 0,
}));
}, [processorMetrics, stats]);
// ── Latency heatmap for ProcessDiagram ──────────────────────────────────
const latencyHeatmap = useMemo(() => {
if (!processorMetrics?.length) return new Map();
const totalAvg = processorMetrics.reduce(
(sum: number, m: any) => sum + m.avgDurationMs, 0,
);
const map = new Map<string, { avgDurationMs: number; p99DurationMs: number; pctOfRoute: number }>();
for (const m of processorMetrics) {
map.set(m.processorId, {
avgDurationMs: m.avgDurationMs,
p99DurationMs: m.p99DurationMs,
pctOfRoute: totalAvg > 0 ? (m.avgDurationMs / totalAvg) * 100 : 0,
});
}
return map;
}, [processorMetrics]);
// ── Error table rows ────────────────────────────────────────────────────
const errorRows: ErrorRow[] = useMemo(
() => (topErrors || []).map((e, i) => ({ ...e, id: `${e.errorType}-${i}` })),
[topErrors],
);
return (
<div className={styles.content}>
<div className={styles.refreshIndicator}>
<span className={styles.refreshDot} />
<span className={styles.refreshText}>Auto-refresh: 30s</span>
</div>
{/* KPI Strip */}
<KpiStrip items={kpiItems} />
{/* Charts — 3 in a row */}
{(timeseries?.buckets?.length ?? 0) > 0 && (
<div className={styles.chartRow}>
<Card title="Throughput">
<AreaChart
series={throughputChartSeries}
yLabel="msg/s"
height={200}
/>
</Card>
<Card title="Latency Percentiles">
<LineChart
series={latencyChartSeries}
yLabel="ms"
threshold={{ value: slaThresholdMs, label: `SLA ${slaThresholdMs}ms` }}
height={200}
/>
</Card>
<Card title="Error Rate">
<AreaChart
series={errorRateChartSeries}
yLabel="%"
height={200}
/>
</Card>
</div>
)}
{/* Process Diagram with Latency Heatmap */}
{appId && routeId && (
<div className={styles.diagramSection}>
<ProcessDiagram
application={appId}
routeId={routeId}
diagramLayout={diagramLayout}
latencyHeatmap={latencyHeatmap}
/>
</div>
)}
{/* Processor Metrics Table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Processor Metrics</span>
<div>
<span className={styles.tableMeta}>
{processorRows.length} processor{processorRows.length !== 1 ? 's' : ''}
</span>
</div>
</div>
<DataTable
columns={PROCESSOR_COLUMNS}
data={processorRows}
sortable
/>
</div>
{/* Top 5 Errors — hidden if empty */}
{errorRows.length > 0 && (
<div className={styles.errorsSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Top 5 Errors</span>
<Badge label={`${errorRows.length}`} color="error" />
</div>
<DataTable
columns={ERROR_COLUMNS}
data={errorRows}
sortable
/>
</div>
)}
</div>
);
}

View File

@@ -2,16 +2,20 @@ import { useParams } from 'react-router';
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import { Spinner } from '@cameleer/design-system'; import { Spinner } from '@cameleer/design-system';
const RoutesMetrics = lazy(() => import('../Routes/RoutesMetrics')); const DashboardL1 = lazy(() => import('./DashboardL1'));
const RouteDetail = lazy(() => import('../Routes/RouteDetail')); const DashboardL2 = lazy(() => import('./DashboardL2'));
const DashboardL3 = lazy(() => import('./DashboardL3'));
const Fallback = <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>; const Fallback = <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
export default function DashboardPage() { export default function DashboardPage() {
const { routeId } = useParams<{ appId?: string; routeId?: string }>(); const { appId, routeId } = useParams<{ appId?: string; routeId?: string }>();
if (routeId) { if (routeId && appId) {
return <Suspense fallback={Fallback}><RouteDetail /></Suspense>; return <Suspense fallback={Fallback}><DashboardL3 /></Suspense>;
} }
return <Suspense fallback={Fallback}><RoutesMetrics /></Suspense>; if (appId) {
return <Suspense fallback={Fallback}><DashboardL2 /></Suspense>;
}
return <Suspense fallback={Fallback}><DashboardL1 /></Suspense>;
} }

View File

@@ -0,0 +1,181 @@
.content {
display: flex;
flex-direction: column;
gap: 20px;
flex: 1;
min-height: 0;
overflow-y: auto;
padding-bottom: 20px;
}
.refreshIndicator {
display: flex;
align-items: center;
gap: 6px;
justify-content: flex-end;
}
.refreshDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 4px rgba(61, 124, 71, 0.5);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.refreshText {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Tables */
.tableSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.tableHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-subtle);
}
.tableTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.tableMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Charts */
.chartGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.chartRow {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
/* Cells */
.monoCell {
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-primary);
}
.appNameCell {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
font-family: var(--font-mono);
cursor: pointer;
}
.appNameCell:hover {
text-decoration: underline;
}
/* Rate coloring */
.rateGood { color: var(--success); }
.rateWarn { color: var(--warning); }
.rateBad { color: var(--error); }
.rateNeutral { color: var(--text-secondary); }
/* Diagram container */
.diagramSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
height: 280px;
}
/* Table right side (meta + badge) */
.tableRight {
display: flex;
align-items: center;
gap: 10px;
}
/* Chart fill */
.chart {
width: 100%;
}
/* Visualization row: treemap left (wider) + punchcards right (stacked) */
.vizRow {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 16px;
}
.punchcardStack {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Toggle button row */
.toggleRow {
display: flex;
gap: 2px;
padding: 0 12px 4px;
}
.toggleBtn {
padding: 3px 10px;
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-muted);
background: transparent;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
}
.toggleBtn:hover {
color: var(--text-primary);
border-color: var(--border);
}
.toggleActive {
color: var(--text-primary);
background: var(--bg-inset);
border-color: var(--border);
font-weight: 600;
}
/* Errors section */
.errorsSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}

View File

@@ -0,0 +1,134 @@
import { useMemo, useState } from 'react';
import styles from './DashboardTab.module.css';
export interface PunchcardCell {
weekday: number;
hour: number;
totalCount: number;
failedCount: number;
}
interface PunchcardHeatmapProps {
cells: PunchcardCell[];
}
type Mode = 'transactions' | 'errors';
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// Remap: backend DOW 0=Sun..6=Sat → display 0=Mon..6=Sun
function toDisplayDay(dow: number): number {
return dow === 0 ? 6 : dow - 1;
}
function transactionColor(ratio: number): string {
if (ratio === 0) return 'var(--bg-inset)';
// Blue scale matching --running hue
const alpha = 0.15 + ratio * 0.75;
return `hsla(220, 65%, 50%, ${alpha.toFixed(2)})`;
}
function errorColor(ratio: number): string {
if (ratio === 0) return 'var(--bg-inset)';
const alpha = 0.15 + ratio * 0.75;
return `hsla(0, 65%, 50%, ${alpha.toFixed(2)})`;
}
const CELL = 11;
const GAP = 2;
const LABEL_W = 28;
const LABEL_H = 14;
export function PunchcardHeatmap({ cells }: PunchcardHeatmapProps) {
const [mode, setMode] = useState<Mode>('transactions');
const { grid, maxVal } = useMemo(() => {
const cellMap = new Map<string, PunchcardCell>();
for (const c of cells) cellMap.set(`${toDisplayDay(c.weekday)}-${c.hour}`, c);
let max = 0;
const g: { day: number; hour: number; value: number }[] = [];
for (let d = 0; d < 7; d++) {
for (let h = 0; h < 24; h++) {
const cell = cellMap.get(`${d}-${h}`);
const val = cell ? (mode === 'errors' ? cell.failedCount : cell.totalCount) : 0;
if (val > max) max = val;
g.push({ day: d, hour: h, value: val });
}
}
return { grid: g, maxVal: Math.max(max, 1) };
}, [cells, mode]);
const cols = 24;
const rows = 7;
const svgW = LABEL_W + cols * (CELL + GAP);
const svgH = LABEL_H + rows * (CELL + GAP);
return (
<div>
<div className={styles.toggleRow}>
<button
className={`${styles.toggleBtn} ${mode === 'transactions' ? styles.toggleActive : ''}`}
onClick={() => setMode('transactions')}
>
Transactions
</button>
<button
className={`${styles.toggleBtn} ${mode === 'errors' ? styles.toggleActive : ''}`}
onClick={() => setMode('errors')}
>
Errors
</button>
</div>
<svg viewBox={`0 0 ${svgW} ${svgH}`} style={{ width: '100%', height: 'auto', display: 'block' }}>
{/* Hour labels (top, every 4 hours) */}
{[0, 4, 8, 12, 16, 20].map(h => (
<text
key={h}
x={LABEL_W + h * (CELL + GAP) + CELL / 2}
y={10}
textAnchor="middle"
fill="var(--text-faint)"
fontSize={7}
fontFamily="var(--font-mono)"
>
{String(h).padStart(2, '0')}
</text>
))}
{/* Day labels (left) */}
{DAYS.map((day, i) => (
<text
key={day}
x={LABEL_W - 4}
y={LABEL_H + i * (CELL + GAP) + CELL / 2 + 3}
textAnchor="end"
fill="var(--text-faint)"
fontSize={7}
fontFamily="var(--font-mono)"
>
{day}
</text>
))}
{/* Cells */}
{grid.map(({ day, hour, value }) => {
const ratio = value / maxVal;
const fill = mode === 'errors' ? errorColor(ratio) : transactionColor(ratio);
return (
<rect
key={`${day}-${hour}`}
x={LABEL_W + hour * (CELL + GAP)}
y={LABEL_H + day * (CELL + GAP)}
width={CELL}
height={CELL}
rx={2}
fill={fill}
>
<title>{`${DAYS[day]} ${String(hour).padStart(2, '0')}:00 — ${value.toLocaleString()} ${mode}`}</title>
</rect>
);
})}
</svg>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { useCallback } from 'react';
import { Treemap as RechartsTreemap, ResponsiveContainer, Tooltip } from 'recharts';
import { rechartsTheme } from '@cameleer/design-system';
export interface TreemapItem {
id: string;
label: string;
value: number;
/** 0-100, drives green→yellow→red color */
slaCompliance: number;
}
interface TreemapProps {
items: TreemapItem[];
onItemClick?: (id: string) => void;
}
function slaColor(pct: number): string {
if (pct >= 99) return 'hsl(120, 45%, 85%)';
if (pct >= 97) return 'hsl(90, 45%, 85%)';
if (pct >= 95) return 'hsl(60, 50%, 85%)';
if (pct >= 90) return 'hsl(30, 55%, 85%)';
return 'hsl(0, 55%, 85%)';
}
function slaBorderColor(pct: number): string {
if (pct >= 99) return 'hsl(120, 40%, 45%)';
if (pct >= 97) return 'hsl(90, 40%, 50%)';
if (pct >= 95) return 'hsl(60, 45%, 45%)';
if (pct >= 90) return 'hsl(30, 50%, 45%)';
return 'hsl(0, 50%, 45%)';
}
function slaTextColor(pct: number): string {
if (pct >= 95) return 'hsl(120, 20%, 25%)';
return 'hsl(0, 40%, 30%)';
}
/** Custom cell renderer for the Recharts Treemap */
function CustomCell(props: Record<string, unknown>) {
const { x, y, width, height, name, slaCompliance, onItemClick } = props as {
x: number; y: number; width: number; height: number;
name: string; slaCompliance: number; onItemClick?: (id: string) => void;
};
const w = width ?? 0;
const h = height ?? 0;
if (w < 2 || h < 2) return null;
const showLabel = w > 40 && h > 20;
const showSla = w > 60 && h > 34;
const sla = slaCompliance ?? 100;
return (
<g
onClick={() => onItemClick?.(name)}
style={{ cursor: onItemClick ? 'pointer' : 'default' }}
>
<rect
x={x + 1} y={y + 1} width={w - 2} height={h - 2}
rx={3}
fill={slaColor(sla)}
stroke={slaBorderColor(sla)}
strokeWidth={1}
/>
{showLabel && (
<text
x={x + 5} y={y + 15}
fill={slaTextColor(sla)}
fontSize={11} fontWeight={600}
style={{ pointerEvents: 'none' }}
>
{name.length > w / 6.5 ? name.slice(0, Math.floor(w / 6.5)) + '\u2026' : name}
</text>
)}
{showSla && (
<text
x={x + 5} y={y + 28}
fill={slaTextColor(sla)}
fontSize={10} fontWeight={400}
style={{ pointerEvents: 'none' }}
>
{sla.toFixed(1)}% SLA
</text>
)}
</g>
);
}
export function Treemap({ items, onItemClick }: TreemapProps) {
// Recharts Treemap expects { name, size, ...extra }
const data = items.map(i => ({
name: i.label,
size: i.value,
slaCompliance: i.slaCompliance,
}));
const renderContent = useCallback(
(props: Record<string, unknown>) => <CustomCell {...props} onItemClick={onItemClick} />,
[onItemClick],
);
if (items.length === 0) {
return <div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '2rem' }}>No data</div>;
}
return (
<ResponsiveContainer width="100%" height={300}>
<RechartsTreemap
data={data}
dataKey="size"
nameKey="name"
stroke="none"
content={renderContent}
isAnimationActive={false}
>
<Tooltip
contentStyle={rechartsTheme.tooltip.contentStyle}
labelStyle={rechartsTheme.tooltip.labelStyle}
itemStyle={rechartsTheme.tooltip.itemStyle}
formatter={(value: number, _name: string, entry: { payload?: { slaCompliance?: number } }) => {
const sla = entry.payload?.slaCompliance ?? 0;
return [`${value.toLocaleString()} exchanges · ${sla.toFixed(1)}% SLA`];
}}
/>
</RechartsTreemap>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,70 @@
import type { AppSettings } from '../../api/queries/dashboard';
export type HealthStatus = 'success' | 'warning' | 'error';
const DEFAULT_SETTINGS: Pick<AppSettings, 'healthErrorWarn' | 'healthErrorCrit' | 'healthSlaWarn' | 'healthSlaCrit'> = {
healthErrorWarn: 1.0,
healthErrorCrit: 5.0,
healthSlaWarn: 99.0,
healthSlaCrit: 95.0,
};
export function computeHealthDot(
errorRate: number,
slaCompliance: number,
settings?: Partial<AppSettings> | null,
): HealthStatus {
const s = { ...DEFAULT_SETTINGS, ...settings };
const errorPct = errorRate * 100;
if (errorPct > s.healthErrorCrit || slaCompliance < s.healthSlaCrit) return 'error';
if (errorPct > s.healthErrorWarn || slaCompliance < s.healthSlaWarn) return 'warning';
return 'success';
}
export function formatThroughput(count: number, windowSeconds: number): string {
if (windowSeconds <= 0) return '0/s';
const tps = count / windowSeconds;
if (tps >= 1000) return `${(tps / 1000).toFixed(1)}k/s`;
if (tps >= 1) return `${tps.toFixed(0)}/s`;
return `${tps.toFixed(2)}/s`;
}
export function formatSlaCompliance(pct: number): string {
if (pct < 0) return '—';
return `${pct.toFixed(1)}%`;
}
export function trendIndicator(current: number, previous: number): { label: string; direction: 'up' | 'down' | 'flat' } {
if (previous === 0) return { label: '—', direction: 'flat' };
const delta = ((current - previous) / previous) * 100;
if (Math.abs(delta) < 0.5) return { label: '—', direction: 'flat' };
return {
label: `${delta > 0 ? '+' : ''}${delta.toFixed(1)}%`,
direction: delta > 0 ? 'up' : 'down',
};
}
export function trendArrow(trend: 'accelerating' | 'stable' | 'decelerating'): string {
switch (trend) {
case 'accelerating': return '\u25B2';
case 'decelerating': return '\u25BC';
default: return '\u2500\u2500';
}
}
export function formatDuration(ms: number): string {
if (ms < 1) return '<1ms';
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
export function formatRelativeTime(isoString: string): string {
const diff = Date.now() - new Date(isoString).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hr ago`;
return `${Math.floor(hours / 24)} d ago`;
}

View File

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

View File

@@ -1,11 +1,13 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate } from 'react-router'; 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 { StatusDot, MonoText, Badge } from '@cameleer/design-system';
import { useCorrelationChain } from '../../api/queries/correlation'; import { useCorrelationChain } from '../../api/queries/correlation';
import { useAgents } from '../../api/queries/agents'; import { useAgents } from '../../api/queries/agents';
import { useAuthStore } from '../../auth/auth-store';
import type { ExecutionDetail } from '../../components/ExecutionDiagram/types'; import type { ExecutionDetail } from '../../components/ExecutionDiagram/types';
import { attributeBadgeColor } from '../../utils/attribute-color'; import { attributeBadgeColor } from '../../utils/attribute-color';
import { RouteControlBar } from './RouteControlBar';
import styles from './ExchangeHeader.module.css'; import styles from './ExchangeHeader.module.css';
interface ExchangeHeaderProps { interface ExchangeHeaderProps {
@@ -47,14 +49,22 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }:
const showChain = chain && chain.length > 1; const showChain = chain && chain.length > 1;
const attrs = Object.entries(detail.attributes ?? {}); 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 { data: agents } = useAgents(undefined, detail.applicationName);
const agentState = useMemo(() => { const { agentState, hasRouteControl, hasReplay } = useMemo(() => {
if (!agents || !detail.agentId) return undefined; if (!agents) return { agentState: undefined, hasRouteControl: false, hasReplay: false };
const agent = (agents as any[]).find((a: any) => a.id === detail.agentId); const agentList = agents as any[];
return agent?.state?.toLowerCase() as 'live' | 'stale' | 'dead' | undefined; 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]); }, [agents, detail.agentId]);
const roles = useAuthStore((s) => s.roles);
const canControl = roles.some(r => r === 'OPERATOR' || r === 'ADMIN');
return ( return (
<div className={styles.header}> <div className={styles.header}>
{/* Exchange info — always shown */} {/* Exchange info — always shown */}
@@ -92,12 +102,27 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }:
<span className={styles.duration}>{formatDuration(detail.durationMs)}</span> <span className={styles.duration}>{formatDuration(detail.durationMs)}</span>
</div> </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 */} {/* Correlation chain */}
<div className={styles.chain}> <div className={styles.chain}>
<span className={styles.chainLabel}>Correlated</span> <span className={styles.chainLabel}>Correlated</span>
{showChain ? chain.map((ce: any, i: number) => { {showChain ? chain.map((ce: any, i: number) => {
const isCurrent = ce.executionId === detail.executionId; const isCurrent = ce.executionId === detail.executionId;
const variant = statusVariant(ce.status); const variant = statusVariant(ce.status);
const isReplay = !!ce.isReplay;
const statusCls = const statusCls =
variant === 'success' ? styles.chainNodeSuccess variant === 'success' ? styles.chainNodeSuccess
: variant === 'error' ? styles.chainNodeError : variant === 'error' ? styles.chainNodeError
@@ -113,9 +138,10 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }:
onCorrelatedSelect(ce.executionId, ce.applicationName ?? detail.applicationName, ce.routeId); 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} /> <StatusDot variant={variant} />
{isReplay && <RotateCcw size={9} className={styles.replayIcon} />}
<span className={styles.chainRoute}>{ce.routeId}</span> <span className={styles.chainRoute}>{ce.routeId}</span>
<span className={styles.chainDuration}>{formatDuration(ce.durationMs)}</span> <span className={styles.chainDuration}>{formatDuration(ce.durationMs)}</span>
</button> </button>

View File

@@ -20,17 +20,35 @@ import type { SelectedExchange } from '../Dashboard/Dashboard';
export default function ExchangesPage() { export default function ExchangesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); 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) // Restore selection from browser history state (enables Back/Forward)
const stateSelected = (location.state as any)?.selectedExchange as SelectedExchange | undefined; 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(() => { useEffect(() => {
const restored = (location.state as any)?.selectedExchange as SelectedExchange | undefined; const restored = (location.state as any)?.selectedExchange as SelectedExchange | undefined;
setSelectedInternal(restored ?? null); if (restored) {
}, [location.state]); 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 [splitPercent, setSplitPercent] = useState(50);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -52,10 +70,15 @@ export default function ExchangesPage() {
}); });
}, [navigate, location.pathname, location.search, location.state]); }, [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(() => { const handleClearSelection = useCallback(() => {
setSelectedInternal(null); 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) => { const handleSplitterDown = useCallback((e: React.PointerEvent) => {
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
@@ -152,13 +175,12 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
return map; return map;
}, [catalog]); }, [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 { data: appConfig } = useApplicationConfig(appId);
const tracedMap = useTracingStore((s) => s.tracedProcessors[appId]);
const nodeConfigs = useMemo(() => { const nodeConfigs = useMemo(() => {
const map = new Map<string, NodeConfig>(); const map = new Map<string, NodeConfig>();
if (tracedMap) { if (appConfig?.tracedProcessors) {
for (const pid of Object.keys(tracedMap)) { for (const pid of Object.keys(appConfig.tracedProcessors)) {
map.set(pid, { traceEnabled: true }); map.set(pid, { traceEnabled: true });
} }
} }
@@ -171,7 +193,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
} }
} }
return map; return map;
}, [tracedMap, appConfig]); }, [appConfig]);
// Processor options for tap modal dropdown // Processor options for tap modal dropdown
const processorOptions = useMemo(() => { 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>
);
}