26 Commits

Author SHA1 Message Date
hsiegeln
dafd7adb00 chore: upgrade @cameleer/design-system to v0.0.3
Some checks failed
CI / docker (push) Has been cancelled
CI / build (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / cleanup-branch (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:42:38 +01:00
hsiegeln
44eecfa5cd deleted obsolote files
All checks were successful
CI / build (push) Successful in 1m21s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 43s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped
2026-03-24 10:24:13 +01:00
hsiegeln
ff76751629 refactor: rename agent group→application across entire codebase
All checks were successful
CI / build (push) Successful in 1m22s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 52s
CI / deploy (push) Successful in 39s
CI / deploy-feature (push) Has been skipped
Complete the group→application terminology rename in the agent
registry subsystem:

- AgentInfo: field group → application, all wither methods updated
- AgentRegistryService: findByGroup → findByApplication
- AgentInstanceResponse: field group → application (API response)
- AgentRegistrationRequest: field group → application (API request)
- JwtServiceImpl: parameter names group → application (JWT claim
  string "group" preserved for token backward compatibility)
- All controllers, lifecycle monitor, command controller updated
- Integration tests: JSON request bodies "group" → "application"
- Frontend: schema.d.ts, openapi.json, agent queries, AgentHealth

RBAC group references (groups table, GroupAdminController, etc.)
are NOT affected — they are a separate domain concept.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:48:12 +01:00
hsiegeln
413839452c fix: use statsForApp when application is set without routeId
All checks were successful
CI / build (push) Successful in 1m21s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 44s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped
The stats endpoint was calling statsForRoute(null, agentIds) when
only application was set — this filtered by route_id=null, returning
zero results. Now correctly routes to statsForApp/timeseriesForApp
which queries the stats_1m_app continuous aggregate by application_name.

Also reverts the group parameter alias workaround — the deployed
backend correctly accepts 'application'.

Three code paths now:
- No filters → stats_1m_all (global)
- application only → stats_1m_app (per-app)
- routeId (±application) → stats_1m_route (per-route)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:28:05 +01:00
hsiegeln
c33e899be7 fix: accept both 'application' and 'group' query params in search API
All checks were successful
CI / build (push) Successful in 1m22s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 50s
CI / deploy (push) Successful in 37s
CI / deploy-feature (push) Has been skipped
The backend was renamed from group→application but Docker build cache
may serve old code. Accept 'group' as a fallback alias so the UI works
with both old and new backends. Applies to GET /search/executions,
/search/stats, and /search/stats/timeseries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:25:05 +01:00
hsiegeln
180514a039 fix: align RBAC user management styling with mock design
All checks were successful
CI / build (push) Successful in 1m19s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 52s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped
- Split pane: card layout with border, border-radius, box-shadow
  matching mock's bordered panel look
- List pane: bg-surface background, padded header with border-bottom
- Entity items: border-bottom separators instead of gap spacing,
  flex-start alignment for multi-line content
- Detail pane: bg-surface background, 20px padding, right border-radius
- User meta line: show email + group path (like mock's "email · group")
- Create form: raised background with bottom border

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:21:11 +01:00
hsiegeln
60fced56ed fix: format Documents column with user locale in OpenSearch admin
All checks were successful
CI / build (push) Successful in 1m25s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 1m0s
CI / deploy (push) Successful in 39s
CI / deploy-feature (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:17:06 +01:00
hsiegeln
515c942623 feat: add admin tab navigation between subpages
All checks were successful
CI / build (push) Successful in 1m19s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 52s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped
Add AdminLayout wrapper with Tabs component for navigating between
admin sections: User Management, Audit Log, OIDC, Database, OpenSearch.

Nest all /admin/* routes under AdminLayout using React Router's
Outlet pattern so the tab bar persists across admin page navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:17:33 +01:00
hsiegeln
3ccd4b6548 fix: self-host fonts instead of loading from Google Fonts CDN
All checks were successful
CI / build (push) Successful in 1m23s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 56s
CI / deploy (push) Successful in 39s
CI / deploy-feature (push) Has been skipped
Loading fonts from fonts.googleapis.com sends user IP addresses to
Google on every page load — a GDPR violation. Self-host DM Sans and
JetBrains Mono as woff2 files bundled with the UI.

- Download DM Sans (400/500/600/700 + 400 italic) woff2 files
- Download JetBrains Mono (400/500/600) woff2 files
- Replace @import url(googleapis) with local @font-face declarations
- Both fonts are OFL-licensed (free to self-host)
- Total size: ~135KB for all 8 font files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:06:59 +01:00
hsiegeln
dad608e3a2 fix: display timestamps in user's local timezone, not UTC
Some checks failed
CI / build (push) Successful in 1m17s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 51s
CI / deploy-feature (push) Has been cancelled
CI / deploy (push) Has been cancelled
Two places in Dashboard used toISOString() for display, which always
renders UTC. Changed to toLocaleString() for the user's local timezone.

- Exchanges table "Started" column
- Detail panel "Timestamp" field

API query parameters correctly continue using toISOString() (UTC).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:00:44 +01:00
hsiegeln
7479dd6daf fix: convert Instant to Timestamp for JDBC agent metrics query
Some checks failed
CI / docker (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / cleanup-branch (push) Has been cancelled
CI / build (push) Has been cancelled
PostgreSQL JDBC driver can't infer SQL type for java.time.Instant.
Convert from/to parameters to java.sql.Timestamp before binding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:59:22 +01:00
hsiegeln
e4dff0cad1 fix: align RoutesMetrics with mock — chart titles, Invalid Date bug
All checks were successful
CI / build (push) Successful in 1m20s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 50s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped
- Fix Invalid Date in Errors bar chart (guard against null timestamps)
- Table header: "Route Metrics" → "Per-Route Performance"
- Chart titles: add units — "Throughput (msg/s)", "Latency (ms)",
  "Errors by Route", "Message Volume (msg/min)"
- Add yLabel to charts for axis labels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:55:29 +01:00
hsiegeln
717367252c fix: align AgentInstance page with mock design
All checks were successful
CI / build (push) Successful in 1m13s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 49s
CI / deploy (push) Successful in 40s
CI / deploy-feature (push) Has been skipped
- Chart headers: add current value meta text (CPU %, memory MB, TPS,
  error rate, thread count) matching mock layout
- Bottom section: 2-column grid with log placeholder (left) and
  timeline events (right) matching mock layout
- Timeline header: show "Timeline" + event count like mock
- Remove duplicate EmptyState placeholder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:51:44 +01:00
hsiegeln
a06808a2a2 fix: align AgentHealth page with mock design
Some checks failed
CI / build (push) Successful in 1m18s
CI / cleanup-branch (push) Has been skipped
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / docker (push) Has been cancelled
- DetailPanel: switch from tabs to flat children layout (fixes stale
  tab state bug), add position:fixed override, key on agent id
- Stat strip: colored status breakdown (live/stale/dead), msg/s detail
  on TPS, "requires attention" on dead count
- Scope trail: simplified to "X/Y live" label
- Event card header: rename "Event Log" to "Timeline" with count badge
- Remove unused Breadcrumb, scopeItems, groupHealth

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:50:16 +01:00
hsiegeln
6b750df1c4 fix: remove hardcoded locales from UI formatting
All checks were successful
CI / build (push) Successful in 1m21s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 52s
CI / deploy (push) Successful in 37s
CI / deploy-feature (push) Has been skipped
Use browser default locale instead of hardcoded 'en-US' and 'en-GB'
for number and time formatting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:44:16 +01:00
hsiegeln
ea56bcf2d7 fix: split Flyway migration — DDL in V1, policies in V2
All checks were successful
CI / build (push) Successful in 1m20s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 43s
CI / deploy (push) Successful in 1m16s
CI / deploy-feature (push) Has been skipped
TimescaleDB add_continuous_aggregate_policy and add_compression_policy
cannot run inside a transaction block. Move all policy calls to V2
with flyway:executeInTransaction=false directive.

Also fix stats_1m_processor_detail: add WITH NO DATA and
materialized_only = false.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:34:35 +01:00
hsiegeln
826466aa55 fix: cast diagram layout response type to fix TS build error
Some checks failed
CI / build (push) Successful in 1m13s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 53s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Failing after 1m16s
The render endpoint returns a union type (SVG string | JSON object).
Cast to DiagramLayout interface so .nodes is accessible. Also rename
useDiagramByRoute parameter from group to application.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:25:36 +01:00
hsiegeln
6a5dba4eba refactor: rename group_name→application_name in DB, OpenSearch, SQL
Some checks failed
CI / build (push) Failing after 41s
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
Consolidate V1-V7 Flyway migrations into single V1__init.sql with
all columns renamed from group_name to application_name. Requires
fresh database (wipe flyway_schema_history, all data).

- DB columns: executions.group_name → application_name,
  processor_executions.group_name → application_name
- Continuous aggregates: all views updated to use application_name
- OpenSearch field: group_name → application_name in index/query
- All Java SQL strings updated to match new column names
- Delete V2-V7 migration files (folded into V1)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:24:19 +01:00
hsiegeln
8ad0016a8e refactor: rename group/groupName to application/applicationName
Some checks failed
CI / build (push) Failing after 40s
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
The execution-related "group" concept actually represents the
application name. Rename all Java fields, API parameters, and frontend
types from groupName→applicationName and group→application for clarity.

- Java records: ExecutionSummary, ExecutionDetail, ExecutionDocument,
  ExecutionRecord, ProcessorRecord
- API params: SearchRequest.group→application, SearchController
  @RequestParam group→application
- Services: IngestionService, DetailService, SearchIndexer, StatsStore
- Frontend: schema.d.ts, Dashboard, ExchangeDetail, RouteDetail,
  executions query hooks

Database column names (group_name) and OpenSearch field names are
unchanged — only the API-facing Java/TS field names are renamed.

RBAC group references (groups table, GroupRepository, GroupsTab) are
a separate domain concept and are NOT affected by this change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:21:38 +01:00
hsiegeln
3c226de62f fix: use diagramContentHash for Route Flow instead of groupName
Some checks failed
CI / build (push) Failing after 51s
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
The deployed backend doesn't return groupName on ExecutionDetail or
ExecutionSummary (Docker build cache issue). Switch diagram lookup to
use diagramContentHash which is always available in the detail response.

- Dashboard: useDiagramLayout(detail.diagramContentHash) instead of
  useDiagramByRoute(groupName, routeId)
- ExchangeDetail: same change

Route Flow now renders correctly in both the slide-in panel and the
full exchange detail page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:13:01 +01:00
hsiegeln
c8c62a98bb fix: add groupName to ExecutionSummary in schema.d.ts
Some checks failed
CI / build (push) Successful in 1m12s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 1m10s
CI / deploy (push) Failing after 2m19s
CI / deploy-feature (push) Has been skipped
The Java record was updated but the OpenAPI schema was not regenerated,
causing a TypeScript build error in CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:03:45 +01:00
hsiegeln
2ae2871822 fix: add groupName to ExecutionDetail, rewrite ExchangeDetail to match mock
Some checks failed
CI / build (push) Failing after 40s
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
- Add groupName field to ExecutionDetail record and DetailService
- Dashboard: fix TDZ error (rows referenced before definition), add
  selectedRow fallback for diagram groupName lookup
- ExchangeDetail: rewrite to match mock layout — auto-select first
  processor, Message IN/OUT split panels with header key-value rows,
  error panel for failed processors, Timeline/Flow toggle buttons
- Track diagram-mapping utility (was untracked, caused CI build failure)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:02:14 +01:00
hsiegeln
a950feaef1 fix: Dashboard DetailPanel uses flat scrollable layout matching mock
Some checks failed
CI / build (push) Failing after 41s
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
Changed from tabs-based to children-based DetailPanel layout:
- Flat scrollable sections: Open Details → Overview → Errors → Route Flow → Processor Timeline
- Title shows "route — exchangeId" matching mock pattern
- Removed unused state (detailTab, processorIdx)
- Added panelSectionMeta CSS for duration display in timeline header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:51:23 +01:00
hsiegeln
695969d759 fix: DetailPanel slide-in now visible — fixed empty content bug and positioning
Some checks failed
CI / build (push) Failing after 39s
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
- Only render DetailPanel when detail data is loaded (key={selectedId} forces remount
  so internal activeTab state resets correctly)
- Override DetailPanel CSS with position:fixed to overlay on right side
  (AppShell layout doesn't support detail prop from child pages)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:47:43 +01:00
hsiegeln
a72b0954db fix: add groupName to ExecutionSummary, locale format stat values, inspect column, fix duplicate keys
Some checks failed
CI / build (push) Failing after 40s
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
- Added groupName field to ExecutionSummary Java record and OpenSearch mapper
- Dashboard stat cards use locale-formatted numbers (en-US)
- Added inspect column (↗) linking directly to exchange detail page
- Fixed duplicate React key warning from two columns sharing executionId key

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:41:46 +01:00
hsiegeln
4572230c9c fix: align all pages with design system mocks — stat cards, tables, detail panels
Some checks failed
CI / build (push) Failing after 40s
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
Dashboard: correct stat card labels (Exchanges/Success Rate/Errors/Throughput/Latency p99),
add detail text, trends, sparklines on all cards, Agent column, LIVE badge,
expanded detail panel with Agent/Correlation/Timestamp, "Open full details" link.

Agent Health: per-group meta (TPS/routes) in GroupCard header, proper HTML table
with column headers for instance list.

Agent Instance: stat card detail props (heap info, start date), scope trail with
inline status/version/routes badges.

Routes: 5th In-Flight stat card, enriched stat card props (detail/trend/sparkline),
SLA threshold line on latency chart.

Exchange Detail: Agent stat box in header.

Also: vite proxy CORS fix, cross-env dev scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:28:56 +01:00
98 changed files with 22624 additions and 5232 deletions

View File

@@ -51,7 +51,7 @@ public class AgentLifecycleMonitor {
if (before != null && before != agent.state()) { if (before != null && before != agent.state()) {
String eventType = mapTransitionEvent(before, agent.state()); String eventType = mapTransitionEvent(before, agent.state());
if (eventType != null) { if (eventType != null) {
agentEventService.recordEvent(agent.id(), agent.group(), eventType, agentEventService.recordEvent(agent.id(), agent.application(), eventType,
agent.name() + " " + before + " -> " + agent.state()); agent.name() + " " + before + " -> " + agent.state());
} }
} }

View File

@@ -92,7 +92,7 @@ public class AgentCommandController {
List<AgentInfo> agents = registryService.findAll().stream() List<AgentInfo> agents = registryService.findAll().stream()
.filter(a -> a.state() == AgentState.LIVE) .filter(a -> a.state() == AgentState.LIVE)
.filter(a -> group.equals(a.group())) .filter(a -> group.equals(a.application()))
.toList(); .toList();
List<String> commandIds = new ArrayList<>(); List<String> commandIds = new ArrayList<>();

View File

@@ -5,6 +5,7 @@ import com.cameleer3.server.app.dto.MetricBucket;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.*; import java.util.*;
@@ -58,7 +59,7 @@ public class AgentMetricsController {
double value = rs.getDouble("avg_value"); double value = rs.getDouble("avg_value");
result.computeIfAbsent(metricName, k -> new ArrayList<>()) result.computeIfAbsent(metricName, k -> new ArrayList<>())
.add(new MetricBucket(bucket, value)); .add(new MetricBucket(bucket, value));
}, intervalStr, agentId, from, to, namesArray); }, intervalStr, agentId, Timestamp.from(from), Timestamp.from(to), namesArray);
return new AgentMetricsResponse(result); return new AgentMetricsResponse(result);
} }

View File

@@ -102,21 +102,21 @@ public class AgentRegistrationController {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
String group = request.group() != null ? request.group() : "default"; String application = request.application() != null ? request.application() : "default";
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of(); List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap(); var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
AgentInfo agent = registryService.register( AgentInfo agent = registryService.register(
request.agentId(), request.name(), group, request.version(), routeIds, capabilities); request.agentId(), request.name(), application, request.version(), routeIds, capabilities);
log.info("Agent registered: {} (name={}, group={})", request.agentId(), request.name(), group); log.info("Agent registered: {} (name={}, application={})", request.agentId(), request.name(), application);
agentEventService.recordEvent(request.agentId(), group, "REGISTERED", agentEventService.recordEvent(request.agentId(), application, "REGISTERED",
"Agent registered: " + request.name()); "Agent registered: " + request.name());
// Issue JWT tokens with AGENT role // Issue JWT tokens with AGENT role
List<String> roles = List.of("AGENT"); List<String> roles = List.of("AGENT");
String accessToken = jwtService.createAccessToken(request.agentId(), group, roles); String accessToken = jwtService.createAccessToken(request.agentId(), application, roles);
String refreshToken = jwtService.createRefreshToken(request.agentId(), group, roles); String refreshToken = jwtService.createRefreshToken(request.agentId(), application, roles);
return ResponseEntity.ok(new AgentRegistrationResponse( return ResponseEntity.ok(new AgentRegistrationResponse(
agent.id(), agent.id(),
@@ -166,8 +166,8 @@ public class AgentRegistrationController {
// Preserve roles from refresh token // Preserve roles from refresh token
List<String> roles = result.roles().isEmpty() List<String> roles = result.roles().isEmpty()
? List.of("AGENT") : result.roles(); ? List.of("AGENT") : result.roles();
String newAccessToken = jwtService.createAccessToken(agentId, agent.group(), roles); String newAccessToken = jwtService.createAccessToken(agentId, agent.application(), roles);
String newRefreshToken = jwtService.createRefreshToken(agentId, agent.group(), roles); String newRefreshToken = jwtService.createRefreshToken(agentId, agent.application(), roles);
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken)); return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken));
} }
@@ -187,13 +187,13 @@ public class AgentRegistrationController {
@GetMapping @GetMapping
@Operation(summary = "List all agents", @Operation(summary = "List all agents",
description = "Returns all registered agents with runtime metrics, optionally filtered by status and/or group") description = "Returns all registered agents with runtime metrics, optionally filtered by status and/or application")
@ApiResponse(responseCode = "200", description = "Agent list returned") @ApiResponse(responseCode = "200", description = "Agent list returned")
@ApiResponse(responseCode = "400", description = "Invalid status filter", @ApiResponse(responseCode = "400", description = "Invalid status filter",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))) content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<List<AgentInstanceResponse>> listAgents( public ResponseEntity<List<AgentInstanceResponse>> listAgents(
@RequestParam(required = false) String status, @RequestParam(required = false) String status,
@RequestParam(required = false) String group) { @RequestParam(required = false) String application) {
List<AgentInfo> agents; List<AgentInfo> agents;
if (status != null) { if (status != null) {
@@ -207,10 +207,10 @@ public class AgentRegistrationController {
agents = registryService.findAll(); agents = registryService.findAll();
} }
// Apply group filter if specified // Apply application filter if specified
if (group != null && !group.isBlank()) { if (application != null && !application.isBlank()) {
agents = agents.stream() agents = agents.stream()
.filter(a -> group.equals(a.group())) .filter(a -> application.equals(a.application()))
.toList(); .toList();
} }
@@ -221,11 +221,11 @@ public class AgentRegistrationController {
List<AgentInstanceResponse> response = finalAgents.stream() List<AgentInstanceResponse> response = finalAgents.stream()
.map(a -> { .map(a -> {
AgentInstanceResponse dto = AgentInstanceResponse.from(a); AgentInstanceResponse dto = AgentInstanceResponse.from(a);
double[] m = agentMetrics.get(a.group()); double[] m = agentMetrics.get(a.application());
if (m != null) { if (m != null) {
long groupAgentCount = finalAgents.stream() long appAgentCount = finalAgents.stream()
.filter(ag -> ag.group().equals(a.group())).count(); .filter(ag -> ag.application().equals(a.application())).count();
double agentTps = groupAgentCount > 0 ? m[0] / groupAgentCount : 0; double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0;
double errorRate = m[1]; double errorRate = m[1];
int activeRoutes = (int) m[2]; int activeRoutes = (int) m[2];
return dto.withMetrics(agentTps, errorRate, activeRoutes); return dto.withMetrics(agentTps, errorRate, activeRoutes);
@@ -242,19 +242,19 @@ public class AgentRegistrationController {
Instant from1m = now.minus(1, ChronoUnit.MINUTES); Instant from1m = now.minus(1, ChronoUnit.MINUTES);
try { try {
jdbc.query( jdbc.query(
"SELECT group_name, " + "SELECT application_name, " +
"SUM(total_count) AS total, " + "SUM(total_count) AS total, " +
"SUM(failed_count) AS failed, " + "SUM(failed_count) AS failed, " +
"COUNT(DISTINCT route_id) AS active_routes " + "COUNT(DISTINCT route_id) AS active_routes " +
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " + "FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
"GROUP BY group_name", "GROUP BY application_name",
rs -> { rs -> {
long total = rs.getLong("total"); long total = rs.getLong("total");
long failed = rs.getLong("failed"); long failed = rs.getLong("failed");
double tps = total / 60.0; double tps = total / 60.0;
double errorRate = total > 0 ? (double) failed / total : 0.0; double errorRate = total > 0 ? (double) failed / total : 0.0;
int activeRoutes = rs.getInt("active_routes"); int activeRoutes = rs.getInt("active_routes");
result.put(rs.getString("group_name"), new double[]{tps, errorRate, activeRoutes}); result.put(rs.getString("application_name"), new double[]{tps, errorRate, activeRoutes});
}, },
Timestamp.from(from1m), Timestamp.from(now)); Timestamp.from(from1m), Timestamp.from(now));
} catch (Exception e) { } catch (Exception e) {

View File

@@ -90,14 +90,14 @@ public class DiagramRenderController {
} }
@GetMapping @GetMapping
@Operation(summary = "Find diagram by application group and route ID", @Operation(summary = "Find diagram by application and route ID",
description = "Resolves group to agent IDs and finds the latest diagram for the route") description = "Resolves application to agent IDs and finds the latest diagram for the route")
@ApiResponse(responseCode = "200", description = "Diagram layout returned") @ApiResponse(responseCode = "200", description = "Diagram layout returned")
@ApiResponse(responseCode = "404", description = "No diagram found for the given group and route") @ApiResponse(responseCode = "404", description = "No diagram found for the given application and route")
public ResponseEntity<DiagramLayout> findByGroupAndRoute( public ResponseEntity<DiagramLayout> findByApplicationAndRoute(
@RequestParam String group, @RequestParam String application,
@RequestParam String routeId) { @RequestParam String routeId) {
List<String> agentIds = registryService.findByGroup(group).stream() List<String> agentIds = registryService.findByApplication(application).stream()
.map(AgentInfo::id) .map(AgentInfo::id)
.toList(); .toList();

View File

@@ -53,11 +53,11 @@ public class ExecutionController {
@ApiResponse(responseCode = "202", description = "Data accepted for processing") @ApiResponse(responseCode = "202", description = "Data accepted for processing")
public ResponseEntity<Void> ingestExecutions(@RequestBody String body) throws JsonProcessingException { public ResponseEntity<Void> ingestExecutions(@RequestBody String body) throws JsonProcessingException {
String agentId = extractAgentId(); String agentId = extractAgentId();
String groupName = resolveGroupName(agentId); String applicationName = resolveApplicationName(agentId);
List<RouteExecution> executions = parsePayload(body); List<RouteExecution> executions = parsePayload(body);
for (RouteExecution execution : executions) { for (RouteExecution execution : executions) {
ingestionService.ingestExecution(agentId, groupName, execution); ingestionService.ingestExecution(agentId, applicationName, execution);
} }
return ResponseEntity.accepted().build(); return ResponseEntity.accepted().build();
@@ -68,9 +68,9 @@ public class ExecutionController {
return auth != null ? auth.getName() : ""; return auth != null ? auth.getName() : "";
} }
private String resolveGroupName(String agentId) { private String resolveApplicationName(String agentId) {
AgentInfo agent = registryService.findById(agentId); AgentInfo agent = registryService.findById(agentId);
return agent != null ? agent.group() : ""; return agent != null ? agent.application() : "";
} }
private List<RouteExecution> parsePayload(String body) throws JsonProcessingException { private List<RouteExecution> parsePayload(String body) throws JsonProcessingException {

View File

@@ -47,9 +47,9 @@ public class RouteCatalogController {
public ResponseEntity<List<AppCatalogEntry>> getCatalog() { public ResponseEntity<List<AppCatalogEntry>> getCatalog() {
List<AgentInfo> allAgents = registryService.findAll(); List<AgentInfo> allAgents = registryService.findAll();
// Group agents by application (group name) // Group agents by application name
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream() Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
.collect(Collectors.groupingBy(AgentInfo::group, LinkedHashMap::new, Collectors.toList())); .collect(Collectors.groupingBy(AgentInfo::application, LinkedHashMap::new, Collectors.toList()));
// Collect all distinct routes per app // Collect all distinct routes per app
Map<String, Set<String>> routesByApp = new LinkedHashMap<>(); Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
@@ -73,11 +73,11 @@ public class RouteCatalogController {
Map<String, Instant> routeLastSeen = new LinkedHashMap<>(); Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
try { try {
jdbc.query( jdbc.query(
"SELECT group_name, route_id, SUM(total_count) AS cnt, MAX(bucket) AS last_seen " + "SELECT application_name, route_id, SUM(total_count) AS cnt, MAX(bucket) AS last_seen " +
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " + "FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
"GROUP BY group_name, route_id", "GROUP BY application_name, route_id",
rs -> { rs -> {
String key = rs.getString("group_name") + "/" + rs.getString("route_id"); String key = rs.getString("application_name") + "/" + rs.getString("route_id");
routeExchangeCounts.put(key, rs.getLong("cnt")); routeExchangeCounts.put(key, rs.getLong("cnt"));
Timestamp ts = rs.getTimestamp("last_seen"); Timestamp ts = rs.getTimestamp("last_seen");
if (ts != null) routeLastSeen.put(key, ts.toInstant()); if (ts != null) routeLastSeen.put(key, ts.toInstant());
@@ -91,9 +91,9 @@ public class RouteCatalogController {
Map<String, Double> agentTps = new LinkedHashMap<>(); Map<String, Double> agentTps = new LinkedHashMap<>();
try { try {
jdbc.query( jdbc.query(
"SELECT group_name, SUM(total_count) AS cnt " + "SELECT application_name, SUM(total_count) AS cnt " +
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " + "FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
"GROUP BY group_name", "GROUP BY application_name",
rs -> { rs -> {
// This gives per-app TPS; we'll distribute among agents below // This gives per-app TPS; we'll distribute among agents below
}, },

View File

@@ -44,7 +44,7 @@ public class RouteMetricsController {
long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds(); long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds();
var sql = new StringBuilder( var sql = new StringBuilder(
"SELECT group_name, route_id, " + "SELECT application_name, route_id, " +
"SUM(total_count) AS total, " + "SUM(total_count) AS total, " +
"SUM(failed_count) AS failed, " + "SUM(failed_count) AS failed, " +
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum) / SUM(total_count) ELSE 0 END AS avg_dur, " + "CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum) / SUM(total_count) ELSE 0 END AS avg_dur, " +
@@ -55,17 +55,17 @@ public class RouteMetricsController {
params.add(Timestamp.from(toInstant)); params.add(Timestamp.from(toInstant));
if (appId != null) { if (appId != null) {
sql.append(" AND group_name = ?"); sql.append(" AND application_name = ?");
params.add(appId); params.add(appId);
} }
sql.append(" GROUP BY group_name, route_id ORDER BY group_name, route_id"); sql.append(" GROUP BY application_name, route_id ORDER BY application_name, route_id");
// Key struct for sparkline lookup // Key struct for sparkline lookup
record RouteKey(String appId, String routeId) {} record RouteKey(String appId, String routeId) {}
List<RouteKey> routeKeys = new ArrayList<>(); List<RouteKey> routeKeys = new ArrayList<>();
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> { List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
String groupName = rs.getString("group_name"); String applicationName = rs.getString("application_name");
String routeId = rs.getString("route_id"); String routeId = rs.getString("route_id");
long total = rs.getLong("total"); long total = rs.getLong("total");
long failed = rs.getLong("failed"); long failed = rs.getLong("failed");
@@ -76,8 +76,8 @@ public class RouteMetricsController {
double errorRate = total > 0 ? (double) failed / total : 0.0; double errorRate = total > 0 ? (double) failed / total : 0.0;
double tps = windowSeconds > 0 ? (double) total / windowSeconds : 0.0; double tps = windowSeconds > 0 ? (double) total / windowSeconds : 0.0;
routeKeys.add(new RouteKey(groupName, routeId)); routeKeys.add(new RouteKey(applicationName, routeId));
return new RouteMetrics(routeId, groupName, total, successRate, return new RouteMetrics(routeId, applicationName, total, successRate,
avgDur, p99Dur, errorRate, tps, List.of()); avgDur, p99Dur, errorRate, tps, List.of());
}, params.toArray()); }, params.toArray());
@@ -93,7 +93,7 @@ public class RouteMetricsController {
"SELECT time_bucket(? * INTERVAL '1 second', bucket) AS period, " + "SELECT time_bucket(? * INTERVAL '1 second', bucket) AS period, " +
"COALESCE(SUM(total_count), 0) AS cnt " + "COALESCE(SUM(total_count), 0) AS cnt " +
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " + "FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
"AND group_name = ? AND route_id = ? " + "AND application_name = ? AND route_id = ? " +
"GROUP BY period ORDER BY period", "GROUP BY period ORDER BY period",
(rs, rowNum) -> rs.getDouble("cnt"), (rs, rowNum) -> rs.getDouble("cnt"),
bucketSeconds, Timestamp.from(fromInstant), Timestamp.from(toInstant), bucketSeconds, Timestamp.from(fromInstant), Timestamp.from(toInstant),
@@ -124,7 +124,7 @@ public class RouteMetricsController {
Instant fromInstant = from != null ? from : toInstant.minus(24, ChronoUnit.HOURS); Instant fromInstant = from != null ? from : toInstant.minus(24, ChronoUnit.HOURS);
var sql = new StringBuilder( var sql = new StringBuilder(
"SELECT processor_id, processor_type, route_id, group_name, " + "SELECT processor_id, processor_type, route_id, application_name, " +
"SUM(total_count) AS total_count, " + "SUM(total_count) AS total_count, " +
"SUM(failed_count) AS failed_count, " + "SUM(failed_count) AS failed_count, " +
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum)::double precision / SUM(total_count) ELSE 0 END AS avg_duration_ms, " + "CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum)::double precision / SUM(total_count) ELSE 0 END AS avg_duration_ms, " +
@@ -137,10 +137,10 @@ public class RouteMetricsController {
params.add(routeId); params.add(routeId);
if (appId != null) { if (appId != null) {
sql.append(" AND group_name = ?"); sql.append(" AND application_name = ?");
params.add(appId); params.add(appId);
} }
sql.append(" GROUP BY processor_id, processor_type, route_id, group_name"); sql.append(" GROUP BY processor_id, processor_type, route_id, application_name");
sql.append(" ORDER BY SUM(total_count) DESC"); sql.append(" ORDER BY SUM(total_count) DESC");
List<ProcessorMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> { List<ProcessorMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
@@ -151,7 +151,7 @@ public class RouteMetricsController {
rs.getString("processor_id"), rs.getString("processor_id"),
rs.getString("processor_type"), rs.getString("processor_type"),
rs.getString("route_id"), rs.getString("route_id"),
rs.getString("group_name"), rs.getString("application_name"),
totalCount, totalCount,
failedCount, failedCount,
rs.getDouble("avg_duration_ms"), rs.getDouble("avg_duration_ms"),

View File

@@ -51,13 +51,13 @@ public class SearchController {
@RequestParam(required = false) String routeId, @RequestParam(required = false) String routeId,
@RequestParam(required = false) String agentId, @RequestParam(required = false) String agentId,
@RequestParam(required = false) String processorType, @RequestParam(required = false) String processorType,
@RequestParam(required = false) String group, @RequestParam(required = false) String application,
@RequestParam(defaultValue = "0") int offset, @RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "50") int limit, @RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) String sortField, @RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortDir) { @RequestParam(required = false) String sortDir) {
List<String> agentIds = resolveGroupToAgentIds(group); List<String> agentIds = resolveApplicationToAgentIds(application);
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
status, timeFrom, timeTo, status, timeFrom, timeTo,
@@ -65,7 +65,7 @@ public class SearchController {
correlationId, correlationId,
text, null, null, null, text, null, null, null,
routeId, agentId, processorType, routeId, agentId, processorType,
group, agentIds, application, agentIds,
offset, limit, offset, limit,
sortField, sortDir sortField, sortDir
); );
@@ -77,11 +77,11 @@ public class SearchController {
@Operation(summary = "Advanced search with all filters") @Operation(summary = "Advanced search with all filters")
public ResponseEntity<SearchResult<ExecutionSummary>> searchPost( public ResponseEntity<SearchResult<ExecutionSummary>> searchPost(
@RequestBody SearchRequest request) { @RequestBody SearchRequest request) {
// Resolve group to agentIds if group is specified but agentIds is not // Resolve application to agentIds if application is specified but agentIds is not
SearchRequest resolved = request; SearchRequest resolved = request;
if (request.group() != null && !request.group().isBlank() if (request.application() != null && !request.application().isBlank()
&& (request.agentIds() == null || request.agentIds().isEmpty())) { && (request.agentIds() == null || request.agentIds().isEmpty())) {
resolved = request.withAgentIds(resolveGroupToAgentIds(request.group())); resolved = request.withAgentIds(resolveApplicationToAgentIds(request.application()));
} }
return ResponseEntity.ok(searchService.search(resolved)); return ResponseEntity.ok(searchService.search(resolved));
} }
@@ -92,12 +92,15 @@ public class SearchController {
@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 group) { @RequestParam(required = false) String application) {
Instant end = to != null ? to : Instant.now(); Instant end = to != null ? to : Instant.now();
List<String> agentIds = resolveGroupToAgentIds(group); if (routeId == null && application == null) {
if (routeId == null && agentIds == null) {
return ResponseEntity.ok(searchService.stats(from, end)); return ResponseEntity.ok(searchService.stats(from, end));
} }
if (routeId == null) {
return ResponseEntity.ok(searchService.statsForApp(from, end, application));
}
List<String> agentIds = resolveApplicationToAgentIds(application);
return ResponseEntity.ok(searchService.stats(from, end, routeId, agentIds)); return ResponseEntity.ok(searchService.stats(from, end, routeId, agentIds));
} }
@@ -108,9 +111,15 @@ public class SearchController {
@RequestParam(required = false) Instant to, @RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "24") int buckets, @RequestParam(defaultValue = "24") int buckets,
@RequestParam(required = false) String routeId, @RequestParam(required = false) String routeId,
@RequestParam(required = false) String group) { @RequestParam(required = false) String application) {
Instant end = to != null ? to : Instant.now(); Instant end = to != null ? to : Instant.now();
List<String> agentIds = resolveGroupToAgentIds(group); if (routeId == null && application == null) {
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
}
if (routeId == null) {
return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application));
}
List<String> agentIds = resolveApplicationToAgentIds(application);
if (routeId == null && agentIds == null) { if (routeId == null && agentIds == null) {
return ResponseEntity.ok(searchService.timeseries(from, end, buckets)); return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
} }
@@ -118,14 +127,14 @@ public class SearchController {
} }
/** /**
* Resolve an application group name to agent IDs. * Resolve an application name to agent IDs.
* Returns null if group is null/blank (no filtering). * Returns null if application is null/blank (no filtering).
*/ */
private List<String> resolveGroupToAgentIds(String group) { private List<String> resolveApplicationToAgentIds(String application) {
if (group == null || group.isBlank()) { if (application == null || application.isBlank()) {
return null; return null;
} }
return registryService.findByGroup(group).stream() return registryService.findByApplication(application).stream()
.map(AgentInfo::id) .map(AgentInfo::id)
.toList(); .toList();
} }

View File

@@ -13,7 +13,7 @@ import java.util.Map;
public record AgentInstanceResponse( public record AgentInstanceResponse(
@NotNull String id, @NotNull String id,
@NotNull String name, @NotNull String name,
@NotNull String group, @NotNull String application,
@NotNull String status, @NotNull String status,
@NotNull List<String> routeIds, @NotNull List<String> routeIds,
@NotNull Instant registeredAt, @NotNull Instant registeredAt,
@@ -29,7 +29,7 @@ public record AgentInstanceResponse(
public static AgentInstanceResponse from(AgentInfo info) { public static AgentInstanceResponse from(AgentInfo info) {
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds(); long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
return new AgentInstanceResponse( return new AgentInstanceResponse(
info.id(), info.name(), info.group(), info.id(), info.name(), info.application(),
info.state().name(), info.routeIds(), info.state().name(), info.routeIds(),
info.registeredAt(), info.lastHeartbeat(), info.registeredAt(), info.lastHeartbeat(),
info.version(), info.capabilities(), info.version(), info.capabilities(),
@@ -41,7 +41,7 @@ public record AgentInstanceResponse(
public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) { public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) {
return new AgentInstanceResponse( return new AgentInstanceResponse(
id, name, group, status, routeIds, registeredAt, lastHeartbeat, id, name, application, status, routeIds, registeredAt, lastHeartbeat,
version, capabilities, version, capabilities,
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
); );

View File

@@ -10,7 +10,7 @@ import java.util.Map;
public record AgentRegistrationRequest( public record AgentRegistrationRequest(
@NotNull String agentId, @NotNull String agentId,
@NotNull String name, @NotNull String name,
@Schema(defaultValue = "default") String group, @Schema(defaultValue = "default") String application,
String version, String version,
List<String> routeIds, List<String> routeIds,
Map<String, Object> capabilities Map<String, Object> capabilities

View File

@@ -288,7 +288,7 @@ public class OpenSearchIndex implements SearchIndex {
map.put("execution_id", doc.executionId()); map.put("execution_id", doc.executionId());
map.put("route_id", doc.routeId()); map.put("route_id", doc.routeId());
map.put("agent_id", doc.agentId()); map.put("agent_id", doc.agentId());
map.put("group_name", doc.groupName()); map.put("application_name", doc.applicationName());
map.put("status", doc.status()); map.put("status", doc.status());
map.put("correlation_id", doc.correlationId()); map.put("correlation_id", doc.correlationId());
map.put("exchange_id", doc.exchangeId()); map.put("exchange_id", doc.exchangeId());
@@ -323,6 +323,7 @@ public class OpenSearchIndex implements SearchIndex {
(String) src.get("execution_id"), (String) src.get("execution_id"),
(String) src.get("route_id"), (String) src.get("route_id"),
(String) src.get("agent_id"), (String) src.get("agent_id"),
(String) src.get("application_name"),
(String) src.get("status"), (String) src.get("status"),
src.get("start_time") != null ? Instant.parse((String) src.get("start_time")) : null, src.get("start_time") != null ? Instant.parse((String) src.get("start_time")) : null,
src.get("end_time") != null ? Instant.parse((String) src.get("end_time")) : null, src.get("end_time") != null ? Instant.parse((String) src.get("end_time")) : null,

View File

@@ -60,13 +60,13 @@ public class JwtServiceImpl implements JwtService {
} }
@Override @Override
public String createAccessToken(String subject, String group, List<String> roles) { public String createAccessToken(String subject, String application, List<String> roles) {
return createToken(subject, group, roles, "access", properties.getAccessTokenExpiryMs()); return createToken(subject, application, roles, "access", properties.getAccessTokenExpiryMs());
} }
@Override @Override
public String createRefreshToken(String subject, String group, List<String> roles) { public String createRefreshToken(String subject, String application, List<String> roles) {
return createToken(subject, group, roles, "refresh", properties.getRefreshTokenExpiryMs()); return createToken(subject, application, roles, "refresh", properties.getRefreshTokenExpiryMs());
} }
@Override @Override
@@ -84,12 +84,12 @@ public class JwtServiceImpl implements JwtService {
return validateAccessToken(token).subject(); return validateAccessToken(token).subject();
} }
private String createToken(String subject, String group, List<String> roles, private String createToken(String subject, String application, List<String> roles,
String type, long expiryMs) { String type, long expiryMs) {
Instant now = Instant.now(); Instant now = Instant.now();
JWTClaimsSet claims = new JWTClaimsSet.Builder() JWTClaimsSet claims = new JWTClaimsSet.Builder()
.subject(subject) .subject(subject)
.claim("group", group) .claim("group", application)
.claim("type", type) .claim("type", type)
.claim("roles", roles) .claim("roles", roles)
.issueTime(Date.from(now)) .issueTime(Date.from(now))
@@ -132,7 +132,7 @@ public class JwtServiceImpl implements JwtService {
throw new InvalidTokenException("Token has no subject"); throw new InvalidTokenException("Token has no subject");
} }
String group = claims.getStringClaim("group"); String application = claims.getStringClaim("group");
// Extract roles — may be absent in legacy tokens // Extract roles — may be absent in legacy tokens
List<String> roles; List<String> roles;
@@ -145,7 +145,7 @@ public class JwtServiceImpl implements JwtService {
roles = List.of(); roles = List.of();
} }
return new JwtValidationResult(subject, group, roles); return new JwtValidationResult(subject, application, roles);
} catch (ParseException e) { } catch (ParseException e) {
throw new InvalidTokenException("Failed to parse JWT", e); throw new InvalidTokenException("Failed to parse JWT", e);
} catch (JOSEException e) { } catch (JOSEException e) {

View File

@@ -24,7 +24,7 @@ public class PostgresExecutionStore implements ExecutionStore {
@Override @Override
public void upsert(ExecutionRecord execution) { public void upsert(ExecutionRecord execution) {
jdbc.update(""" jdbc.update("""
INSERT INTO executions (execution_id, route_id, agent_id, group_name, INSERT INTO executions (execution_id, route_id, agent_id, application_name,
status, correlation_id, exchange_id, start_time, end_time, status, correlation_id, exchange_id, start_time, end_time,
duration_ms, error_message, error_stacktrace, diagram_content_hash, duration_ms, error_message, error_stacktrace, diagram_content_hash,
created_at, updated_at) created_at, updated_at)
@@ -45,7 +45,7 @@ public class PostgresExecutionStore implements ExecutionStore {
updated_at = now() updated_at = now()
""", """,
execution.executionId(), execution.routeId(), execution.agentId(), execution.executionId(), execution.routeId(), execution.agentId(),
execution.groupName(), execution.status(), execution.correlationId(), execution.applicationName(), execution.status(), execution.correlationId(),
execution.exchangeId(), execution.exchangeId(),
Timestamp.from(execution.startTime()), Timestamp.from(execution.startTime()),
execution.endTime() != null ? Timestamp.from(execution.endTime()) : null, execution.endTime() != null ? Timestamp.from(execution.endTime()) : null,
@@ -55,11 +55,11 @@ public class PostgresExecutionStore implements ExecutionStore {
@Override @Override
public void upsertProcessors(String executionId, Instant startTime, public void upsertProcessors(String executionId, Instant startTime,
String groupName, String routeId, String applicationName, String routeId,
List<ProcessorRecord> processors) { List<ProcessorRecord> processors) {
jdbc.batchUpdate(""" jdbc.batchUpdate("""
INSERT INTO processor_executions (execution_id, processor_id, processor_type, INSERT INTO processor_executions (execution_id, processor_id, processor_type,
diagram_node_id, group_name, route_id, depth, parent_processor_id, diagram_node_id, application_name, route_id, depth, parent_processor_id,
status, start_time, end_time, duration_ms, error_message, error_stacktrace, status, start_time, end_time, duration_ms, error_message, error_stacktrace,
input_body, output_body, input_headers, output_headers) input_body, output_body, input_headers, output_headers)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb)
@@ -76,7 +76,7 @@ public class PostgresExecutionStore implements ExecutionStore {
""", """,
processors.stream().map(p -> new Object[]{ processors.stream().map(p -> new Object[]{
p.executionId(), p.processorId(), p.processorType(), p.executionId(), p.processorId(), p.processorType(),
p.diagramNodeId(), p.groupName(), p.routeId(), p.diagramNodeId(), p.applicationName(), p.routeId(),
p.depth(), p.parentProcessorId(), p.status(), p.depth(), p.parentProcessorId(), p.status(),
Timestamp.from(p.startTime()), Timestamp.from(p.startTime()),
p.endTime() != null ? Timestamp.from(p.endTime()) : null, p.endTime() != null ? Timestamp.from(p.endTime()) : null,
@@ -103,7 +103,7 @@ public class PostgresExecutionStore implements ExecutionStore {
private static final RowMapper<ExecutionRecord> EXECUTION_MAPPER = (rs, rowNum) -> private static final RowMapper<ExecutionRecord> EXECUTION_MAPPER = (rs, rowNum) ->
new ExecutionRecord( new ExecutionRecord(
rs.getString("execution_id"), rs.getString("route_id"), rs.getString("execution_id"), rs.getString("route_id"),
rs.getString("agent_id"), rs.getString("group_name"), rs.getString("agent_id"), rs.getString("application_name"),
rs.getString("status"), rs.getString("correlation_id"), rs.getString("status"), rs.getString("correlation_id"),
rs.getString("exchange_id"), rs.getString("exchange_id"),
toInstant(rs, "start_time"), toInstant(rs, "end_time"), toInstant(rs, "start_time"), toInstant(rs, "end_time"),
@@ -115,7 +115,7 @@ public class PostgresExecutionStore implements ExecutionStore {
new ProcessorRecord( new ProcessorRecord(
rs.getString("execution_id"), rs.getString("processor_id"), rs.getString("execution_id"), rs.getString("processor_id"),
rs.getString("processor_type"), rs.getString("diagram_node_id"), rs.getString("processor_type"), rs.getString("diagram_node_id"),
rs.getString("group_name"), rs.getString("route_id"), rs.getString("application_name"), rs.getString("route_id"),
rs.getInt("depth"), rs.getString("parent_processor_id"), rs.getInt("depth"), rs.getString("parent_processor_id"),
rs.getString("status"), rs.getString("status"),
toInstant(rs, "start_time"), toInstant(rs, "end_time"), toInstant(rs, "start_time"), toInstant(rs, "end_time"),

View File

@@ -29,9 +29,9 @@ public class PostgresStatsStore implements StatsStore {
} }
@Override @Override
public ExecutionStats statsForApp(Instant from, Instant to, String groupName) { public ExecutionStats statsForApp(Instant from, Instant to, String applicationName) {
return queryStats("stats_1m_app", from, to, List.of( return queryStats("stats_1m_app", from, to, List.of(
new Filter("group_name", groupName))); new Filter("application_name", applicationName)));
} }
@Override @Override
@@ -56,9 +56,9 @@ public class PostgresStatsStore implements StatsStore {
} }
@Override @Override
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String groupName) { public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName) {
return queryTimeseries("stats_1m_app", from, to, bucketCount, List.of( return queryTimeseries("stats_1m_app", from, to, bucketCount, List.of(
new Filter("group_name", groupName)), true); new Filter("application_name", applicationName)), true);
} }
@Override @Override

View File

@@ -13,6 +13,7 @@ CREATE TABLE users (
provider TEXT NOT NULL, provider TEXT NOT NULL,
email TEXT, email TEXT,
display_name TEXT, display_name TEXT,
password_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
@@ -39,12 +40,20 @@ CREATE TABLE groups (
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
-- Built-in Admins group
INSERT INTO groups (id, name) VALUES
('00000000-0000-0000-0000-000000000010', 'Admins');
CREATE TABLE group_roles ( CREATE TABLE group_roles (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, role_id) PRIMARY KEY (group_id, role_id)
); );
-- Assign ADMIN role to Admins group
INSERT INTO group_roles (group_id, role_id) VALUES
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');
CREATE TABLE user_groups ( CREATE TABLE user_groups (
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
@@ -70,7 +79,7 @@ CREATE TABLE executions (
execution_id TEXT NOT NULL, execution_id TEXT NOT NULL,
route_id TEXT NOT NULL, route_id TEXT NOT NULL,
agent_id TEXT NOT NULL, agent_id TEXT NOT NULL,
group_name TEXT NOT NULL, application_name TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
correlation_id TEXT, correlation_id TEXT,
exchange_id TEXT, exchange_id TEXT,
@@ -89,7 +98,7 @@ SELECT create_hypertable('executions', 'start_time', chunk_time_interval => INTE
CREATE INDEX idx_executions_agent_time ON executions (agent_id, start_time DESC); CREATE INDEX idx_executions_agent_time ON executions (agent_id, start_time DESC);
CREATE INDEX idx_executions_route_time ON executions (route_id, start_time DESC); CREATE INDEX idx_executions_route_time ON executions (route_id, start_time DESC);
CREATE INDEX idx_executions_group_time ON executions (group_name, start_time DESC); CREATE INDEX idx_executions_app_time ON executions (application_name, start_time DESC);
CREATE INDEX idx_executions_correlation ON executions (correlation_id); CREATE INDEX idx_executions_correlation ON executions (correlation_id);
CREATE TABLE processor_executions ( CREATE TABLE processor_executions (
@@ -98,7 +107,7 @@ CREATE TABLE processor_executions (
processor_id TEXT NOT NULL, processor_id TEXT NOT NULL,
processor_type TEXT NOT NULL, processor_type TEXT NOT NULL,
diagram_node_id TEXT, diagram_node_id TEXT,
group_name TEXT NOT NULL, application_name TEXT NOT NULL,
route_id TEXT NOT NULL, route_id TEXT NOT NULL,
depth INT NOT NULL, depth INT NOT NULL,
parent_processor_id TEXT, parent_processor_id TEXT,
@@ -153,22 +162,56 @@ CREATE TABLE route_diagrams (
CREATE INDEX idx_diagrams_route_agent ON route_diagrams (route_id, agent_id); CREATE INDEX idx_diagrams_route_agent ON route_diagrams (route_id, agent_id);
-- ============================================================= -- =============================================================
-- OIDC configuration -- Agent events
-- ============================================================= -- =============================================================
CREATE TABLE oidc_config ( CREATE TABLE agent_events (
config_id TEXT PRIMARY KEY DEFAULT 'default', id BIGSERIAL PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false, agent_id TEXT NOT NULL,
issuer_uri TEXT, app_id TEXT NOT NULL,
client_id TEXT, event_type TEXT NOT NULL,
client_secret TEXT, detail TEXT,
roles_claim TEXT, timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
default_roles TEXT[] NOT NULL DEFAULT '{}',
auto_signup BOOLEAN DEFAULT false,
display_name_claim TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE INDEX idx_agent_events_agent ON agent_events(agent_id, timestamp DESC);
CREATE INDEX idx_agent_events_app ON agent_events(app_id, timestamp DESC);
CREATE INDEX idx_agent_events_time ON agent_events(timestamp DESC);
-- =============================================================
-- Server configuration
-- =============================================================
CREATE TABLE server_config (
config_key TEXT PRIMARY KEY,
config_val JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT
);
-- =============================================================
-- Admin
-- =============================================================
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
username TEXT NOT NULL,
action TEXT NOT NULL,
category TEXT NOT NULL,
target TEXT,
detail JSONB,
result TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
CREATE INDEX idx_audit_log_username ON audit_log (username);
CREATE INDEX idx_audit_log_category ON audit_log (category);
CREATE INDEX idx_audit_log_action ON audit_log (action);
CREATE INDEX idx_audit_log_target ON audit_log (target);
-- ============================================================= -- =============================================================
-- Continuous aggregates -- Continuous aggregates
-- ============================================================= -- =============================================================
@@ -188,16 +231,12 @@ WHERE status IS NOT NULL
GROUP BY bucket GROUP BY bucket
WITH NO DATA; WITH NO DATA;
SELECT add_continuous_aggregate_policy('stats_1m_all',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');
CREATE MATERIALIZED VIEW stats_1m_app CREATE MATERIALIZED VIEW stats_1m_app
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
SELECT SELECT
time_bucket('1 minute', start_time) AS bucket, time_bucket('1 minute', start_time) AS bucket,
group_name, application_name,
COUNT(*) AS total_count, COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count, COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count, COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
@@ -206,19 +245,15 @@ SELECT
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
FROM executions FROM executions
WHERE status IS NOT NULL WHERE status IS NOT NULL
GROUP BY bucket, group_name GROUP BY bucket, application_name
WITH NO DATA; WITH NO DATA;
SELECT add_continuous_aggregate_policy('stats_1m_app',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');
CREATE MATERIALIZED VIEW stats_1m_route CREATE MATERIALIZED VIEW stats_1m_route
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
SELECT SELECT
time_bucket('1 minute', start_time) AS bucket, time_bucket('1 minute', start_time) AS bucket,
group_name, application_name,
route_id, route_id,
COUNT(*) AS total_count, COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count, COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
@@ -228,19 +263,15 @@ SELECT
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
FROM executions FROM executions
WHERE status IS NOT NULL WHERE status IS NOT NULL
GROUP BY bucket, group_name, route_id GROUP BY bucket, application_name, route_id
WITH NO DATA; WITH NO DATA;
SELECT add_continuous_aggregate_policy('stats_1m_route',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');
CREATE MATERIALIZED VIEW stats_1m_processor CREATE MATERIALIZED VIEW stats_1m_processor
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
SELECT SELECT
time_bucket('1 minute', start_time) AS bucket, time_bucket('1 minute', start_time) AS bucket,
group_name, application_name,
route_id, route_id,
processor_type, processor_type,
COUNT(*) AS total_count, COUNT(*) AS total_count,
@@ -249,41 +280,24 @@ SELECT
MAX(duration_ms) AS duration_max, MAX(duration_ms) AS duration_max,
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
FROM processor_executions FROM processor_executions
GROUP BY bucket, group_name, route_id, processor_type GROUP BY bucket, application_name, route_id, processor_type
WITH NO DATA; WITH NO DATA;
SELECT add_continuous_aggregate_policy('stats_1m_processor',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');
-- ============================================================= CREATE MATERIALIZED VIEW stats_1m_processor_detail
-- Admin WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
-- ============================================================= SELECT
time_bucket('1 minute', start_time) AS bucket,
application_name,
route_id,
processor_id,
processor_type,
COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
SUM(duration_ms) AS duration_sum,
MAX(duration_ms) AS duration_max,
approx_percentile(0.99, percentile_agg(duration_ms)) AS p99_duration
FROM processor_executions
GROUP BY bucket, application_name, route_id, processor_id, processor_type
WITH NO DATA;
CREATE TABLE admin_thresholds (
id INTEGER PRIMARY KEY DEFAULT 1,
config JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT NOT NULL,
CONSTRAINT single_row CHECK (id = 1)
);
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
username TEXT NOT NULL,
action TEXT NOT NULL,
category TEXT NOT NULL,
target TEXT,
detail JSONB,
result TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
CREATE INDEX idx_audit_log_username ON audit_log (username);
CREATE INDEX idx_audit_log_category ON audit_log (category);
CREATE INDEX idx_audit_log_action ON audit_log (action);
CREATE INDEX idx_audit_log_target ON audit_log (target);

View File

@@ -1,7 +0,0 @@
-- Built-in Admins group
INSERT INTO groups (id, name) VALUES
('00000000-0000-0000-0000-000000000010', 'Admins');
-- Assign ADMIN role to Admins group
INSERT INTO group_roles (group_id, role_id) VALUES
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');

View File

@@ -0,0 +1,38 @@
-- V2__policies.sql - TimescaleDB policies (must run outside transaction)
-- flyway:executeInTransaction=false
-- Agent metrics retention & compression
ALTER TABLE agent_metrics SET (timescaledb.compress);
SELECT add_retention_policy('agent_metrics', INTERVAL '90 days', if_not_exists => true);
SELECT add_compression_policy('agent_metrics', INTERVAL '7 days', if_not_exists => true);
-- Continuous aggregate refresh policies
SELECT add_continuous_aggregate_policy('stats_1m_all',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => true);
SELECT add_continuous_aggregate_policy('stats_1m_app',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => true);
SELECT add_continuous_aggregate_policy('stats_1m_route',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => true);
SELECT add_continuous_aggregate_policy('stats_1m_processor',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => true);
SELECT add_continuous_aggregate_policy('stats_1m_processor_detail',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => true);

View File

@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN password_hash TEXT;

View File

@@ -1,36 +0,0 @@
-- =============================================================
-- Consolidate oidc_config + admin_thresholds → server_config
-- =============================================================
CREATE TABLE server_config (
config_key TEXT PRIMARY KEY,
config_val JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT
);
-- Migrate existing oidc_config row (if any)
INSERT INTO server_config (config_key, config_val, updated_at)
SELECT 'oidc',
jsonb_build_object(
'enabled', enabled,
'issuerUri', issuer_uri,
'clientId', client_id,
'clientSecret', client_secret,
'rolesClaim', roles_claim,
'defaultRoles', to_jsonb(default_roles),
'autoSignup', auto_signup,
'displayNameClaim', display_name_claim
),
updated_at
FROM oidc_config
WHERE config_id = 'default';
-- Migrate existing admin_thresholds row (if any)
INSERT INTO server_config (config_key, config_val, updated_at, updated_by)
SELECT 'thresholds', config, updated_at, updated_by
FROM admin_thresholds
WHERE id = 1;
DROP TABLE oidc_config;
DROP TABLE admin_thresholds;

View File

@@ -1,13 +0,0 @@
-- Agent lifecycle events for tracking registration, state transitions, etc.
CREATE TABLE agent_events (
id BIGSERIAL PRIMARY KEY,
agent_id TEXT NOT NULL,
app_id TEXT NOT NULL,
event_type TEXT NOT NULL,
detail TEXT,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_agent_events_agent ON agent_events(agent_id, timestamp DESC);
CREATE INDEX idx_agent_events_app ON agent_events(app_id, timestamp DESC);
CREATE INDEX idx_agent_events_time ON agent_events(timestamp DESC);

View File

@@ -1,6 +0,0 @@
-- Retention: drop agent_metrics chunks older than 90 days
SELECT add_retention_policy('agent_metrics', INTERVAL '90 days', if_not_exists => true);
-- Compression: compress agent_metrics chunks older than 7 days
ALTER TABLE agent_metrics SET (timescaledb.compress);
SELECT add_compression_policy('agent_metrics', INTERVAL '7 days', if_not_exists => true);

View File

@@ -1,21 +0,0 @@
-- V7: Per-processor-id continuous aggregate for route detail page
CREATE MATERIALIZED VIEW stats_1m_processor_detail
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 minute', start_time) AS bucket,
group_name,
route_id,
processor_id,
processor_type,
COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
SUM(duration_ms) AS duration_sum,
MAX(duration_ms) AS duration_max,
approx_percentile(0.99, percentile_agg(duration_ms)) AS p99_duration
FROM processor_executions
GROUP BY bucket, group_name, route_id, processor_id, processor_type;
SELECT add_continuous_aggregate_policy('stats_1m_processor_detail',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');

View File

@@ -37,8 +37,8 @@ public class TestSecurityHelper {
/** /**
* Returns a valid JWT access token with the given roles (no agent registration). * Returns a valid JWT access token with the given roles (no agent registration).
*/ */
public String createToken(String subject, String group, List<String> roles) { public String createToken(String subject, String application, List<String> roles) {
return jwtService.createAccessToken(subject, group, roles); return jwtService.createAccessToken(subject, application, roles);
} }
/** /**

View File

@@ -38,17 +38,17 @@ class AgentCommandControllerIT extends AbstractPostgresIT {
operatorJwt = securityHelper.operatorToken(); operatorJwt = securityHelper.operatorToken();
} }
private ResponseEntity<String> registerAgent(String agentId, String name, String group) { private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
String json = """ String json = """
{ {
"agentId": "%s", "agentId": "%s",
"name": "%s", "name": "%s",
"group": "%s", "application": "%s",
"version": "1.0.0", "version": "1.0.0",
"routeIds": ["route-1"], "routeIds": ["route-1"],
"capabilities": {} "capabilities": {}
} }
""".formatted(agentId, name, group); """.formatted(agentId, name, application);
return restTemplate.postForEntity( return restTemplate.postForEntity(
"/api/v1/agents/register", "/api/v1/agents/register",

View File

@@ -41,7 +41,7 @@ class AgentRegistrationControllerIT extends AbstractPostgresIT {
{ {
"agentId": "%s", "agentId": "%s",
"name": "%s", "name": "%s",
"group": "test-group", "application": "test-group",
"version": "1.0.0", "version": "1.0.0",
"routeIds": ["route-1", "route-2"], "routeIds": ["route-1", "route-2"],
"capabilities": {"tracing": true} "capabilities": {"tracing": true}

View File

@@ -53,17 +53,17 @@ class AgentSseControllerIT extends AbstractPostgresIT {
operatorJwt = securityHelper.operatorToken(); operatorJwt = securityHelper.operatorToken();
} }
private ResponseEntity<String> registerAgent(String agentId, String name, String group) { private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
String json = """ String json = """
{ {
"agentId": "%s", "agentId": "%s",
"name": "%s", "name": "%s",
"group": "%s", "application": "%s",
"version": "1.0.0", "version": "1.0.0",
"routeIds": ["route-1"], "routeIds": ["route-1"],
"capabilities": {} "capabilities": {}
} }
""".formatted(agentId, name, group); """.formatted(agentId, name, application);
return restTemplate.postForEntity( return restTemplate.postForEntity(
"/api/v1/agents/register", "/api/v1/agents/register",

View File

@@ -29,7 +29,7 @@ class BootstrapTokenIT extends AbstractPostgresIT {
{ {
"agentId": "bootstrap-test-agent", "agentId": "bootstrap-test-agent",
"name": "Bootstrap Test", "name": "Bootstrap Test",
"group": "test-group", "application": "test-group",
"version": "1.0.0", "version": "1.0.0",
"routeIds": [], "routeIds": [],
"capabilities": {} "capabilities": {}
@@ -97,7 +97,7 @@ class BootstrapTokenIT extends AbstractPostgresIT {
{ {
"agentId": "bootstrap-test-previous", "agentId": "bootstrap-test-previous",
"name": "Previous Token Test", "name": "Previous Token Test",
"group": "test-group", "application": "test-group",
"version": "1.0.0", "version": "1.0.0",
"routeIds": [], "routeIds": [],
"capabilities": {} "capabilities": {}

View File

@@ -39,7 +39,7 @@ class JwtRefreshIT extends AbstractPostgresIT {
{ {
"agentId": "%s", "agentId": "%s",
"name": "Refresh Test Agent", "name": "Refresh Test Agent",
"group": "test-group", "application": "test-group",
"version": "1.0.0", "version": "1.0.0",
"routeIds": [], "routeIds": [],
"capabilities": {} "capabilities": {}

View File

@@ -78,7 +78,7 @@ class JwtServiceTest {
String token = jwtService.createAccessToken("user:admin", "user", roles); String token = jwtService.createAccessToken("user:admin", "user", roles);
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token); JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
assertEquals("user:admin", result.subject()); assertEquals("user:admin", result.subject());
assertEquals("user", result.group()); assertEquals("user", result.application());
assertEquals(roles, result.roles()); assertEquals(roles, result.roles());
} }
@@ -88,7 +88,7 @@ class JwtServiceTest {
String token = jwtService.createRefreshToken("agent-1", "default", roles); String token = jwtService.createRefreshToken("agent-1", "default", roles);
JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token); JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
assertEquals("agent-1", result.subject()); assertEquals("agent-1", result.subject());
assertEquals("default", result.group()); assertEquals("default", result.application());
assertEquals(roles, result.roles()); assertEquals(roles, result.roles());
} }

View File

@@ -32,7 +32,7 @@ class RegistrationSecurityIT extends AbstractPostgresIT {
{ {
"agentId": "%s", "agentId": "%s",
"name": "Security Test Agent", "name": "Security Test Agent",
"group": "test-group", "application": "test-group",
"version": "1.0.0", "version": "1.0.0",
"routeIds": [], "routeIds": [],
"capabilities": {} "capabilities": {}

View File

@@ -90,7 +90,7 @@ class SseSigningIT extends AbstractPostgresIT {
{ {
"agentId": "%s", "agentId": "%s",
"name": "SSE Signing Test Agent", "name": "SSE Signing Test Agent",
"group": "test-group", "application": "test-group",
"version": "1.0.0", "version": "1.0.0",
"routeIds": ["route-1"], "routeIds": ["route-1"],
"capabilities": {} "capabilities": {}

View File

@@ -54,10 +54,10 @@ class PostgresStatsStoreIT extends AbstractPostgresIT {
assertFalse(ts.buckets().isEmpty()); assertFalse(ts.buckets().isEmpty());
} }
private void insertExecution(String id, String routeId, String groupName, private void insertExecution(String id, String routeId, String applicationName,
String status, Instant startTime, long durationMs) { String status, Instant startTime, long durationMs) {
executionStore.upsert(new ExecutionRecord( executionStore.upsert(new ExecutionRecord(
id, routeId, "agent-1", groupName, status, null, null, id, routeId, "agent-1", applicationName, status, null, null,
startTime, startTime.plusMillis(durationMs), durationMs, startTime, startTime.plusMillis(durationMs), durationMs,
status.equals("FAILED") ? "error" : null, null, null)); status.equals("FAILED") ? "error" : null, null, null));
} }

View File

@@ -13,7 +13,7 @@ import java.util.Map;
* *
* @param id agent-provided persistent identifier * @param id agent-provided persistent identifier
* @param name human-readable agent name * @param name human-readable agent name
* @param group logical grouping (e.g., "order-service-prod") * @param application application name (e.g., "order-service-prod")
* @param version agent software version * @param version agent software version
* @param routeIds list of Camel route IDs managed by this agent * @param routeIds list of Camel route IDs managed by this agent
* @param capabilities agent-declared capabilities (free-form) * @param capabilities agent-declared capabilities (free-form)
@@ -25,7 +25,7 @@ import java.util.Map;
public record AgentInfo( public record AgentInfo(
String id, String id,
String name, String name,
String group, String application,
String version, String version,
List<String> routeIds, List<String> routeIds,
Map<String, Object> capabilities, Map<String, Object> capabilities,
@@ -36,28 +36,28 @@ public record AgentInfo(
) { ) {
public AgentInfo withState(AgentState newState) { public AgentInfo withState(AgentState newState) {
return new AgentInfo(id, name, group, version, routeIds, capabilities, return new AgentInfo(id, name, application, version, routeIds, capabilities,
newState, registeredAt, lastHeartbeat, staleTransitionTime); newState, registeredAt, lastHeartbeat, staleTransitionTime);
} }
public AgentInfo withLastHeartbeat(Instant newLastHeartbeat) { public AgentInfo withLastHeartbeat(Instant newLastHeartbeat) {
return new AgentInfo(id, name, group, version, routeIds, capabilities, return new AgentInfo(id, name, application, version, routeIds, capabilities,
state, registeredAt, newLastHeartbeat, staleTransitionTime); state, registeredAt, newLastHeartbeat, staleTransitionTime);
} }
public AgentInfo withRegisteredAt(Instant newRegisteredAt) { public AgentInfo withRegisteredAt(Instant newRegisteredAt) {
return new AgentInfo(id, name, group, version, routeIds, capabilities, return new AgentInfo(id, name, application, version, routeIds, capabilities,
state, newRegisteredAt, lastHeartbeat, staleTransitionTime); state, newRegisteredAt, lastHeartbeat, staleTransitionTime);
} }
public AgentInfo withStaleTransitionTime(Instant newStaleTransitionTime) { public AgentInfo withStaleTransitionTime(Instant newStaleTransitionTime) {
return new AgentInfo(id, name, group, version, routeIds, capabilities, return new AgentInfo(id, name, application, version, routeIds, capabilities,
state, registeredAt, lastHeartbeat, newStaleTransitionTime); state, registeredAt, lastHeartbeat, newStaleTransitionTime);
} }
public AgentInfo withMetadata(String name, String group, String version, public AgentInfo withMetadata(String name, String application, String version,
List<String> routeIds, Map<String, Object> capabilities) { List<String> routeIds, Map<String, Object> capabilities) {
return new AgentInfo(id, name, group, version, routeIds, capabilities, return new AgentInfo(id, name, application, version, routeIds, capabilities,
state, registeredAt, lastHeartbeat, staleTransitionTime); state, registeredAt, lastHeartbeat, staleTransitionTime);
} }
} }

View File

@@ -43,10 +43,10 @@ public class AgentRegistryService {
* Register a new agent or re-register an existing one. * Register a new agent or re-register an existing one.
* Re-registration updates metadata, transitions state to LIVE, and resets timestamps. * Re-registration updates metadata, transitions state to LIVE, and resets timestamps.
*/ */
public AgentInfo register(String id, String name, String group, String version, public AgentInfo register(String id, String name, String application, String version,
List<String> routeIds, Map<String, Object> capabilities) { List<String> routeIds, Map<String, Object> capabilities) {
Instant now = Instant.now(); Instant now = Instant.now();
AgentInfo newAgent = new AgentInfo(id, name, group, version, AgentInfo newAgent = new AgentInfo(id, name, application, version,
List.copyOf(routeIds), Map.copyOf(capabilities), List.copyOf(routeIds), Map.copyOf(capabilities),
AgentState.LIVE, now, now, null); AgentState.LIVE, now, now, null);
@@ -55,13 +55,13 @@ public class AgentRegistryService {
// Re-registration: update metadata, reset to LIVE // Re-registration: update metadata, reset to LIVE
log.info("Agent {} re-registering (was {})", id, existing.state()); log.info("Agent {} re-registering (was {})", id, existing.state());
return existing return existing
.withMetadata(name, group, version, List.copyOf(routeIds), Map.copyOf(capabilities)) .withMetadata(name, application, version, List.copyOf(routeIds), Map.copyOf(capabilities))
.withState(AgentState.LIVE) .withState(AgentState.LIVE)
.withLastHeartbeat(now) .withLastHeartbeat(now)
.withRegisteredAt(now) .withRegisteredAt(now)
.withStaleTransitionTime(null); .withStaleTransitionTime(null);
} }
log.info("Agent {} registered (name={}, group={})", id, name, group); log.info("Agent {} registered (name={}, application={})", id, name, application);
return newAgent; return newAgent;
}); });
@@ -168,11 +168,11 @@ public class AgentRegistryService {
} }
/** /**
* Return all agents belonging to the given application group. * Return all agents belonging to the given application.
*/ */
public List<AgentInfo> findByGroup(String group) { public List<AgentInfo> findByApplication(String application) {
return agents.values().stream() return agents.values().stream()
.filter(a -> group.equals(a.group())) .filter(a -> application.equals(a.application()))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }

View File

@@ -20,6 +20,7 @@ public class DetailService {
List<ProcessorNode> roots = buildTree(processors); List<ProcessorNode> roots = buildTree(processors);
return new ExecutionDetail( return new ExecutionDetail(
exec.executionId(), exec.routeId(), exec.agentId(), exec.executionId(), exec.routeId(), exec.agentId(),
exec.applicationName(),
exec.status(), exec.startTime(), exec.endTime(), exec.status(), exec.startTime(), exec.endTime(),
exec.durationMs() != null ? exec.durationMs() : 0L, exec.durationMs() != null ? exec.durationMs() : 0L,
exec.correlationId(), exec.exchangeId(), exec.correlationId(), exec.exchangeId(),

View File

@@ -27,6 +27,7 @@ public record ExecutionDetail(
String executionId, String executionId,
String routeId, String routeId,
String agentId, String agentId,
String applicationName,
String status, String status,
Instant startTime, Instant startTime,
Instant endTime, Instant endTime,

View File

@@ -74,7 +74,7 @@ public class SearchIndexer implements SearchIndexerStats {
.toList(); .toList();
searchIndex.index(new ExecutionDocument( searchIndex.index(new ExecutionDocument(
exec.executionId(), exec.routeId(), exec.agentId(), exec.groupName(), exec.executionId(), exec.routeId(), exec.agentId(), exec.applicationName(),
exec.status(), exec.correlationId(), exec.exchangeId(), exec.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));

View File

@@ -38,18 +38,18 @@ public class IngestionService {
this.bodySizeLimit = bodySizeLimit; this.bodySizeLimit = bodySizeLimit;
} }
public void ingestExecution(String agentId, String groupName, RouteExecution execution) { public void ingestExecution(String agentId, String applicationName, RouteExecution execution) {
ExecutionRecord record = toExecutionRecord(agentId, groupName, execution); ExecutionRecord record = toExecutionRecord(agentId, applicationName, execution);
executionStore.upsert(record); executionStore.upsert(record);
if (execution.getProcessors() != null && !execution.getProcessors().isEmpty()) { if (execution.getProcessors() != null && !execution.getProcessors().isEmpty()) {
List<ProcessorRecord> processors = flattenProcessors( List<ProcessorRecord> processors = flattenProcessors(
execution.getProcessors(), record.executionId(), execution.getProcessors(), record.executionId(),
record.startTime(), groupName, execution.getRouteId(), record.startTime(), applicationName, execution.getRouteId(),
null, 0); null, 0);
executionStore.upsertProcessors( executionStore.upsertProcessors(
record.executionId(), record.startTime(), record.executionId(), record.startTime(),
groupName, execution.getRouteId(), processors); applicationName, execution.getRouteId(), processors);
} }
eventPublisher.accept(new ExecutionUpdatedEvent( eventPublisher.accept(new ExecutionUpdatedEvent(
@@ -72,13 +72,13 @@ public class IngestionService {
return metricsBuffer; return metricsBuffer;
} }
private ExecutionRecord toExecutionRecord(String agentId, String groupName, private ExecutionRecord toExecutionRecord(String agentId, String applicationName,
RouteExecution exec) { RouteExecution exec) {
String diagramHash = diagramStore String diagramHash = diagramStore
.findContentHashForRoute(exec.getRouteId(), agentId) .findContentHashForRoute(exec.getRouteId(), agentId)
.orElse(""); .orElse("");
return new ExecutionRecord( return new ExecutionRecord(
exec.getExchangeId(), exec.getRouteId(), agentId, groupName, exec.getExchangeId(), exec.getRouteId(), agentId, applicationName,
exec.getStatus() != null ? exec.getStatus().name() : "RUNNING", exec.getStatus() != null ? exec.getStatus().name() : "RUNNING",
exec.getCorrelationId(), exec.getExchangeId(), exec.getCorrelationId(), exec.getExchangeId(),
exec.getStartTime(), exec.getEndTime(), exec.getStartTime(), exec.getEndTime(),
@@ -90,13 +90,13 @@ public class IngestionService {
private List<ProcessorRecord> flattenProcessors( private List<ProcessorRecord> flattenProcessors(
List<ProcessorExecution> processors, String executionId, List<ProcessorExecution> processors, String executionId,
java.time.Instant execStartTime, String groupName, String routeId, java.time.Instant execStartTime, String applicationName, String routeId,
String parentProcessorId, int depth) { String parentProcessorId, int depth) {
List<ProcessorRecord> flat = new ArrayList<>(); List<ProcessorRecord> flat = new ArrayList<>();
for (ProcessorExecution p : processors) { for (ProcessorExecution p : processors) {
flat.add(new ProcessorRecord( flat.add(new ProcessorRecord(
executionId, p.getProcessorId(), p.getProcessorType(), executionId, p.getProcessorId(), p.getProcessorType(),
p.getDiagramNodeId(), groupName, routeId, p.getDiagramNodeId(), applicationName, routeId,
depth, parentProcessorId, depth, parentProcessorId,
p.getStatus() != null ? p.getStatus().name() : "RUNNING", p.getStatus() != null ? p.getStatus().name() : "RUNNING",
p.getStartTime() != null ? p.getStartTime() : execStartTime, p.getStartTime() != null ? p.getStartTime() : execStartTime,
@@ -109,7 +109,7 @@ public class IngestionService {
if (p.getChildren() != null) { if (p.getChildren() != null) {
flat.addAll(flattenProcessors( flat.addAll(flattenProcessors(
p.getChildren(), executionId, execStartTime, p.getChildren(), executionId, execStartTime,
groupName, routeId, p.getProcessorId(), depth + 1)); applicationName, routeId, p.getProcessorId(), depth + 1));
} }
} }
return flat; return flat;

View File

@@ -23,6 +23,7 @@ public record ExecutionSummary(
String executionId, String executionId,
String routeId, String routeId,
String agentId, String agentId,
String applicationName,
String status, String status,
Instant startTime, Instant startTime,
Instant endTime, Instant endTime,

View File

@@ -22,7 +22,7 @@ import java.util.List;
* @param routeId exact match on route_id * @param routeId exact match on route_id
* @param agentId exact match on agent_id * @param agentId exact match on agent_id
* @param processorType matches processor_types array via has() * @param processorType matches processor_types array via has()
* @param group application group filter (resolved to agentIds server-side) * @param application application name filter (resolved to agentIds server-side)
* @param agentIds list of agent IDs (resolved from group, used for IN clause) * @param agentIds list of agent IDs (resolved from group, used for IN clause)
* @param offset pagination offset (0-based) * @param offset pagination offset (0-based)
* @param limit page size (default 50, max 500) * @param limit page size (default 50, max 500)
@@ -43,7 +43,7 @@ public record SearchRequest(
String routeId, String routeId,
String agentId, String agentId,
String processorType, String processorType,
String group, String application,
List<String> agentIds, List<String> agentIds,
int offset, int offset,
int limit, int limit,
@@ -80,12 +80,12 @@ public record SearchRequest(
return SORT_FIELD_TO_COLUMN.getOrDefault(sortField, "start_time"); return SORT_FIELD_TO_COLUMN.getOrDefault(sortField, "start_time");
} }
/** Create a copy with resolved agentIds (from group lookup). */ /** Create a copy with resolved agentIds (from application name lookup). */
public SearchRequest withAgentIds(List<String> resolvedAgentIds) { public SearchRequest withAgentIds(List<String> resolvedAgentIds) {
return new SearchRequest( return new SearchRequest(
status, timeFrom, timeTo, durationMin, durationMax, correlationId, status, timeFrom, timeTo, durationMin, durationMax, correlationId,
text, textInBody, textInHeaders, textInErrors, text, textInBody, textInHeaders, textInErrors,
routeId, agentId, processorType, group, resolvedAgentIds, routeId, agentId, processorType, application, resolvedAgentIds,
offset, limit, sortField, sortDir offset, limit, sortField, sortDir
); );
} }

View File

@@ -28,6 +28,10 @@ public class SearchService {
return statsStore.stats(from, to); return statsStore.stats(from, to);
} }
public ExecutionStats statsForApp(Instant from, Instant to, String applicationName) {
return statsStore.statsForApp(from, to, applicationName);
}
public ExecutionStats stats(Instant from, Instant to, String routeId, List<String> agentIds) { public ExecutionStats stats(Instant from, Instant to, String routeId, List<String> agentIds) {
return statsStore.statsForRoute(from, to, routeId, agentIds); return statsStore.statsForRoute(from, to, routeId, agentIds);
} }
@@ -36,6 +40,10 @@ public class SearchService {
return statsStore.timeseries(from, to, bucketCount); return statsStore.timeseries(from, to, bucketCount);
} }
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName) {
return statsStore.timeseriesForApp(from, to, bucketCount, applicationName);
}
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount, public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount,
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);

View File

@@ -14,21 +14,21 @@ public interface JwtService {
/** /**
* Validated JWT payload. * Validated JWT payload.
* *
* @param subject the {@code sub} claim (agent ID or {@code user:<username>}) * @param subject the {@code sub} claim (agent ID or {@code user:<username>})
* @param group the {@code group} claim * @param application the {@code group} claim (application name)
* @param roles the {@code roles} claim (e.g. {@code ["AGENT"]}, {@code ["ADMIN"]}) * @param roles the {@code roles} claim (e.g. {@code ["AGENT"]}, {@code ["ADMIN"]})
*/ */
record JwtValidationResult(String subject, String group, List<String> roles) {} record JwtValidationResult(String subject, String application, List<String> roles) {}
/** /**
* Creates a signed access JWT with the given subject, group, and roles. * Creates a signed access JWT with the given subject, application, and roles.
*/ */
String createAccessToken(String subject, String group, List<String> roles); String createAccessToken(String subject, String application, List<String> roles);
/** /**
* Creates a signed refresh JWT with the given subject, group, and roles. * Creates a signed refresh JWT with the given subject, application, and roles.
*/ */
String createRefreshToken(String subject, String group, List<String> roles); String createRefreshToken(String subject, String application, List<String> roles);
/** /**
* Validates an access token and returns the full validation result. * Validates an access token and returns the full validation result.
@@ -46,12 +46,12 @@ public interface JwtService {
// --- Backward-compatible defaults (delegate to role-aware methods) --- // --- Backward-compatible defaults (delegate to role-aware methods) ---
default String createAccessToken(String subject, String group) { default String createAccessToken(String subject, String application) {
return createAccessToken(subject, group, List.of()); return createAccessToken(subject, application, List.of());
} }
default String createRefreshToken(String subject, String group) { default String createRefreshToken(String subject, String application) {
return createRefreshToken(subject, group, List.of()); return createRefreshToken(subject, application, List.of());
} }
default String validateAndExtractAgentId(String token) { default String validateAndExtractAgentId(String token) {

View File

@@ -9,7 +9,7 @@ public interface ExecutionStore {
void upsert(ExecutionRecord execution); void upsert(ExecutionRecord execution);
void upsertProcessors(String executionId, Instant startTime, void upsertProcessors(String executionId, Instant startTime,
String groupName, String routeId, String applicationName, String routeId,
List<ProcessorRecord> processors); List<ProcessorRecord> processors);
Optional<ExecutionRecord> findById(String executionId); Optional<ExecutionRecord> findById(String executionId);
@@ -17,7 +17,7 @@ public interface ExecutionStore {
List<ProcessorRecord> findProcessors(String executionId); List<ProcessorRecord> findProcessors(String executionId);
record ExecutionRecord( record ExecutionRecord(
String executionId, String routeId, String agentId, String groupName, String executionId, String routeId, String agentId, String applicationName,
String status, String correlationId, String exchangeId, String status, String correlationId, String exchangeId,
Instant startTime, Instant endTime, Long durationMs, Instant startTime, Instant endTime, Long durationMs,
String errorMessage, String errorStacktrace, String diagramContentHash String errorMessage, String errorStacktrace, String diagramContentHash
@@ -25,7 +25,7 @@ public interface ExecutionStore {
record ProcessorRecord( record ProcessorRecord(
String executionId, String processorId, String processorType, String executionId, String processorId, String processorType,
String diagramNodeId, String groupName, String routeId, String diagramNodeId, String applicationName, String routeId,
int depth, String parentProcessorId, String status, int depth, String parentProcessorId, String status,
Instant startTime, Instant endTime, Long durationMs, Instant startTime, Instant endTime, Long durationMs,
String errorMessage, String errorStacktrace, String errorMessage, String errorStacktrace,

View File

@@ -12,7 +12,7 @@ public interface StatsStore {
ExecutionStats stats(Instant from, Instant to); ExecutionStats stats(Instant from, Instant to);
// Per-app stats (stats_1m_app) // Per-app stats (stats_1m_app)
ExecutionStats statsForApp(Instant from, Instant to, String groupName); ExecutionStats statsForApp(Instant from, Instant to, String applicationName);
// Per-route stats (stats_1m_route), optionally scoped to specific agents // Per-route stats (stats_1m_route), optionally scoped to specific agents
ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds); ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds);
@@ -24,7 +24,7 @@ public interface StatsStore {
StatsTimeseries timeseries(Instant from, Instant to, int bucketCount); StatsTimeseries timeseries(Instant from, Instant to, int bucketCount);
// Per-app timeseries // Per-app timeseries
StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String groupName); StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName);
// Per-route timeseries, optionally scoped to specific agents // Per-route timeseries, optionally scoped to specific agents
StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount, StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount,

View File

@@ -4,7 +4,7 @@ import java.time.Instant;
import java.util.List; import java.util.List;
public record ExecutionDocument( public record ExecutionDocument(
String executionId, String routeId, String agentId, String groupName, String executionId, String routeId, String agentId, String applicationName,
String status, String correlationId, String exchangeId, String status, String correlationId, String exchangeId,
Instant startTime, Instant endTime, Long durationMs, Instant startTime, Instant endTime, Long durationMs,
String errorMessage, String errorStacktrace, String errorMessage, String errorStacktrace,

View File

@@ -32,7 +32,7 @@ class AgentRegistryServiceTest {
assertThat(agent).isNotNull(); assertThat(agent).isNotNull();
assertThat(agent.id()).isEqualTo("agent-1"); assertThat(agent.id()).isEqualTo("agent-1");
assertThat(agent.name()).isEqualTo("Order Agent"); assertThat(agent.name()).isEqualTo("Order Agent");
assertThat(agent.group()).isEqualTo("order-svc"); assertThat(agent.application()).isEqualTo("order-svc");
assertThat(agent.version()).isEqualTo("1.0.0"); assertThat(agent.version()).isEqualTo("1.0.0");
assertThat(agent.routeIds()).containsExactly("route1", "route2"); assertThat(agent.routeIds()).containsExactly("route1", "route2");
assertThat(agent.capabilities()).containsEntry("feature", "tracing"); assertThat(agent.capabilities()).containsEntry("feature", "tracing");
@@ -52,7 +52,7 @@ class AgentRegistryServiceTest {
assertThat(updated.id()).isEqualTo("agent-1"); assertThat(updated.id()).isEqualTo("agent-1");
assertThat(updated.name()).isEqualTo("New Name"); assertThat(updated.name()).isEqualTo("New Name");
assertThat(updated.group()).isEqualTo("new-group"); assertThat(updated.application()).isEqualTo("new-group");
assertThat(updated.version()).isEqualTo("2.0.0"); assertThat(updated.version()).isEqualTo("2.0.0");
assertThat(updated.routeIds()).containsExactly("route1", "route2"); assertThat(updated.routeIds()).containsExactly("route1", "route2");
assertThat(updated.capabilities()).containsEntry("new", "cap"); assertThat(updated.capabilities()).containsEntry("new", "cap");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,142 @@
# RBAC CRUD Gaps — Design Specification
## Goal
Add missing CRUD and assignment UI to the RBAC management page, fix date formatting, seed a built-in Admins group, and fix dashboard diagram ordering.
## References
- Parent spec: `docs/superpowers/specs/2026-03-17-rbac-management-design.md`
- Visual prototype: `examples/RBAC/rbac_management_ui.html`
---
## Changes
### 1. Users Tab — Delete + Assignments
Users cannot be created manually (they arrive via login). The detail pane gains:
- **Delete button** in the detail header area. Uses existing `ConfirmDeleteDialog` with the user's `displayName` as the confirmation string. Calls `useDeleteUser()`. **Guard:** the currently authenticated user (from `useAuthStore`) cannot delete themselves — button disabled with tooltip "Cannot delete your own account".
- **Group membership section** — "+ Add" chip opens a **multi-select dropdown** listing all groups the user is NOT already a member of. Checkboxes for batch selection, "Apply" button to commit. Calls are batched via `Promise.allSettled()` — if any fail, show an inline error, invalidate queries regardless to refresh. Existing group chips gain an "x" remove button calling `useRemoveUserFromGroup()`.
- **Direct roles section** — the existing "Effective roles" section renders both direct and inherited roles. The "+ Add" multi-select dropdown lists roles not yet directly assigned. Calls `useAssignRoleToUser()` (batched via `Promise.allSettled()`). Direct role chips gain an "x" button calling `useRemoveRoleFromUser()`. Inherited role chips (dashed border) do NOT get remove buttons — they can only be removed by changing group membership or group role assignments.
- **Created field** — change from date-only to full date+time: `new Date(createdAt).toLocaleString()`.
- **Mutation button states** — all action buttons (delete, remove chip "x") disable while their mutation is in-flight to prevent double-clicks.
### 2. Groups Tab — CRUD + Assignments
- **"+ Add group" button** in the panel header (`.btnAdd` style exists). Opens an inline form below the search bar with: name text input, optional parent group dropdown, "Create" button. Calls `useCreateGroup()`. Form clears and closes on success. On error: shows error message inline.
- **Delete button** in detail pane header. Uses `ConfirmDeleteDialog` with group name. Calls `useDeleteGroup()`. Resets selected group. **Guard:** the built-in Admins group (`SystemRole.ADMINS_GROUP_ID`) cannot be deleted — button disabled with tooltip "Built-in group cannot be deleted".
- **Assigned roles section** — "+ Add" multi-select dropdown listing roles not yet assigned to this group. Batched via `Promise.allSettled()`. Calls `useAssignRoleToGroup()`. Role chips gain "x" for `useRemoveRoleFromGroup()`.
- **Parent group** — shown as a dropdown in the detail header area, allowing re-parenting. Calls `useUpdateGroup()`. The dropdown excludes the group itself and its transitive descendants (cycle prevention — requires recursive traversal of `childGroups` on each `GroupDetail`). Setting to empty/none makes it top-level.
### 3. Roles Tab — CRUD
- **"+ Add role" button** in panel header. Opens an inline form: name (required), description (optional), scope (optional, free-text, defaults to "custom"). Calls `useCreateRole()`.
- **Delete button** in detail pane header. **Disabled for system roles** (lock icon + tooltip "System roles cannot be deleted"). Custom roles use `ConfirmDeleteDialog` with role name → `useDeleteRole()`.
- No assignment UI on the roles tab — assignments are managed from the User and Group detail panes.
### 4. Multi-Select Dropdown Component
A reusable component used across all assignment actions:
```
Props:
items: { id: string; label: string }[] — available items to pick from
onApply: (selectedIds: string[]) => void — called with all checked IDs
placeholder?: string — search filter placeholder
```
Behavior:
- Opens as a positioned dropdown below the "+ Add" chip
- Search/filter input at top
- Checkbox list of items (max-height with scroll)
- "Apply" button at bottom (disabled when nothing selected)
- Closes on Apply, Escape, or click-outside
- Shows count badge on Apply button: "Apply (3)"
Styling: background `var(--bg-raised)`, border `var(--border)`, border-radius `var(--radius-md)`, items with `var(--bg-hover)` on hover, checkboxes with `var(--amber)` accent.
### 5. Inline Create Form
A reusable pattern for "Add group" and "Add role":
- Appears below the search bar in the list pane, pushing content down
- Input fields with labels
- "Create" and "Cancel" buttons
- On success: closes form, clears inputs, new entity appears in list
- On error: shows error message inline
- "Create" button disabled while mutation is in-flight
### 6. Built-in Admins Group Seed
**Database migration** — new `V2__admin_group_seed.sql` (V1 is already deployed, V2-V10 were deleted in the migration consolidation so V2 is safe):
```sql
-- Built-in Admins group
INSERT INTO groups (id, name) VALUES
('00000000-0000-0000-0000-000000000010', 'Admins');
-- Assign ADMIN role to Admins group
INSERT INTO group_roles (group_id, role_id) VALUES
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');
```
**SystemRole.java** — add constants:
```java
public static final UUID ADMINS_GROUP_ID = UUID.fromString("00000000-0000-0000-0000-000000000010");
```
**UiAuthController.login()** — after upserting the user and assigning ADMIN role, also add to Admins group:
```java
rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID);
```
**Frontend guard:** The Admins group UUID is hardcoded as a constant in the frontend to disable deletion. Alternatively, check if a group's ID matches a known system group ID.
### 7. Dashboard Diagram Ordering
The inheritance diagram's three columns (Groups → Roles → Users) must show items in a consistent, matching order:
- **Groups column**: alphabetical by name, children indented under parents
- **Roles column**: iterate groups top-to-bottom, collect their direct roles, deduplicate preserving first-seen order. Roles not assigned to any group are omitted from the diagram.
- **Users column**: alphabetical by display name
Sort explicitly in `DashboardTab.tsx` before rendering.
---
## Files Changed
### Frontend — Modified
| File | Change |
|---|---|
| `ui/src/pages/admin/rbac/UsersTab.tsx` | Delete button, group/role assignment dropdowns, date format fix, self-delete guard |
| `ui/src/pages/admin/rbac/GroupsTab.tsx` | Add group form, delete button, role assignment dropdown, parent group dropdown, Admins guard |
| `ui/src/pages/admin/rbac/RolesTab.tsx` | Add role form, delete button (disabled for system) |
| `ui/src/pages/admin/rbac/DashboardTab.tsx` | Sort diagram columns consistently |
| `ui/src/pages/admin/rbac/RbacPage.module.css` | Styles for multi-select dropdown, inline create form, delete button, action chips, remove buttons |
### Frontend — New
| File | Responsibility |
|---|---|
| `ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx` | Reusable multi-select picker with search, checkboxes, batch apply |
### Backend — Modified
| File | Change |
|---|---|
| `cameleer3-server-core/.../rbac/SystemRole.java` | Add `ADMINS_GROUP_ID` constant |
| `cameleer3-server-app/.../security/UiAuthController.java` | Add admin user to Admins group on login |
### Backend — New Migration
| File | Change |
|---|---|
| `cameleer3-server-app/src/main/resources/db/migration/V2__admin_group_seed.sql` | Seed Admins group + ADMIN role assignment |
---
## Out of Scope
- Editing user profile fields (name, email) — users are managed by their identity provider
- Drag-and-drop group hierarchy management
- Role permission editing (custom roles have no effect on Spring Security yet)

View File

@@ -0,0 +1,327 @@
# RBAC Management — Design Specification
## Goal
Implement a full RBAC management system (issue #41) with group hierarchy, role inheritance, and a management UI integrated into the admin section. Replace the flat `users.roles` text array with a proper relational model.
## References
- Functional spec: `examples/RBAC/rbac-ui-spec.md`
- Visual prototype: `examples/RBAC/rbac_management_ui.html`
---
## Backend
### Database Schema
Squash V1V10 Flyway migrations into a single `V1__init.sql`. The `users` table drops the `roles TEXT[]` column. New tables:
```sql
CREATE TABLE users (
user_id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
email TEXT,
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- RBAC: all roles — system roles seeded with fixed UUIDs, custom roles created by admins
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'custom',
system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Seed system roles with fixed UUIDs (stable across environments)
INSERT INTO roles (id, name, description, scope, system) VALUES
('00000000-0000-0000-0000-000000000001', 'AGENT', 'Agent registration and data ingestion', 'system-wide', true),
('00000000-0000-0000-0000-000000000002', 'VIEWER', 'Read-only access to dashboards and data', 'system-wide', true),
('00000000-0000-0000-0000-000000000003', 'OPERATOR', 'Operational commands (start/stop/configure agents)', 'system-wide', true),
('00000000-0000-0000-0000-000000000004', 'ADMIN', 'Full administrative access', 'system-wide', true);
-- RBAC: groups with self-referential hierarchy
CREATE TABLE groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
parent_group_id UUID REFERENCES groups(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Join: roles assigned to groups (system + custom)
CREATE TABLE group_roles (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, role_id)
);
-- Join: direct group membership for users
CREATE TABLE user_groups (
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
-- Join: direct role assignments to users (system + custom)
CREATE TABLE user_roles (
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
-- Indexes for join query performance
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX idx_user_groups_user_id ON user_groups(user_id);
CREATE INDEX idx_group_roles_group_id ON group_roles(group_id);
CREATE INDEX idx_groups_parent ON groups(parent_group_id);
```
Note: `roles TEXT[]` column is removed from `users`. All roles (system and custom) live in the `roles` table. System roles are seeded rows with `system = true` and fixed UUIDs — the application prevents their deletion or modification.
### System Roles
The four system roles (AGENT, VIEWER, OPERATOR, ADMIN) are:
- Seeded as rows in the `roles` table with `system = true` and fixed UUIDs
- Assigned to users via the same `user_roles` join table as custom roles
- Protected by application logic: creation, deletion, and name/scope modification are rejected
- Displayed in the UI as read-only entries (lock icon, non-deletable)
- Used by Spring Security / JWT for authorization decisions
Custom roles (`system = false`) are application-defined and have no effect on Spring Security — they serve the RBAC management model only (for future permission expansion).
The `scope` field distinguishes role domains: system roles use `system-wide`, custom roles can use descriptive scopes like `monitoring:read`, `config:write` for future permission gating.
### Domain Model (Java)
```java
// Existing, modified — drop roles field
public record UserInfo(
String userId,
String provider,
String email,
String displayName,
Instant createdAt
) {}
// New — enriched user for admin API responses
public record UserDetail(
String userId,
String provider,
String email,
String displayName,
Instant createdAt,
List<RoleSummary> directRoles, // from user_roles join (system + custom)
List<GroupSummary> directGroups, // from user_groups join
List<RoleSummary> effectiveRoles, // computed: union of direct + inherited via groups
List<GroupSummary> effectiveGroups // computed: direct groups + their ancestor chain
) {}
public record GroupDetail(
UUID id,
String name,
UUID parentGroupId, // nullable
Instant createdAt,
List<RoleSummary> directRoles,
List<RoleSummary> effectiveRoles, // direct + inherited from parent chain
List<UserSummary> members, // direct members
List<GroupSummary> childGroups
) {}
public record RoleDetail(
UUID id,
String name,
String description,
String scope,
boolean system, // true for AGENT/VIEWER/OPERATOR/ADMIN
Instant createdAt,
List<GroupSummary> assignedGroups,
List<UserSummary> directUsers,
List<UserSummary> effectivePrincipals // all users who hold this role
) {}
// Summaries for embedding in detail responses
public record UserSummary(String userId, String displayName, String provider) {}
public record GroupSummary(UUID id, String name) {}
public record RoleSummary(UUID id, String name, boolean system, String source) {}
// source: "direct" | group name (for inherited)
```
### Inheritance Logic
Server-side computation in a service class (e.g., `RbacService`):
1. **Effective groups for user**: Start from `user_groups` (direct memberships), then for each group walk `parent_group_id` chain upward to collect all ancestor groups. The union is every group the user is transitively a member of.
2. **Effective roles for user**: Direct `user_roles` + all `group_roles` for every effective group. Both system and custom roles flow through the same path.
3. **Effective roles for group**: Direct `group_roles` + inherited from parent chain.
4. **Effective principals for role**: All users who hold the role directly + all users in any group that has the role (transitively).
No role negation — roles only grant, never deny.
**Cycle detection**: When setting `parent_group_id` on a group, the application must walk the proposed parent chain upward and reject the update if it would create a cycle (i.e., the group appears in its own ancestor chain). Return HTTP 409 Conflict.
### Auth Integration
`JwtService` and `SecurityConfig` read system roles from `user_roles` joined to `roles WHERE system = true`, instead of `users.roles`. The `UserRepository` methods that currently read/write `users.roles` are updated to use the join table. JWT claims remain unchanged (`roles: ["ADMIN", "VIEWER"]`).
OIDC auto-signup: When a user is auto-registered via OIDC token exchange, they get a row in `users` with `provider = "oidc:<issuer>"` and a default system role (VIEWER) via `user_roles`. No group membership by default.
### API Endpoints
All under `/api/v1/admin/` prefix, protected by `@PreAuthorize("hasRole('ADMIN')")`.
The existing `PUT /users/{userId}/roles` bulk endpoint is removed. Role assignments use individual add/remove endpoints.
All mutation endpoints log to the `AuditService` (category: `USER_MGMT` for user operations, `RBAC` for group/role operations).
**Users** — response type: `UserDetail`
| Method | Path | Description | Request Body |
|---|---|---|---|
| GET | `/users` | List all users with effective roles/groups | — |
| GET | `/users/{id}` | Full user detail | — |
| POST | `/users/{id}/roles/{roleId}` | Assign role to user (system or custom) | — |
| DELETE | `/users/{id}/roles/{roleId}` | Remove role from user | — |
| POST | `/users/{id}/groups/{groupId}` | Add user to group | — |
| DELETE | `/users/{id}/groups/{groupId}` | Remove user from group | — |
| DELETE | `/users/{id}` | Delete user | — |
**Groups** — response type: `GroupDetail`
| Method | Path | Description | Request Body |
|---|---|---|---|
| GET | `/groups` | List all groups with hierarchy | — |
| GET | `/groups/{id}` | Full group detail | — |
| POST | `/groups` | Create group | `{ name, parentGroupId? }` |
| PUT | `/groups/{id}` | Update group | `{ name?, parentGroupId? }` — returns 409 on cycle |
| DELETE | `/groups/{id}` | Delete group — cascades role/member associations; child groups become top-level (parent set to null) | — |
| POST | `/groups/{id}/roles/{roleId}` | Assign role to group | — |
| DELETE | `/groups/{id}/roles/{roleId}` | Remove role from group | — |
**Roles** — response type: `RoleDetail`
| Method | Path | Description | Request Body |
|---|---|---|---|
| GET | `/roles` | List all roles (system + custom) | — |
| GET | `/roles/{id}` | Role detail | — |
| POST | `/roles` | Create custom role | `{ name, description?, scope? }` |
| PUT | `/roles/{id}` | Update custom role (rejects system roles) | `{ name?, description?, scope? }` |
| DELETE | `/roles/{id}` | Delete custom role (rejects system roles) | — |
**Dashboard:**
| Method | Path | Description |
|---|---|---|
| GET | `/rbac/stats` | `{ userCount, activeUserCount, groupCount, maxGroupDepth, roleCount }` |
---
## Frontend
### Routing
New route at `/admin/rbac` in `router.tsx`, lazy-loaded:
```tsx
const RbacPage = lazy(() => import('./pages/admin/rbac/RbacPage').then(m => ({ default: m.RbacPage })));
// ...
{ path: 'admin/rbac', element: <Suspense fallback={null}><RbacPage /></Suspense> }
```
Update `AppSidebar` ADMIN_LINKS to add `{ to: '/admin/rbac', label: 'User Management' }`.
### Component Structure
```
pages/admin/rbac/
├── RbacPage.tsx ← ADMIN role gate + tab navigation
├── RbacPage.module.css ← All RBAC-specific styles
├── DashboardTab.tsx ← Stat cards + inheritance diagram
├── UsersTab.tsx ← Split pane orchestrator
├── GroupsTab.tsx ← Split pane orchestrator
├── RolesTab.tsx ← Split pane orchestrator
├── components/
│ ├── EntityListPane.tsx ← Reusable: search input + scrollable card list
│ ├── EntityCard.tsx ← Single list row: avatar, name, meta, tags, status dot
│ ├── UserDetail.tsx ← Header, fields, groups, effective roles, group tree
│ ├── GroupDetail.tsx ← Header, fields, members, children, roles, hierarchy
│ ├── RoleDetail.tsx ← Header, fields, assigned groups/users, effective principals
│ ├── InheritanceChip.tsx ← Chip with dashed border + "↑ Source" annotation
│ ├── GroupTree.tsx ← Indented tree with corner connectors
│ ├── EntityAvatar.tsx ← Circle (user), rounded-square (group/role), color by type
│ ├── OidcBadge.tsx ← Small badge showing OIDC provider origin
│ ├── InheritanceDiagram.tsx ← Three-column Groups→Roles→Users read-only diagram
│ └── InheritanceNote.tsx ← Green-bordered explanation block
api/queries/admin/
│ └── rbac.ts ← useUsers, useUser, useGroups, useGroup, useRoles, useRole, useRbacStats + mutation hooks
```
### Tab Navigation
`RbacPage` uses a horizontal tab bar (Dashboard | Users | Groups | Roles) with URL-synced active state via query parameter (`?tab=users`). Each tab renders its content below the tab bar in the full main panel area.
### Split Pane Layout
Users, Groups, and Roles tabs share the same layout:
- **Left (52%)**: `EntityListPane` with search input + scrollable entity cards
- **Right (48%)**: Detail pane showing selected entity, or empty state prompt
- Resizable via `ResizableDivider` (existing shared component)
### Entity Card Patterns
**User card:** Circle avatar (initials, blue tint) + name + email/primary-group meta + role tags (amber) + group tags (green) + status dot + OIDC badge if `provider !== "local"`
**Group card:** Rounded-square avatar (initials, green/amber/red by domain) + name + parent/member-count meta + role tags (direct solid, inherited faded+italic)
**Role card:** Rounded-square avatar (initials, amber tint) + name + description/assignment-count meta + assigned-to tags. System roles show a lock icon.
### Badge/Chip Styling
Following the spec and existing CSS token system:
| Chip type | Background | Border | Text |
|---|---|---|---|
| Role (direct) | `var(--amber-dim)` | solid `var(--amber)` | amber text |
| Role (inherited) | transparent | dashed `var(--amber)` | faded amber, italic |
| Group | `var(--green-dim)` / `#E1F5EE` | solid green | green text |
| OIDC badge | `var(--cyan-dim)` | solid cyan | cyan text, shows provider |
| System role | Same as role but with lock icon | — | — |
Inherited role chips include `↑ GroupName` annotation in the detail pane.
### OIDC Badge
Displayed on user cards and user detail when `provider !== "local"`. Shows a small cyan-tinted pill with the provider name (e.g., "OIDC" or the issuer hostname). Positioned after the user's name in the card, and as a field in the detail pane.
### Search
Client-side filtering on entity list panes — filter by any visible text (name, email, group, role). Sufficient for the expected user count.
### State Management
- React Query for all server state (users, groups, roles, stats)
- Local `useState` for selected entity, search filter, active tab
- Mutations invalidate related queries (e.g., updating a user's groups invalidates both user and group queries)
---
## Migration Strategy
1. Delete all V1V10 migration files
2. Create single `V1__init.sql` containing the full consolidated schema
3. Deployed environments: drop and recreate the database (data loss accepted)
4. CI/CD: no special handling — clean database on deploy
5. Update `application.yml` if needed: `spring.flyway.clean-on-validation-error: true` or manual DB drop
---
## Out of Scope
- Permission-based access control (custom roles don't gate endpoints — system roles do)
- Audit log panel within RBAC (existing audit log page covers this)
- Bulk import/export of users or groups
- SCIM provisioning
- Role negation / deny rules

View File

@@ -0,0 +1,261 @@
# Cameleer3 Dashboard Review -- Senior Camel Developer Perspective
**Reviewer**: Senior Apache Camel Developer (10+ years, Java DSL / Spring Boot)
**Artifact reviewed**: `mock-v2-light.html` -- Operations Dashboard (v2 synthesis)
**Date**: 2026-03-17
---
## 1. What the Dashboard Gets RIGHT
### Business ID as First-Class Citizen
The Order ID and Customer columns in the execution table are exactly what I need. When support calls me about "order OP-88421", I can paste that into the search and find the execution immediately. Every other monitoring tool I have used forces me to map business IDs to correlation IDs manually. This alone would save me 10-15 minutes per incident.
### Inline Error Previews
Showing the exception message directly in the table row without requiring a click-through is genuinely useful. The two error examples in the mock (`HttpOperationFailedException` with a 504, `SQLTransientConnectionException` with HikariPool exhaustion) are realistic Camel exceptions. I can scan the error list and immediately tell whether it is a downstream timeout or a connection pool issue. That distinction determines whether I investigate our code or page the DBA.
### Processor Timeline (Gantt View)
The processor timeline in the detail panel is the single most valuable feature. Seeing that `to(payment-api)` consumed 280ms out of a 412ms total execution, while `enrich(inventory)` took 85ms, immediately tells me WHERE the bottleneck is. In my experience, 95% of Camel performance issues are in external calls, and this view pinpoints them. The color coding (green/yellow/red) for processor bars makes the slow step obvious at a glance.
### SLA Awareness Baked In
The SLA threshold line on the latency chart, the "SLA" tag on slow durations, and the "CLOSE" warning on the p99 card are exactly the kind of proactive indicators I want. Most monitoring tools show me raw numbers; this dashboard shows me numbers in context. I know immediately that 287ms p99 is dangerously close to our 300ms SLA.
### Shift-Aware Time Context
The "since 06:00" shift concept is something I have never seen in a developer tool but actually matches how production support works. When I start my day shift, I want to see what happened overnight and what is happening now, not a rolling 24-hour window that mixes yesterday afternoon with this morning.
### Agent Health in Sidebar
Seeing agent status (live/stale/dead), throughput per agent, and error rates at a glance in the sidebar is practical. When an agent goes stale, I know to check if a pod restarted or if there is a network partition.
### Application-to-Route Navigation Hierarchy
The sidebar tree (Applications > order-service > Routes > order-intake, order-enrichment, etc.) matches how I think about Camel deployments. I have multiple applications, each with multiple routes. Being able to filter by application first, then drill into routes, is the right hierarchy.
---
## 2. What is MISSING or Could Be Better
### 2.1 Exchange Body/Header Inspection -- CRITICAL GAP
**Pain point**: The "Exchange" tab exists in the detail panel tabs but its content is not shown. This is the single most important debugging feature for a Camel developer. When a message fails at step 5 of 7, I need to see:
- What was the original inbound message (before any transformation)?
- What did the exchange body look like at each processor step?
- Which headers were present at each step, and which were added/removed?
- What was the exception body (often different from the exception message)?
**How to address it**: The Exchange tab should show a step-by-step diff view of the exchange. For each processor in the route, show the body (with a JSON/XML pretty-printer) and the headers map. Highlight headers that were added at that step. Allow comparing any two steps side-by-side. Show the original inbound message prominently at the top.
**Priority**: **Must-Have**. Without this, the dashboard is an operations monitor, not a debugging tool. This is the difference between "I can see something failed" and "I can see WHY it failed."
### 2.2 Route Diagram / Visual Graph -- MENTIONED BUT NOT SHOWN
**Pain point**: The "View Route Diagram" button exists in the detail actions, but there is no mockup of what the route diagram looks like. As a Camel developer, I need to see the DAG (directed acyclic graph) of my route: from(jms:orders) -> unmarshal -> validate -> choice -> [branch A: enrich -> transform -> to(http)] [branch B: log -> to(dlq)]. I also need to see execution overlay on the diagram -- which path did THIS specific exchange take, and how long did each node take.
**How to address it**: Add a Route Diagram page/view that shows:
- The route definition as an interactive DAG (nodes = processors, edges = flow)
- Execution overlay: color-code each node by success/failure for a specific execution
- Aggregate overlay: color-code each node by throughput/error rate over a time window
- Highlight the path taken by the selected exchange (dim the branches not taken)
- Show inter-route connections (e.g., `direct:`, `seda:`, `vm:` endpoints linking routes)
**Priority**: **Must-Have**. Cameleer already has `RouteGraph` data from agents -- this is the tool's differentiating feature.
### 2.3 Cross-Route Correlation / Message Tracing
**Pain point**: A single business transaction (e.g., an order) often spans multiple routes: `order-intake` -> `order-enrichment` -> `payment-process` -> `shipment-dispatch`. The dashboard shows each route execution as a separate row. There is no way to see the full journey of order OP-88421 across all routes.
**How to address it**: Add a "Transaction Trace" or "Message Flow" view that:
- Groups all executions sharing a breadcrumbId or correlation ID
- Shows them as a horizontal timeline or waterfall chart
- Highlights which route in the chain failed
- Works across `direct:`, `seda:`, and `vm:` endpoints that link routes
The search bar says "Search by Order ID, correlation ID" which is a good start, but the results should show the correlated group, not just individual rows.
**Priority**: **Must-Have**. Splitter/aggregator patterns and multi-route flows are the norm, not the exception, in real Camel applications.
### 2.4 Dead Letter Queue Monitoring
**Pain point**: When messages fail and are routed to a dead letter channel (which is the standard Camel error handling pattern), I need to know: how many messages are in the DLQ, what are they, how long have they been there, and can I retry them?
**How to address it**: Add a DLQ section or page showing:
- Count of messages per dead letter endpoint
- Age distribution (how many are from today vs. last week)
- Message preview (body + headers + the exception that caused routing to DLQ)
- Retry action (re-submit the message to the original route)
- Purge action (acknowledge and discard)
**Priority**: **Must-Have**. DLQ management is a daily production task.
### 2.5 Per-Processor Statistics (Aggregate View)
**Pain point**: The processor timeline in the detail panel shows per-processor timing for a single execution. But I also need aggregate statistics: for processor `to(payment-api)`, what is the p50/p95/p99 latency over the last hour? How many times did it fail? Is it getting slower over time?
**How to address it**: Clicking a processor name in the timeline should show aggregate stats for that processor. Alternatively, the Route Detail page should have a "Processors" tab with a table of all processors in the route, their call count, success rate, and latency percentiles.
**Priority**: **Must-Have**. Identifying a chronically slow processor is different from identifying a one-off slow execution.
### 2.6 Error Pattern Grouping / Top Errors
**Pain point**: The dashboard shows individual error rows. When there are 38 errors, I do not want to scroll through all 38. I want to see: "23 of the 38 errors are `HttpOperationFailedException` on `payment-process`, 10 are `SQLTransientConnectionException` on `order-enrichment`, 5 are `ValidationException` on `order-intake`." The design notes mention "Top error pattern grouping panel" from the operator expert, but it is not in the final mock.
**How to address it**: Add an error summary panel above or alongside the execution table showing errors grouped by exception class + route. Each group should show count, first/last occurrence, and whether the count is trending up.
**Priority**: **Must-Have**. Pattern recognition is more important than individual error viewing.
### 2.7 Route Status Management
**Pain point**: I need to know which routes are started, stopped, or suspended. And I need the ability to stop/start/suspend individual routes without redeploying. This is routine in production -- temporarily suspending a route that is flooding a downstream system.
**How to address it**: The sidebar route list should show route status (started/stopped/suspended) with icons. Right-click or action menu on a route should offer start/stop/suspend. This maps directly to Camel's route controller API.
**Priority**: **Nice-to-Have** for v1, **Must-Have** for v2. Operators will ask for this quickly.
### 2.8 Route Version Comparison
**Pain point**: After a deployment, I want to compare the current route definition with the previous version. Did someone add a processor? Change an endpoint URI? Route definition drift is a real source of production issues.
**How to address it**: Store route graph snapshots per deployment/version. Show a diff view highlighting added/removed/modified processors.
**Priority**: **Nice-to-Have**. Valuable but less urgent than the above.
### 2.9 Thread Pool / Resource Monitoring
**Pain point**: Camel's default thread pool max is 20. When all threads are consumed, messages queue up silently. The HikariPool error in the mock is a perfect example -- pool exhaustion. I need visibility into thread pool utilization, connection pool utilization, and inflight exchange count.
**How to address it**: Add a "Resources" section (either in the agent detail or a separate page) showing:
- Camel thread pool utilization (active/max)
- Connection pool utilization (from endpoint components)
- Inflight exchange count per route
- Consumer prefetch/backlog (for JMS/Kafka consumers)
**Priority**: **Nice-to-Have** initially, but becomes **Must-Have** when debugging pool exhaustion issues.
### 2.10 Saved Searches / Alert Rules
**Pain point**: I find myself searching for the same patterns repeatedly: "errors on payment-process in the last hour", "executions over 500ms for order-enrichment". There is no way to save these as bookmarks or convert them into alert rules.
**How to address it**: Allow saving filter configurations as named views. Allow converting a saved search into an alerting rule (email/webhook when count exceeds threshold).
**Priority**: **Nice-to-Have**.
---
## 3. Specific Page/Feature Recommendations
### 3.1 Route Detail Page
When I click a route name (e.g., `order-intake`) from the sidebar, I should see:
- **Header**: Route name, status (started/stopped), uptime, route definition source (Java DSL / XML / YAML)
- **KPI Strip**: Total executions, success rate, p50/p99 latency, inflight count, throughput -- all for this route only
- **Processor Table**: Every processor in the route with columns: name, type, call count, success rate, p50 latency, p99 latency, total time %. Sortable by any column. This is where I find the bottleneck processor.
- **Route Diagram**: Interactive DAG with execution overlay. Nodes sized by throughput, colored by error rate. Clicking a node filters the execution list to that processor.
- **Recent Executions**: Filtered version of the main table, showing only this route's executions.
- **Error Patterns**: Top errors for this route, grouped by exception class.
### 3.2 Exchange / Message Inspector
When I click "Exchange" tab in the detail panel:
- **Inbound Message**: The original message as received by the route's consumer. Body + headers. Shown prominently, always visible.
- **Step-by-Step Trace**: For each processor, show the exchange state AFTER that processor ran. Diff mode should highlight what changed (body mutations, added headers, removed headers).
- **Properties**: Camel exchange properties (not just headers). Properties often carry routing decisions.
- **Exception**: If the exchange failed, show the caught exception, the handled flag, and whether it was routed to a dead letter channel.
- **Response**: If the route produces a response (e.g., REST endpoint), show the outbound body.
Display format should auto-detect JSON/XML and pretty-print. Binary payloads should show hex dump with size.
### 3.3 Metrics Dashboard (Developer vs. Operator KPIs)
The current metrics (throughput, latency p99, error rate) are operator KPIs. A Camel developer also needs:
**Developer KPIs** (add a "Developer" metrics view):
- Per-processor latency breakdown (stacked bar: which processors consume the most time)
- External endpoint response time (HTTP, DB, JMS) -- separate from Camel processing time
- Type converter cache hit rate (rarely needed, but valuable when debugging serialization issues)
- Redelivery count (how many messages required retries before succeeding)
- Content-based router distribution (for `choice()` routes: how many messages went down each branch)
**Operator KPIs** (already well-covered):
- Throughput, error rate, latency percentiles -- these are solid as-is
### 3.4 Dead Letter Queue View
A dedicated DLQ page:
- **Summary Cards**: One card per DLQ endpoint (e.g., `jms:DLQ.orders`, `seda:error-handler`), showing message count, oldest message age, newest message timestamp.
- **Message List**: Table with columns: original route, exception class, business ID, timestamp, retry count.
- **Message Detail**: Click a DLQ message to see the exchange snapshot (body + headers + exception) at the time of failure.
- **Actions**: Retry (re-submit to original endpoint), Retry All (bulk retry for a pattern), Discard, Move to another queue.
- **Filters**: By exception type, by route, by age.
### 3.5 Route Comparison
Two use cases:
1. **Version diff**: Compare route graph v3.2.0 vs. v3.2.1. Show added/removed/modified processors as a visual diff on the DAG.
2. **Performance comparison**: Compare this week's latency distribution for `payment-process` with last week's. Overlay histograms. Useful for validating that a deployment improved (or degraded) performance.
---
## 4. Information Architecture Critique
### What Works
- **Sidebar hierarchy** (Applications > Routes) is correct and matches how Camel projects are structured.
- **Health strip at top** provides instant situational awareness without scrolling.
- **Master-detail pattern** (table + slide-in panel) avoids page navigation for quick inspection. This keeps context.
- **Keyboard shortcuts** (Ctrl+K search, arrow navigation, Esc to close) are the right accelerators for power users.
### What Needs Adjustment
**The sidebar is too flat.** It shows applications and routes in the same list, but there is no way to navigate to:
- A dedicated Route Detail page (with per-processor stats, diagram, error patterns)
- An Agent Detail page (with resource utilization, version info, configuration)
- A DLQ page
- A Search/Trace page (for cross-route correlation)
Recommendation: Add top-level navigation items to the sidebar:
```
Dashboard (the current view)
Routes (route list with status, drill into route detail)
Traces (cross-route message flow / correlation)
Errors (grouped error patterns, DLQ)
Agents (agent health, resource utilization)
Diagrams (route graph visualization)
```
**Route click should go deeper.** Currently, clicking a route in the sidebar filters the execution table. This is useful, but clicking the route NAME in a table row or in the detail panel should navigate to a dedicated Route Detail page with per-processor aggregate stats and the route diagram.
**Search results need grouping.** The Ctrl+K search bar says "Search by Order ID, route, error..." but search results should group by correlation ID when searching by business ID. If I search for "OP-88421", I want to see ALL executions related to that order across all routes, not just the one row in `payment-process`.
**1-click access priorities:**
- Health overview: 1 click (current: 0 clicks -- it is the home page -- good)
- Filter by errors only: 1 click (current: 1 click on Error pill -- good)
- View a specific execution's processor timeline: 2 clicks (current: 1 click on row -- good)
- View exchange body/headers: should be 2 clicks (click row, click Exchange tab). Currently not implemented.
- View route diagram: should be 2 clicks (click route name, see diagram). Currently requires finding the button in the detail panel.
- Cross-route trace: should be 2 clicks (click correlation ID or business ID, see trace). Currently not possible.
- DLQ status: should be 1 click from sidebar. Currently not available.
---
## 5. Score Card
| Dimension | Score (1-10) | Notes |
|-----------------------------|:---:|-------|
| Transaction tracking | 4 | Individual executions visible, but no cross-route transaction view. Correlation ID shown but not actionable. |
| Root cause analysis | 6 | Processor timeline identifies the slow/failing step. Error messages shown inline. But no exchange body inspection, no stack trace expansion, no header diff. |
| Performance monitoring | 7 | Throughput, latency p99, error rate charts with SLA lines are solid. Missing per-processor aggregate stats and resource utilization. |
| Route visualization | 3 | Route names in sidebar, but no actual route diagram/DAG. The "View Route Diagram" button exists with no destination. This is Cameleer's key differentiator -- it must ship. |
| Exchange/message visibility | 2 | Exchange tab exists but has no content. No body inspection, no header view, no step-by-step diff. This is the most critical gap. |
| Correlation/tracing | 3 | Correlation ID displayed in detail panel, but no way to trace a message across routes. No breadcrumb linking. No transaction waterfall. |
| Overall daily usefulness | 5 | As an operations monitor (is anything broken right now?), it scores 7-8. As a developer debugging tool (why is it broken and how do I fix it?), it scores 3-4. The gap is in the debugging/inspection features. |
### Summary Verdict
The dashboard is a **strong operations monitor** -- it answers "what is happening right now?" effectively. The health strip, SLA awareness, shift context, business ID columns, and inline error previews are genuinely useful and better than most tools I have used.
However, it is a **weak debugging tool** -- it does not yet answer "why did this specific message fail?" or "what did the exchange look like at each step?" The Exchange tab, route diagram, cross-route tracing, and error pattern grouping are the features that would make this a daily-driver tool rather than a pretty overview I glance at in the morning.
The processor Gantt chart in the detail panel is the single best feature in the entire dashboard. Build on that. Make it clickable (click a processor to see the exchange state at that point). Add aggregate stats. Link it to the route diagram. That is where this tool becomes indispensable.
**Bottom line**: Ship the exchange inspector, the route diagram, and cross-route tracing, and this goes from a 5/10 to an 8/10 daily-use tool.

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

34
ui/package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "ui", "name": "ui",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@cameleer/design-system": "^0.0.2", "@cameleer/design-system": "^0.0.3",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"react": "^19.2.4", "react": "^19.2.4",
@@ -24,6 +24,7 @@
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0", "@vitejs/plugin-react": "^6.0.0",
"cross-env": "^10.1.0",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
@@ -275,9 +276,9 @@
} }
}, },
"node_modules/@cameleer/design-system": { "node_modules/@cameleer/design-system": {
"version": "0.0.2", "version": "0.0.3",
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.2/design-system-0.0.2.tgz", "resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.3/design-system-0.0.3.tgz",
"integrity": "sha512-6PbqtrW4E1yVE+ou2BCYVdHItvN88kNStS2pIKHuJhcerY3vCctLNU4pZSORkLUfvB181I+QIkBIEFa1CKSG8Q==", "integrity": "sha512-x1mZvgYz7j57xFB26pMh9hn5waSJA1CcRWTgkzleLfaO/CmhekLup1HHlbh0b9SxVci6g2HzbcJldr4kvM1yzg==",
"dependencies": { "dependencies": {
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -323,6 +324,13 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -1629,6 +1637,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",

View File

@@ -5,6 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:local": "cross-env VITE_API_TARGET=http://localhost:8081 vite",
"dev:remote": "cross-env VITE_API_TARGET=http://192.168.50.86:30090 vite",
"build": "tsc -p tsconfig.app.json --noEmit && vite build", "build": "tsc -p tsconfig.app.json --noEmit && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
@@ -12,7 +14,7 @@
"generate-api:live": "curl -s http://localhost:8081/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts" "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.0.2", "@cameleer/design-system": "^0.0.3",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"react": "^19.2.4", "react": "^19.2.4",
@@ -28,6 +30,7 @@
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0", "@vitejs/plugin-react": "^6.0.0",
"cross-env": "^10.1.0",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",

File diff suppressed because one or more lines are too long

View File

@@ -3,12 +3,12 @@ import { api } from '../client';
import { config } from '../../config'; import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store'; import { useAuthStore } from '../../auth/auth-store';
export function useAgents(status?: string, group?: string) { export function useAgents(status?: string, application?: string) {
return useQuery({ return useQuery({
queryKey: ['agents', status, group], queryKey: ['agents', status, application],
queryFn: async () => { queryFn: async () => {
const { data, error } = await api.GET('/agents', { const { data, error } = await api.GET('/agents', {
params: { query: { ...(status ? { status } : {}), ...(group ? { group } : {}) } }, params: { query: { ...(status ? { status } : {}), ...(application ? { application } : {}) } },
}); });
if (error) throw new Error('Failed to load agents'); if (error) throw new Error('Failed to load agents');
return data!; return data!;

View File

@@ -1,6 +1,13 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '../client'; import { api } from '../client';
interface DiagramLayout {
width?: number;
height?: number;
nodes?: Array<{ id?: string; label?: string; type?: string; x?: number; y?: number; width?: number; height?: number }>;
edges?: Array<{ from?: string; to?: string }>;
}
export function useDiagramLayout(contentHash: string | null) { export function useDiagramLayout(contentHash: string | null) {
return useQuery({ return useQuery({
queryKey: ['diagrams', 'layout', contentHash], queryKey: ['diagrams', 'layout', contentHash],
@@ -10,22 +17,22 @@ export function useDiagramLayout(contentHash: string | null) {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); });
if (error) throw new Error('Failed to load diagram layout'); if (error) throw new Error('Failed to load diagram layout');
return data!; return data as DiagramLayout;
}, },
enabled: !!contentHash, enabled: !!contentHash,
}); });
} }
export function useDiagramByRoute(group: string | undefined, routeId: string | undefined) { export function useDiagramByRoute(application: string | undefined, routeId: string | undefined) {
return useQuery({ return useQuery({
queryKey: ['diagrams', 'byRoute', group, routeId], queryKey: ['diagrams', 'byRoute', application, routeId],
queryFn: async () => { queryFn: async () => {
const { data, error } = await api.GET('/diagrams', { const { data, error } = await api.GET('/diagrams', {
params: { query: { group: group!, routeId: routeId! } }, params: { query: { application: application!, routeId: routeId! } },
}); });
if (error) throw new Error('Failed to load diagram for route'); if (error) throw new Error('Failed to load diagram for route');
return data!; return data!;
}, },
enabled: !!group && !!routeId, enabled: !!application && !!routeId,
}); });
} }

View File

@@ -6,10 +6,10 @@ export function useExecutionStats(
timeFrom: string | undefined, timeFrom: string | undefined,
timeTo: string | undefined, timeTo: string | undefined,
routeId?: string, routeId?: string,
group?: string, application?: string,
) { ) {
return useQuery({ return useQuery({
queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, group], queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, application],
queryFn: async () => { queryFn: async () => {
const { data, error } = await api.GET('/search/stats', { const { data, error } = await api.GET('/search/stats', {
params: { params: {
@@ -17,7 +17,7 @@ export function useExecutionStats(
from: timeFrom!, from: timeFrom!,
to: timeTo || undefined, to: timeTo || undefined,
routeId: routeId || undefined, routeId: routeId || undefined,
group: group || undefined, application: application || undefined,
}, },
}, },
}); });
@@ -49,10 +49,10 @@ export function useStatsTimeseries(
timeFrom: string | undefined, timeFrom: string | undefined,
timeTo: string | undefined, timeTo: string | undefined,
routeId?: string, routeId?: string,
group?: string, application?: string,
) { ) {
return useQuery({ return useQuery({
queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, group], queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, application],
queryFn: async () => { queryFn: async () => {
const { data, error } = await api.GET('/search/stats/timeseries', { const { data, error } = await api.GET('/search/stats/timeseries', {
params: { params: {
@@ -61,7 +61,7 @@ export function useStatsTimeseries(
to: timeTo || undefined, to: timeTo || undefined,
buckets: 24, buckets: 24,
routeId: routeId || undefined, routeId: routeId || undefined,
group: group || undefined, application: application || undefined,
}, },
}, },
}); });

View File

@@ -625,10 +625,10 @@ export interface paths {
cookie?: never; cookie?: never;
}; };
/** /**
* Find diagram by application group and route ID * Find diagram by application and route ID
* @description Resolves group to agent IDs and finds the latest diagram for the route * @description Resolves application to agent IDs and finds the latest diagram for the route
*/ */
get: operations["findByGroupAndRoute"]; get: operations["findByApplicationAndRoute"];
put?: never; put?: never;
post?: never; post?: never;
delete?: never; delete?: never;
@@ -683,7 +683,7 @@ export interface paths {
}; };
/** /**
* List all agents * List all agents
* @description Returns all registered agents with runtime metrics, optionally filtered by status and/or group * @description Returns all registered agents with runtime metrics, optionally filtered by status and/or application
*/ */
get: operations["listAgents"]; get: operations["listAgents"];
put?: never; put?: never;
@@ -1079,7 +1079,7 @@ export interface components {
routeId?: string; routeId?: string;
agentId?: string; agentId?: string;
processorType?: string; processorType?: string;
group?: string; application?: string;
agentIds?: string[]; agentIds?: string[];
/** Format: int32 */ /** Format: int32 */
offset?: number; offset?: number;
@@ -1092,6 +1092,7 @@ export interface components {
executionId: string; executionId: string;
routeId: string; routeId: string;
agentId: string; agentId: string;
applicationName: string;
status: string; status: string;
/** Format: date-time */ /** Format: date-time */
startTime: string; startTime: string;
@@ -1157,7 +1158,7 @@ export interface components {
agentId: string; agentId: string;
name: string; name: string;
/** @default default */ /** @default default */
group: string; application: string;
version?: string; version?: string;
routeIds?: string[]; routeIds?: string[];
capabilities?: { capabilities?: {
@@ -1326,7 +1327,7 @@ export interface components {
errorStackTrace: string; errorStackTrace: string;
diagramContentHash: string; diagramContentHash: string;
processors: components["schemas"]["ProcessorNode"][]; processors: components["schemas"]["ProcessorNode"][];
groupName?: string; applicationName?: string;
children?: components["schemas"]["ProcessorNode"][]; children?: components["schemas"]["ProcessorNode"][];
}; };
ProcessorNode: { ProcessorNode: {
@@ -1383,7 +1384,7 @@ export interface components {
AgentInstanceResponse: { AgentInstanceResponse: {
id: string; id: string;
name: string; name: string;
group: string; application: string;
status: string; status: string;
routeIds: string[]; routeIds: string[];
/** Format: date-time */ /** Format: date-time */
@@ -2976,7 +2977,7 @@ export interface operations {
from: string; from: string;
to?: string; to?: string;
routeId?: string; routeId?: string;
group?: string; application?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -3002,7 +3003,7 @@ export interface operations {
to?: string; to?: string;
buckets?: number; buckets?: number;
routeId?: string; routeId?: string;
group?: string; application?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -3132,10 +3133,10 @@ export interface operations {
}; };
}; };
}; };
findByGroupAndRoute: { findByApplicationAndRoute: {
parameters: { parameters: {
query: { query: {
group: string; application: string;
routeId: string; routeId: string;
}; };
header?: never; header?: never;
@@ -3153,7 +3154,7 @@ export interface operations {
"*/*": components["schemas"]["DiagramLayout"]; "*/*": components["schemas"]["DiagramLayout"];
}; };
}; };
/** @description No diagram found for the given group and route */ /** @description No diagram found for the given application and route */
404: { 404: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -3238,7 +3239,7 @@ export interface operations {
parameters: { parameters: {
query?: { query?: {
status?: string; status?: string;
group?: string; application?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,4 +1,62 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500;600&display=swap'); /* DM Sans — self-hosted (GDPR compliant) */
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./fonts/dm-sans-400.woff2') format('woff2');
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./fonts/dm-sans-500.woff2') format('woff2');
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('./fonts/dm-sans-600.woff2') format('woff2');
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./fonts/dm-sans-700.woff2') format('woff2');
}
@font-face {
font-family: 'DM Sans';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('./fonts/dm-sans-400-italic.woff2') format('woff2');
}
/* JetBrains Mono — self-hosted (GDPR compliant) */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./fonts/jetbrains-mono-400.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./fonts/jetbrains-mono-500.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('./fonts/jetbrains-mono-600.woff2') format('woff2');
}
:root { :root {
font-family: 'DM Sans', system-ui, sans-serif; font-family: 'DM Sans', system-ui, sans-serif;

View File

@@ -0,0 +1,26 @@
import { Outlet, useNavigate, useLocation } from 'react-router';
import { Tabs } from '@cameleer/design-system';
const ADMIN_TABS = [
{ label: 'User Management', value: '/admin/rbac' },
{ label: 'Audit Log', value: '/admin/audit' },
{ label: 'OIDC', value: '/admin/oidc' },
{ label: 'Database', value: '/admin/database' },
{ label: 'OpenSearch', value: '/admin/opensearch' },
];
export default function AdminLayout() {
const navigate = useNavigate();
const location = useLocation();
return (
<div>
<Tabs
tabs={ADMIN_TABS}
active={location.pathname}
onChange={(path) => navigate(path)}
/>
<Outlet />
</div>
);
}

View File

@@ -13,7 +13,7 @@ export default function OpenSearchAdminPage() {
const indexColumns: Column<any>[] = [ const indexColumns: Column<any>[] = [
{ key: 'name', header: 'Index' }, { key: 'name', header: 'Index' },
{ key: 'health', header: 'Health', render: (v) => <Badge label={String(v)} color={v === 'green' ? 'success' : v === 'yellow' ? 'warning' : 'error'} /> }, { key: 'health', header: 'Health', render: (v) => <Badge label={String(v)} color={v === 'green' ? 'success' : v === 'yellow' ? 'warning' : 'error'} /> },
{ key: 'docCount', header: 'Documents', sortable: true }, { key: 'docCount', header: 'Documents', sortable: true, render: (v) => Number(v).toLocaleString() },
{ key: 'size', header: 'Size' }, { key: 'size', header: 'Size' },
{ key: 'primaryShards', header: 'Shards' }, { key: 'primaryShards', header: 'Shards' },
]; ];

View File

@@ -1,48 +1,191 @@
.statStrip { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 16px; } .statStrip {
.splitPane { display: grid; grid-template-columns: 52fr 48fr; height: calc(100vh - 280px); } display: grid;
.listPane { overflow-y: auto; border-right: 1px solid var(--border-subtle); padding-right: 16px; } grid-template-columns: repeat(3, 1fr);
.detailPane { overflow-y: auto; padding-left: 16px; } gap: 10px;
.listHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } margin-bottom: 16px;
.entityList { display: flex; flex-direction: column; gap: 2px; } }
.splitPane {
display: grid;
grid-template-columns: 52fr 48fr;
gap: 1px;
background: var(--border-subtle);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
min-height: 500px;
box-shadow: var(--shadow-card);
}
.listPane {
background: var(--bg-surface);
display: flex;
flex-direction: column;
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
}
.detailPane {
background: var(--bg-surface);
overflow-y: auto;
padding: 20px;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
}
.listHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.listHeader input { flex: 1; }
.entityList {
flex: 1;
overflow-y: auto;
}
.entityItem { .entityItem {
display: flex; align-items: center; gap: 10px; padding: 8px 10px; display: flex;
cursor: pointer; border-radius: 6px; transition: background 0.1s; align-items: flex-start;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.1s;
border-bottom: 1px solid var(--border-subtle);
} }
.entityItem:hover { background: var(--bg-hover); }
.entityItemSelected { background: var(--bg-raised); } .entityItem:last-child {
.entityInfo { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; } border-bottom: none;
.entityName { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 6px; } }
.entityMeta { font-size: 11px; color: var(--text-muted); }
.entityTags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 2px; } .entityItem:hover {
background: var(--bg-hover);
}
.entityItemSelected {
background: var(--bg-raised);
}
.entityInfo {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.entityName {
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
}
.entityMeta {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.entityTags {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 4px;
}
.createForm { .createForm {
background: var(--bg-surface); border: 1px solid var(--border-subtle); background: var(--bg-raised);
border-radius: var(--radius-lg); padding: 12px; margin-bottom: 12px; border-bottom: 1px solid var(--border-subtle);
padding: 12px;
} }
.createFormActions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
.createFormActions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
}
.detailHeader { .detailHeader {
display: flex; align-items: center; gap: 12px; display: flex;
margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--border-subtle); align-items: center;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-subtle);
} }
.metaGrid { .metaGrid {
display: grid; grid-template-columns: 100px 1fr; gap: 6px 12px; display: grid;
font-size: 13px; margin-bottom: 16px; grid-template-columns: 100px 1fr;
gap: 6px 12px;
font-size: 13px;
margin-bottom: 16px;
} }
.metaLabel { .metaLabel {
font-weight: 700; font-size: 10px; text-transform: uppercase; font-weight: 700;
letter-spacing: 0.6px; color: var(--text-muted); font-size: 10px;
} text-transform: uppercase;
.sectionTags { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; } letter-spacing: 0.6px;
.inheritedNote { font-size: 11px; font-style: italic; color: var(--text-muted); margin-top: 4px; } color: var(--text-muted);
.securitySection {
padding: 12px; border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); margin-bottom: 16px;
}
.resetForm { display: flex; gap: 8px; margin-top: 8px; }
.emptyDetail {
display: flex; align-items: center; justify-content: center;
height: 100%; color: var(--text-muted); font-size: 13px;
} }
.sectionTitle { .sectionTitle {
font-size: 13px; font-weight: 700; color: var(--text-primary); font-size: 13px;
margin-bottom: 8px; margin-top: 16px; font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
margin-top: 16px;
}
.sectionTags {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.inheritedNote {
font-size: 11px;
font-style: italic;
color: var(--text-muted);
margin-top: 4px;
}
.securitySection {
padding: 12px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
margin-bottom: 16px;
}
.resetForm {
display: flex;
gap: 8px;
margin-top: 8px;
}
.emptyDetail {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 13px;
}
.emptySearch {
padding: 20px;
text-align: center;
color: var(--text-muted);
font-size: 12px;
}
.providerBadge {
font-size: 9px;
} }

View File

@@ -307,7 +307,11 @@ export default function UsersTab() {
<Badge label={user.provider} variant="outlined" /> <Badge label={user.provider} variant="outlined" />
)} )}
</div> </div>
{user.email && <div className={styles.entityMeta}>{user.email}</div>} <div className={styles.entityMeta}>
{user.email || user.userId}
{user.directGroups.length > 0 && ` · ${user.directGroups.map((g) => g.name).join(', ')}`}
{user.directGroups.length === 0 && ' · no groups'}
</div>
{(user.directRoles.length > 0 || user.directGroups.length > 0) && ( {(user.directRoles.length > 0 || user.directGroups.length > 0) && (
<div className={styles.entityTags}> <div className={styles.entityTags}>
{user.directRoles.map((r) => ( {user.directRoles.map((r) => (

View File

@@ -19,15 +19,54 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.instanceRow { /* GroupCard meta strip */
.groupMeta {
display: flex; display: flex;
gap: 16px;
align-items: center; align-items: center;
gap: 8px; font-size: 12px;
padding: 8px 12px; color: var(--text-muted);
}
.groupMeta strong {
color: var(--text-primary);
}
/* Instance table */
.instanceTable {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.instanceTable thead tr {
border-bottom: 1px solid var(--border-subtle);
}
.instanceTable thead th {
padding: 6px 8px;
text-align: left;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.thStatus {
width: 24px;
}
.tdStatus {
width: 24px;
padding: 0 4px 0 8px;
}
.instanceRow {
cursor: pointer; cursor: pointer;
transition: background 0.1s; transition: background 0.1s;
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
font-size: 12px;
} }
.instanceRow:last-child { .instanceRow:last-child {
@@ -38,6 +77,15 @@
background: var(--bg-hover); background: var(--bg-hover);
} }
.instanceRow td {
padding: 7px 8px;
vertical-align: middle;
}
.instanceRowActive {
background: var(--bg-selected, var(--bg-hover));
}
.instanceName { .instanceName {
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
@@ -49,6 +97,24 @@
font-family: var(--font-mono); font-family: var(--font-mono);
} }
.instanceError {
font-size: 11px;
color: var(--error);
font-family: var(--font-mono);
}
.instanceHeartbeatDead {
font-size: 11px;
color: var(--error);
font-family: var(--font-mono);
}
.instanceHeartbeatStale {
font-size: 11px;
color: var(--warning);
font-family: var(--font-mono);
}
.instanceLink { .instanceLink {
color: var(--text-muted); color: var(--text-muted);
text-decoration: none; text-decoration: none;
@@ -178,3 +244,36 @@
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
} }
/* Status breakdown in stat card */
.statusBreakdown {
display: flex;
gap: 8px;
font-size: 11px;
}
.statusLive { color: var(--success); }
.statusStale { color: var(--warning); }
.statusDead { color: var(--error); }
/* Scope trail */
.scopeLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
/* DetailPanel override */
.detailPanelOverride {
position: fixed;
top: 0;
right: 0;
height: 100vh;
z-index: 100;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
}
.panelDivider {
border-top: 1px solid var(--border-subtle);
margin: 16px 0;
}

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import { import {
StatCard, StatusDot, Badge, MonoText, StatCard, StatusDot, Badge, MonoText,
GroupCard, EventFeed, Breadcrumb, Alert, GroupCard, EventFeed, Alert,
DetailPanel, ProgressBar, LineChart, DetailPanel, ProgressBar, LineChart,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import styles from './AgentHealth.module.css'; import styles from './AgentHealth.module.css';
@@ -63,7 +63,7 @@ function AgentOverviewContent({ agent }: { agent: any }) {
<dl className={styles.detailList}> <dl className={styles.detailList}>
<div className={styles.detailRow}> <div className={styles.detailRow}>
<dt>Application</dt> <dt>Application</dt>
<dd><MonoText>{agent.group ?? '—'}</MonoText></dd> <dd><MonoText>{agent.application ?? '—'}</MonoText></dd>
</div> </div>
<div className={styles.detailRow}> <div className={styles.detailRow}>
<dt>Version</dt> <dt>Version</dt>
@@ -175,7 +175,7 @@ export default function AgentHealth() {
const agentsByApp = useMemo(() => { const agentsByApp = useMemo(() => {
const map: Record<string, any[]> = {}; const map: Record<string, any[]> = {};
(agents || []).forEach((a: any) => { (agents || []).forEach((a: any) => {
const g = a.group; const g = a.application;
if (!map[g]) map[g] = []; if (!map[g]) map[g] = [];
map[g].push(a); map[g].push(a);
}); });
@@ -185,28 +185,10 @@ export default function AgentHealth() {
const liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length; const liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length;
const staleCount = (agents || []).filter((a: any) => a.status === 'STALE').length; const staleCount = (agents || []).filter((a: any) => a.status === 'STALE').length;
const deadCount = (agents || []).filter((a: any) => a.status === 'DEAD').length; const deadCount = (agents || []).filter((a: any) => a.status === 'DEAD').length;
const uniqueApps = new Set((agents || []).map((a: any) => a.group)).size; const uniqueApps = new Set((agents || []).map((a: any) => a.application)).size;
const activeRoutes = (agents || []).filter((a: any) => a.status === 'LIVE').reduce((sum: number, a: any) => sum + (a.activeRoutes || 0), 0); const activeRoutes = (agents || []).filter((a: any) => a.status === 'LIVE').reduce((sum: number, a: any) => sum + (a.activeRoutes || 0), 0);
const totalTps = (agents || []).filter((a: any) => a.status === 'LIVE').reduce((sum: number, a: any) => sum + (a.tps || 0), 0); const totalTps = (agents || []).filter((a: any) => a.status === 'LIVE').reduce((sum: number, a: any) => sum + (a.tps || 0), 0);
const groupHealth: 'live' | 'stale' | 'dead' = useMemo(() => {
if (!appId) return 'live';
const groupAgents = agentsByApp[appId] || [];
if (groupAgents.some((a: any) => a.status === 'DEAD')) return 'dead';
if (groupAgents.some((a: any) => a.status === 'STALE')) return 'stale';
return 'live';
}, [appId, agentsByApp]);
const scopeItems = useMemo(() => {
const items: { label: string; href?: string }[] = [
{ label: 'Agent Health', href: '/agents' },
];
if (appId) {
items.push({ label: appId });
}
return items;
}, [appId]);
const feedEvents = useMemo(() => const feedEvents = useMemo(() =>
(events || []).map((e: any) => ({ (events || []).map((e: any) => ({
id: String(e.id), id: String(e.id),
@@ -225,64 +207,126 @@ export default function AgentHealth() {
return ( return (
<div> <div>
<div className={styles.statStrip}> <div className={styles.statStrip}>
<StatCard label="Total Agents" value={(agents || []).length} detail={`${liveCount} live / ${staleCount} stale / ${deadCount} dead`} /> <StatCard
label="Total Agents"
value={(agents || []).length}
detail={
<span className={styles.statusBreakdown}>
<span className={styles.statusLive}>{liveCount} live</span>
<span className={styles.statusStale}>{staleCount} stale</span>
<span className={styles.statusDead}>{deadCount} dead</span>
</span>
}
/>
<StatCard label="Applications" value={uniqueApps} /> <StatCard label="Applications" value={uniqueApps} />
<StatCard label="Active Routes" value={activeRoutes} /> <StatCard label="Active Routes" value={activeRoutes} />
<StatCard label="Total TPS" value={totalTps.toFixed(1)} /> <StatCard label="Total TPS" value={totalTps.toFixed(1)} detail="msg/s" />
<StatCard label="Dead" value={deadCount} accent={deadCount > 0 ? 'error' : undefined} /> <StatCard label="Dead" value={deadCount} accent={deadCount > 0 ? 'error' : undefined} detail={deadCount > 0 ? 'requires attention' : undefined} />
</div> </div>
<div className={styles.scopeTrail}> <div className={styles.scopeTrail}>
<Breadcrumb items={scopeItems} /> <span className={styles.scopeLabel}>{liveCount}/{(agents || []).length} live</span>
{!appId && <Badge label={`${liveCount} live`} variant="outlined" />}
{appId && (
<Badge
label={groupHealth}
color={groupHealth === 'live' ? 'success' : groupHealth === 'stale' ? 'warning' : 'error'}
/>
)}
</div> </div>
<div className={styles.groupGrid}> <div className={styles.groupGrid}>
{Object.entries(apps).map(([group, groupAgents]) => { {Object.entries(apps).map(([group, groupAgents]) => {
const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD'); const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD');
const groupTps = (groupAgents || []).reduce((s: number, a: any) => s + (a.tps || 0), 0);
const groupActiveRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.activeRoutes || 0), 0);
const groupTotalRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.totalRoutes || 0), 0);
const liveInGroup = (groupAgents || []).filter((a: any) => a.status === 'LIVE').length;
return ( return (
<GroupCard <GroupCard
key={group} key={group}
title={group} title={group}
headerRight={<Badge label={`${groupAgents?.length ?? 0} instances`} />} headerRight={
<Badge
label={`${liveInGroup}/${groupAgents?.length ?? 0} LIVE`}
color={
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
: 'success'
}
variant="filled"
/>
}
meta={
<div className={styles.groupMeta}>
<span><strong>{groupTps.toFixed(1)}</strong> msg/s</span>
<span><strong>{groupActiveRoutes}</strong>/{groupTotalRoutes} routes</span>
</div>
}
accent={ accent={
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error' groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning' : groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
: 'success' : 'success'
} }
onClick={() => navigate(`/agents/${group}`)}
> >
{deadInGroup.length > 0 && ( {deadInGroup.length > 0 && (
<Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert> <Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert>
)} )}
{(groupAgents || []).map((agent: any) => ( <table className={styles.instanceTable}>
<div <thead>
key={agent.id} <tr>
className={styles.instanceRow} <th className={styles.thStatus} />
onClick={(e) => { <th>Instance</th>
e.stopPropagation(); <th>State</th>
setSelectedAgent(agent); <th>Uptime</th>
navigate(`/agents/${group}/${agent.id}`); <th>TPS</th>
}} <th>Errors</th>
> <th>Heartbeat</th>
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} /> </tr>
<span className={styles.instanceName}>{agent.name}</span> </thead>
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} /> <tbody>
<span className={styles.instanceMeta}>{formatUptime(agent.uptimeSeconds)}</span> {(groupAgents || []).map((agent: any) => (
{agent.tps != null && <span className={styles.instanceMeta}>{(agent.tps || 0).toFixed(1)} tps</span>} <tr
{agent.errorRate != null && ( key={agent.id}
<span className={styles.instanceMeta}>{(agent.errorRate * 100).toFixed(1)}% err</span> className={[
)} styles.instanceRow,
<span className={styles.instanceMeta}>{formatRelativeTime(agent.lastHeartbeat)}</span> selectedAgent?.id === agent.id ? styles.instanceRowActive : '',
<span className={styles.instanceLink} aria-label="View instance"></span> ].filter(Boolean).join(' ')}
</div> onClick={() => {
))} setSelectedAgent(agent);
navigate(`/agents/${group}/${agent.id}`);
}}
>
<td className={styles.tdStatus}>
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
</td>
<td>
<MonoText size="sm" className={styles.instanceName}>{agent.name ?? agent.id}</MonoText>
</td>
<td>
<Badge
label={agent.status}
color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'}
variant="filled"
/>
</td>
<td>
<span className={styles.instanceMeta}>{formatUptime(agent.uptimeSeconds)}</span>
</td>
<td>
<span className={styles.instanceMeta}>{agent.tps != null ? `${(agent.tps as number).toFixed(1)}/s` : '—'}</span>
</td>
<td>
<span className={agent.errorRate != null ? styles.instanceError : styles.instanceMeta}>
{agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'}
</span>
</td>
<td>
<span className={
agent.status === 'DEAD' ? styles.instanceHeartbeatDead
: agent.status === 'STALE' ? styles.instanceHeartbeatStale
: styles.instanceMeta
}>
{formatRelativeTime(agent.lastHeartbeat)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</GroupCard> </GroupCard>
); );
})} })}
@@ -290,29 +334,26 @@ export default function AgentHealth() {
{feedEvents.length > 0 && ( {feedEvents.length > 0 && (
<div className={styles.eventCard}> <div className={styles.eventCard}>
<div className={styles.eventCardHeader}>Event Log</div> <div className={styles.eventCardHeader}>
<span>Timeline</span>
<Badge label={`${feedEvents.length} events`} variant="outlined" />
</div>
<EventFeed events={feedEvents} maxItems={100} /> <EventFeed events={feedEvents} maxItems={100} />
</div> </div>
)} )}
{selectedAgent && ( {selectedAgent && (
<DetailPanel <DetailPanel
open={!!selectedAgent} key={selectedAgent.id}
open={true}
title={selectedAgent.name ?? selectedAgent.id} title={selectedAgent.name ?? selectedAgent.id}
onClose={() => setSelectedAgent(null)} onClose={() => setSelectedAgent(null)}
tabs={[ className={styles.detailPanelOverride}
{ >
label: 'Overview', <AgentOverviewContent agent={selectedAgent} />
value: 'overview', <div className={styles.panelDivider} />
content: <AgentOverviewContent agent={selectedAgent} />, <AgentPerformanceContent agent={selectedAgent} />
}, </DetailPanel>
{
label: 'Performance',
value: 'performance',
content: <AgentPerformanceContent agent={selectedAgent} />,
},
]}
/>
)} )}
</div> </div>
); );

View File

@@ -110,9 +110,66 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.scopeTrail {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 16px;
font-size: 13px;
flex-wrap: wrap;
}
.scopeLink {
color: var(--text-accent, var(--text-primary));
text-decoration: none;
font-weight: 500;
}
.scopeLink:hover {
text-decoration: underline;
}
.scopeSep {
color: var(--text-muted);
font-size: 10px;
}
.scopeCurrent {
color: var(--text-primary);
font-weight: 600;
}
.paneTitle { .paneTitle {
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 12px; margin-bottom: 12px;
} }
.chartMeta {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
font-family: var(--font-mono);
}
.bottomSection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 20px;
}
.eventCount {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
font-family: var(--font-mono);
}
.emptyEvents {
padding: 20px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
}

View File

@@ -122,10 +122,35 @@ export default function AgentInstance() {
<div className={styles.statStrip}> <div className={styles.statStrip}>
<StatCard label="CPU" value={cpuPct != null ? `${(cpuPct * 100).toFixed(0)}%` : '—'} /> <StatCard label="CPU" value={cpuPct != null ? `${(cpuPct * 100).toFixed(0)}%` : '—'} />
<StatCard label="Memory" value={memPct != null ? `${memPct.toFixed(0)}%` : '—'} /> <StatCard
label="Memory"
value={memPct != null ? `${memPct.toFixed(0)}%` : '—'}
detail={heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : undefined}
/>
<StatCard label="Throughput" value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '—'} /> <StatCard label="Throughput" value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '—'} />
<StatCard label="Errors" value={agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : '—'} accent={agent?.errorRate > 0 ? 'error' : undefined} /> <StatCard label="Errors" value={agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : '—'} accent={agent?.errorRate > 0 ? 'error' : undefined} />
<StatCard label="Uptime" value={formatUptime(agent?.uptimeSeconds)} /> <StatCard
label="Uptime"
value={formatUptime(agent?.uptimeSeconds)}
detail={agent?.registeredAt ? `since ${new Date(agent.registeredAt).toLocaleDateString()}` : undefined}
/>
</div>
<div className={styles.scopeTrail}>
<a href="/agents" className={styles.scopeLink}>All Agents</a>
<span className={styles.scopeSep}>&#9656;</span>
<a href={`/agents/${appId}`} className={styles.scopeLink}>{appId}</a>
<span className={styles.scopeSep}>&#9656;</span>
<span className={styles.scopeCurrent}>{agent.name}</span>
<Badge
label={agent.status.toUpperCase()}
color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'}
/>
{agent.version && <Badge label={agent.version} variant="outlined" />}
<Badge
label={`${agent.activeRoutes ?? (agent.routeIds?.length ?? 0)}/${agent.totalRoutes ?? (agent.routeIds?.length ?? 0)} routes`}
color={(agent.activeRoutes ?? 0) < (agent.totalRoutes ?? 0) ? 'warning' : 'success'}
/>
</div> </div>
<Card className={styles.infoCard}> <Card className={styles.infoCard}>
@@ -175,54 +200,76 @@ export default function AgentInstance() {
</> </>
)} )}
<div className={styles.sectionTitle}>Performance</div>
<div className={styles.chartsGrid}> <div className={styles.chartsGrid}>
<div className={styles.chartCard}> <div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>CPU Usage</div></div> <div className={styles.chartHeader}>
<div className={styles.chartTitle}>CPU Usage</div>
<div className={styles.chartMeta}>{cpuPct != null ? `${(cpuPct * 100).toFixed(0)}% current` : ''}</div>
</div>
{cpuSeries {cpuSeries
? <AreaChart series={cpuSeries} yLabel="%" height={200} /> ? <AreaChart series={cpuSeries} yLabel="%" height={200} />
: <EmptyState title="No data" description="No CPU metrics available" />} : <EmptyState title="No data" description="No CPU metrics available" />}
</div> </div>
<div className={styles.chartCard}> <div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>Memory Heap</div></div> <div className={styles.chartHeader}>
<div className={styles.chartTitle}>Memory (Heap)</div>
<div className={styles.chartMeta}>{heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : ''}</div>
</div>
{heapSeries {heapSeries
? <AreaChart series={heapSeries} yLabel="MB" height={200} /> ? <AreaChart series={heapSeries} yLabel="MB" height={200} />
: <EmptyState title="No data" description="No heap metrics available" />} : <EmptyState title="No data" description="No heap metrics available" />}
</div> </div>
<div className={styles.chartCard}> <div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>Throughput</div></div> <div className={styles.chartHeader}>
<div className={styles.chartTitle}>Throughput</div>
<div className={styles.chartMeta}>{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}</div>
</div>
{throughputSeries {throughputSeries
? <AreaChart series={throughputSeries} height={200} /> ? <AreaChart series={throughputSeries} yLabel="msg/s" height={200} />
: <EmptyState title="No data" description="No throughput data in range" />} : <EmptyState title="No data" description="No throughput data in range" />}
</div> </div>
<div className={styles.chartCard}> <div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>Error Rate</div></div> <div className={styles.chartHeader}>
<div className={styles.chartTitle}>Error Rate</div>
<div className={styles.chartMeta}>{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}</div>
</div>
{errorSeries {errorSeries
? <LineChart series={errorSeries} height={200} /> ? <LineChart series={errorSeries} yLabel="%" height={200} />
: <EmptyState title="No data" description="No error data in range" />} : <EmptyState title="No data" description="No error data in range" />}
</div> </div>
<div className={styles.chartCard}> <div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>Thread Count</div></div> <div className={styles.chartHeader}>
<div className={styles.chartTitle}>Thread Count</div>
{threadSeries && <div className={styles.chartMeta}>{threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active</div>}
</div>
{threadSeries {threadSeries
? <LineChart series={threadSeries} height={200} /> ? <LineChart series={threadSeries} yLabel="threads" height={200} />
: <EmptyState title="No data" description="No thread metrics available" />} : <EmptyState title="No data" description="No thread metrics available" />}
</div> </div>
<div className={styles.chartCard}> <div className={styles.chartCard}>
<div className={styles.chartHeader}><div className={styles.chartTitle}>GC Pauses</div></div> <div className={styles.chartHeader}>
<div className={styles.chartTitle}>GC Pauses</div>
</div>
{gcSeries {gcSeries
? <BarChart series={gcSeries} yLabel="ms" height={200} /> ? <BarChart series={gcSeries} yLabel="ms" height={200} />
: <EmptyState title="No data" description="No GC metrics available" />} : <EmptyState title="No data" description="No GC metrics available" />}
</div> </div>
</div> </div>
{feedEvents.length > 0 && ( <div className={styles.bottomSection}>
<div className={styles.eventCard}> <EmptyState title="Application Log" description="Application log streaming is not yet available" />
<div className={styles.eventCardHeader}>Events</div>
<EventFeed events={feedEvents} maxItems={50} /> <div className={styles.eventCard}>
</div> <div className={styles.eventCardHeader}>
)} <span>Timeline</span>
<span className={styles.eventCount}>{feedEvents.length} events</span>
</div>
{feedEvents.length > 0
? <EventFeed events={feedEvents} maxItems={50} />
: <div className={styles.emptyEvents}>No events in the selected time range.</div>}
</div>
</div>
<EmptyState title="Application Logs" description="Application log streaming is not yet available" />
</div> </div>
); );
} }

View File

@@ -58,6 +58,18 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 10px; margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.panelSectionMeta {
font-size: 11px;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: var(--text-muted);
font-family: var(--font-mono);
} }
.overviewGrid { .overviewGrid {
@@ -82,3 +94,46 @@
flex-shrink: 0; flex-shrink: 0;
padding-top: 2px; padding-top: 2px;
} }
.inspectLink {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
font-size: 14px;
color: var(--text-muted);
text-decoration: none;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
}
.inspectLink:hover {
color: var(--accent, #c6820e);
background: var(--bg-hover);
}
.detailPanelOverride {
position: fixed;
top: 0;
right: 0;
height: 100vh;
z-index: 100;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
}
.openDetailLink {
display: inline-block;
font-size: 13px;
font-weight: 600;
color: var(--accent, #c6820e);
cursor: pointer;
background: none;
border: none;
padding: 0;
text-decoration: none;
}
.openDetailLink:hover {
text-decoration: underline;
}

View File

@@ -1,27 +1,33 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { useParams } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import { import {
StatCard, StatusDot, Badge, MonoText, Sparkline, StatCard, StatusDot, Badge, MonoText,
DataTable, DetailPanel, ProcessorTimeline, RouteFlow, DataTable, DetailPanel, ProcessorTimeline, RouteFlow,
Alert, Collapsible, CodeBlock, Alert, Collapsible, CodeBlock, ShortcutsBar,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'; import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail } from '../../api/queries/executions';
import { useDiagramLayout } from '../../api/queries/diagrams';
import { useGlobalFilters } from '@cameleer/design-system'; import { useGlobalFilters } from '@cameleer/design-system';
import type { ExecutionSummary } from '../../api/types'; import type { ExecutionSummary } from '../../api/types';
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
import styles from './Dashboard.module.css'; import styles from './Dashboard.module.css';
interface Row extends ExecutionSummary { id: string } interface Row extends ExecutionSummary { id: string }
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
export default function Dashboard() { export default function Dashboard() {
const { appId, routeId } = useParams(); const { appId, routeId } = useParams();
const navigate = useNavigate();
const { timeRange } = useGlobalFilters(); const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString(); const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString(); const timeTo = timeRange.end.toISOString();
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [detailTab, setDetailTab] = useState('overview');
const [processorIdx, setProcessorIdx] = useState<number | null>(null);
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000; const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
@@ -30,63 +36,195 @@ export default function Dashboard() {
const { data: searchResult } = useSearchExecutions({ const { data: searchResult } = useSearchExecutions({
timeFrom, timeTo, timeFrom, timeTo,
routeId: routeId || undefined, routeId: routeId || undefined,
group: appId || undefined, application: appId || undefined,
offset: 0, limit: 50, offset: 0, limit: 50,
}, true); }, true);
const { data: detail } = useExecutionDetail(selectedId); const { data: detail } = useExecutionDetail(selectedId);
const { data: snapshot } = useProcessorSnapshot(selectedId, processorIdx);
const rows: Row[] = useMemo(() => const rows: Row[] = useMemo(() =>
(searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })), (searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
[searchResult], [searchResult],
); );
const sparklineData = useMemo(() => const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null);
(timeseries?.buckets || []).map((b: any) => b.totalCount as number),
[timeseries], const totalCount = stats?.totalCount ?? 0;
); const failedCount = stats?.failedCount ?? 0;
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount * 100) : 100;
const throughput = timeWindowSeconds > 0 ? totalCount / timeWindowSeconds : 0;
const sparkExchanges = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => b.totalCount as number), [timeseries]);
const sparkErrors = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => b.failedCount as number), [timeseries]);
const sparkLatency = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number), [timeseries]);
const sparkThroughput = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => {
const bucketSeconds = timeWindowSeconds / Math.max((timeseries?.buckets || []).length, 1);
return bucketSeconds > 0 ? (b.totalCount as number) / bucketSeconds : 0;
}), [timeseries, timeWindowSeconds]);
const prevTotal = stats?.prevTotalCount ?? 0;
const prevFailed = stats?.prevFailedCount ?? 0;
const exchangeTrend = prevTotal > 0 ? ((totalCount - prevTotal) / prevTotal * 100) : 0;
const prevSuccessRate = prevTotal > 0 ? ((prevTotal - prevFailed) / prevTotal * 100) : 100;
const successRateDelta = successRate - prevSuccessRate;
const errorDelta = failedCount - prevFailed;
const columns: Column<Row>[] = [ const columns: Column<Row>[] = [
{ {
key: 'status', header: 'Status', width: '80px', key: 'status', header: 'Status', width: '80px',
render: (v) => <StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />, render: (v, row) => (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />
<MonoText size="xs">{v === 'COMPLETED' ? 'OK' : v === 'FAILED' ? 'ERR' : 'RUN'}</MonoText>
</span>
),
}, },
{ key: 'routeId', header: 'Route', render: (v) => <MonoText size="sm">{String(v)}</MonoText> }, {
{ key: 'groupName', header: 'App', render: (v) => <Badge label={String(v)} color="auto" /> }, key: '_inspect' as any, header: '', width: '36px',
{ key: 'executionId', header: 'Exchange ID', render: (v) => <MonoText size="xs">{String(v).slice(0, 12)}</MonoText> }, render: (_v, row) => (
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => new Date(v as string).toLocaleTimeString() }, <a
href={`/exchanges/${row.executionId}`}
onClick={(e) => { e.stopPropagation(); e.preventDefault(); navigate(`/exchanges/${row.executionId}`); }}
className={styles.inspectLink}
title="Open full details"
>&#x2197;</a>
),
},
{ key: 'routeId', header: 'Route', sortable: true, render: (v) => <span>{String(v)}</span> },
{ key: 'applicationName', header: 'Application', sortable: true, render: (v) => <span>{String(v ?? '')}</span> },
{ key: 'executionId', header: 'Exchange ID', sortable: true, render: (v) => <MonoText size="xs">{String(v)}</MonoText> },
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => <MonoText size="xs">{new Date(v as string).toLocaleString()}</MonoText> },
{ {
key: 'durationMs', header: 'Duration', sortable: true, key: 'durationMs', header: 'Duration', sortable: true,
render: (v) => `${v}ms`, render: (v) => <MonoText size="sm">{formatDuration(v as number)}</MonoText>,
},
{
key: 'agentId', header: 'Agent',
render: (v) => v ? <Badge label={String(v)} color="auto" /> : null,
}, },
]; ];
const detailTabs = detail ? [ const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : [];
{
label: 'Overview', value: 'overview', return (
content: ( <div>
<> <div className={styles.healthStrip}>
<StatCard
label="Exchanges"
value={totalCount.toLocaleString()}
detail={`${successRate.toFixed(1)}% success rate`}
trend={exchangeTrend > 0 ? 'up' : exchangeTrend < 0 ? 'down' : 'neutral'}
trendValue={exchangeTrend > 0 ? `+${exchangeTrend.toFixed(0)}%` : `${exchangeTrend.toFixed(0)}%`}
sparkline={sparkExchanges}
accent="amber"
/>
<StatCard
label="Success Rate"
value={`${successRate.toFixed(1)}%`}
detail={`${(totalCount - failedCount).toLocaleString()} ok / ${failedCount} error`}
trend={successRateDelta >= 0 ? 'up' : 'down'}
trendValue={`${successRateDelta >= 0 ? '+' : ''}${successRateDelta.toFixed(1)}%`}
accent="success"
/>
<StatCard
label="Errors"
value={failedCount}
detail={`${failedCount} errors in selected period`}
trend={errorDelta > 0 ? 'up' : errorDelta < 0 ? 'down' : 'neutral'}
trendValue={errorDelta > 0 ? `+${errorDelta}` : `${errorDelta}`}
sparkline={sparkErrors}
accent="error"
/>
<StatCard
label="Throughput"
value={throughput.toFixed(1)}
detail={`${throughput.toFixed(1)} msg/s`}
sparkline={sparkThroughput}
accent="running"
/>
<StatCard
label="Latency p99"
value={(stats?.p99LatencyMs ?? 0).toLocaleString()}
detail={`${(stats?.p99LatencyMs ?? 0).toLocaleString()}ms`}
sparkline={sparkLatency}
accent="warning"
/>
</div>
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Recent Exchanges</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{rows.length} of {searchResult?.total ?? 0} exchanges</span>
<Badge label="LIVE" color="success" />
</div>
</div>
<DataTable
columns={columns}
data={rows}
onRowClick={(row) => { setSelectedId(row.id); }}
selectedId={selectedId ?? undefined}
sortable
pageSize={25}
/>
</div>
{selectedId && detail && (
<DetailPanel
key={selectedId}
open={true}
onClose={() => setSelectedId(null)}
title={`${detail.routeId}${selectedId.slice(0, 12)}`}
className={styles.detailPanelOverride}
>
{/* Open full details link */}
<div className={styles.panelSection}> <div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Details</div> <button
className={styles.openDetailLink}
onClick={() => navigate(`/exchanges/${detail.executionId}`)}
>
Open full details &#x2192;
</button>
</div>
{/* Overview */}
<div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Overview</div>
<div className={styles.overviewGrid}> <div className={styles.overviewGrid}>
<div className={styles.overviewRow}> <div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Exchange ID</span> <span className={styles.overviewLabel}>Status</span>
<MonoText size="sm">{detail.executionId}</MonoText> <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : 'error'} />
<span>{detail.status}</span>
</span>
</div> </div>
<div className={styles.overviewRow}> <div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Status</span> <span className={styles.overviewLabel}>Duration</span>
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} /> <MonoText size="sm">{formatDuration(detail.durationMs)}</MonoText>
</div> </div>
<div className={styles.overviewRow}> <div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Route</span> <span className={styles.overviewLabel}>Route</span>
<span>{detail.routeId}</span> <span>{detail.routeId}</span>
</div> </div>
<div className={styles.overviewRow}> <div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Duration</span> <span className={styles.overviewLabel}>Agent</span>
<span>{detail.durationMs}ms</span> <MonoText size="sm">{detail.agentId ?? '—'}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Correlation</span>
<MonoText size="xs">{detail.correlationId ?? '—'}</MonoText>
</div>
<div className={styles.overviewRow}>
<span className={styles.overviewLabel}>Timestamp</span>
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toLocaleString() : '—'}</MonoText>
</div> </div>
</div> </div>
</div> </div>
{/* Errors */}
{detail.errorMessage && ( {detail.errorMessage && (
<div className={styles.panelSection}> <div className={styles.panelSection}>
<div className={styles.panelSectionTitle}>Errors</div> <div className={styles.panelSectionTitle}>Errors</div>
@@ -101,77 +239,33 @@ export default function Dashboard() {
)} )}
</div> </div>
)} )}
</>
),
},
{
label: 'Processors', value: 'processors',
content: (() => {
const procList = detail.processors?.length ? detail.processors : (detail.children ?? []);
return procList.length ? (
<ProcessorTimeline
processors={flattenProcessors(procList)}
totalMs={detail.durationMs}
onProcessorClick={(_p, i) => setProcessorIdx(i)}
selectedIndex={processorIdx ?? undefined}
/>
) : <div style={{ padding: '1rem' }}>No processor data</div>;
})(),
},
] : [];
return ( {/* Route Flow */}
<div> <div className={styles.panelSection}>
<div className={styles.healthStrip}> <div className={styles.panelSectionTitle}>Route Flow</div>
<StatCard {diagram ? (
label="Throughput" <RouteFlow
value={timeWindowSeconds > 0 ? `${((stats?.totalCount ?? 0) / timeWindowSeconds).toFixed(2)} ex/s` : '0.00 ex/s'} nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
sparkline={sparklineData} onNodeClick={(_node, _i) => {}}
/> />
<StatCard ) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>}
label="Error Rate"
value={(stats?.totalCount ?? 0) > 0 ? `${((stats?.failedCount ?? 0) / stats!.totalCount * 100).toFixed(1)}%` : '0.0%'}
accent="error"
/>
<StatCard
label="Avg Latency"
value={`${stats?.avgDurationMs ?? 0}ms`}
/>
<StatCard
label="P99 Latency"
value={`${stats?.p99LatencyMs ?? 0}ms`}
accent="warning"
/>
<StatCard
label="In-Flight"
value={stats?.activeCount ?? 0}
accent="running"
/>
</div>
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Recent Exchanges</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>{rows.length} results</span>
</div> </div>
</div>
<DataTable
columns={columns}
data={rows}
onRowClick={(row) => { setSelectedId(row.id); setProcessorIdx(null); }}
selectedId={selectedId ?? undefined}
sortable
pageSize={25}
/>
</div>
<DetailPanel {/* Processor Timeline */}
open={!!selectedId} <div className={styles.panelSection}>
onClose={() => setSelectedId(null)} <div className={styles.panelSectionTitle}>
title={selectedId ? `Exchange ${selectedId.slice(0, 12)}...` : ''} Processor Timeline
tabs={detailTabs} <span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
/> </div>
{procList.length ? (
<ProcessorTimeline
processors={flattenProcessors(procList)}
totalMs={detail.durationMs}
/>
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>}
</div>
</DetailPanel>
)}
</div> </div>
); );
} }

View File

@@ -21,6 +21,37 @@
flex: 1; flex: 1;
} }
.exchangeId {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.exchangeRoute {
font-size: 12px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.routeLink {
color: var(--accent, #c6820e);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.routeLink:hover {
color: var(--amber-deep, #a36b0b);
}
.headerDivider {
color: var(--text-faint);
}
.headerRight { .headerRight {
display: flex; display: flex;
gap: 20px; gap: 20px;
@@ -47,6 +78,62 @@
color: var(--text-primary); color: var(--text-primary);
} }
/* Correlation Chain */
.correlationChain {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
padding-top: 12px;
margin-top: 12px;
border-top: 1px solid var(--border-subtle);
flex-wrap: wrap;
}
.chainLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-right: 4px;
}
.chainNode {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: var(--radius-sm, 4px);
border: 1px solid var(--border-subtle);
font-size: 11px;
font-family: var(--font-mono);
cursor: pointer;
background: var(--bg-surface);
color: var(--text-secondary);
transition: all 0.12s;
}
.chainNode:hover {
border-color: var(--text-faint);
background: var(--bg-hover);
}
.chainNodeCurrent {
background: var(--amber-bg, rgba(198, 130, 14, 0.08));
border-color: var(--accent, #c6820e);
color: var(--accent, #c6820e);
font-weight: 600;
}
.chainNodeSuccess { border-left: 3px solid var(--success); }
.chainNodeError { border-left: 3px solid var(--error); }
.chainNodeRunning { border-left: 3px solid var(--running); }
.chainNodeWarning { border-left: 3px solid var(--warning); }
.chainMore { color: var(--text-muted); font-size: 11px; font-style: italic; }
/* Timeline Section */
.timelineSection { .timelineSection {
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
@@ -68,12 +155,59 @@
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.procCount {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
padding: 1px 8px;
border-radius: 10px;
background: var(--bg-inset);
color: var(--text-muted);
}
.timelineToggle {
display: inline-flex;
gap: 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm, 4px);
overflow: hidden;
}
.toggleBtn {
padding: 4px 12px;
font-size: 11px;
font-family: var(--font-body);
border: none;
background: transparent;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.12s;
}
.toggleBtn:hover {
background: var(--bg-hover);
}
.toggleBtnActive {
background: var(--accent, #c6820e);
color: #fff;
font-weight: 600;
}
.toggleBtnActive:hover {
background: var(--amber-deep, #a36b0b);
} }
.timelineBody { .timelineBody {
padding: 12px 16px; padding: 12px 16px;
} }
/* Detail Split (IN / OUT panels) */
.detailSplit { .detailSplit {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -89,6 +223,10 @@
overflow: hidden; overflow: hidden;
} }
.detailPanelError {
border-color: var(--error-border, rgba(220, 38, 38, 0.3));
}
.panelHeader { .panelHeader {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -96,18 +234,66 @@
padding: 10px 16px; padding: 10px 16px;
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
background: var(--bg-raised); background: var(--bg-raised);
gap: 8px;
}
.detailPanelError .panelHeader {
background: var(--error-bg, rgba(220, 38, 38, 0.06));
border-bottom-color: var(--error-border, rgba(220, 38, 38, 0.3));
} }
.panelTitle { .panelTitle {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
.arrowIn {
color: var(--success);
font-weight: 700;
}
.arrowOut {
color: var(--running);
font-weight: 700;
}
.arrowError {
color: var(--error);
font-weight: 700;
font-size: 16px;
}
.panelTag {
font-family: var(--font-mono);
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
background: var(--bg-inset);
color: var(--text-muted);
font-weight: 500;
white-space: nowrap;
} }
.panelBody { .panelBody {
padding: 16px; padding: 16px;
} }
/* Headers section */
.headersSection {
margin-bottom: 12px;
}
.headerList {
display: flex;
flex-direction: column;
gap: 0;
}
.headerKvRow { .headerKvRow {
display: grid; display: grid;
grid-template-columns: 140px 1fr; grid-template-columns: 140px 1fr;
@@ -124,6 +310,9 @@
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.headerValue { .headerValue {
@@ -131,6 +320,12 @@
color: var(--text-primary); color: var(--text-primary);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
}
/* Body section */
.bodySection {
margin-top: 12px;
} }
.sectionLabel { .sectionLabel {
@@ -140,44 +335,50 @@
letter-spacing: 0.6px; letter-spacing: 0.6px;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 6px; margin-bottom: 6px;
}
.correlationChain { margin-bottom: 16px; }
.chainRow {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
padding: 12px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
}
.chainArrow { color: var(--text-muted); font-size: 16px; flex-shrink: 0; }
.chainCard {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 10px;
background: var(--bg-raised);
border: 1px solid var(--border-subtle);
border-radius: 6px;
font-size: 12px;
text-decoration: none;
color: var(--text-primary);
flex-shrink: 0;
cursor: pointer;
} }
.chainCard:hover { background: var(--bg-hover); } .count {
font-family: var(--font-mono);
font-size: 10px;
padding: 0 5px;
border-radius: 8px;
background: var(--bg-inset);
color: var(--text-faint);
}
.chainCardActive { border-color: var(--accent); background: var(--bg-hover); } /* Error panel styles */
.errorMessageBox {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
background: var(--error-bg, rgba(220, 38, 38, 0.06));
padding: 10px 12px;
border-radius: var(--radius-sm, 4px);
border: 1px solid var(--error-border, rgba(220, 38, 38, 0.3));
margin-bottom: 12px;
line-height: 1.5;
word-break: break-word;
white-space: pre-wrap;
}
.chainRoute { font-weight: 600; } .errorDetailGrid {
display: grid;
grid-template-columns: 120px 1fr;
gap: 4px 12px;
font-size: 11px;
}
.chainDuration { color: var(--text-muted); font-family: var(--font-mono); font-size: 11px; } .errorDetailLabel {
font-weight: 600;
color: var(--text-muted);
font-family: var(--font-mono);
}
.chainMore { color: var(--text-muted); font-size: 11px; font-style: italic; } .errorDetailValue {
color: var(--text-primary);
font-family: var(--font-mono);
word-break: break-all;
}

View File

@@ -2,11 +2,11 @@ import React, { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import { import {
Badge, StatusDot, MonoText, CodeBlock, InfoCallout, Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
ProcessorTimeline, Breadcrumb, Spinner, SegmentedTabs, RouteFlow, ProcessorTimeline, Breadcrumb, Spinner, RouteFlow,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'; import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
import { useCorrelationChain } from '../../api/queries/correlation'; import { useCorrelationChain } from '../../api/queries/correlation';
import { useDiagramByRoute } from '../../api/queries/diagrams'; import { useDiagramLayout } from '../../api/queries/diagrams';
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'; import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
import styles from './ExchangeDetail.module.css'; import styles from './ExchangeDetail.module.css';
@@ -14,18 +14,53 @@ function countProcessors(nodes: any[]): number {
return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0); return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0);
} }
function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
return `${ms}ms`;
}
function parseHeaders(raw: string | undefined | null): Record<string, string> {
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (typeof parsed === 'object' && parsed !== null) {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed)) {
result[k] = typeof v === 'string' ? v : JSON.stringify(v);
}
return result;
}
} catch { /* ignore */ }
return {};
}
export default function ExchangeDetail() { export default function ExchangeDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: detail, isLoading } = useExecutionDetail(id ?? null); const { data: detail, isLoading } = useExecutionDetail(id ?? null);
const [selectedProcessor, setSelectedProcessor] = useState<number | null>(null); const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt');
const [viewMode, setViewMode] = useState<'timeline' | 'flow'>('timeline');
const { data: snapshot } = useProcessorSnapshot(id ?? null, selectedProcessor);
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null); const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null);
const { data: diagram } = useDiagramByRoute(detail?.groupName, detail?.routeId); const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null);
const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : [];
// Auto-select first failed processor, or 0
const defaultIndex = useMemo(() => {
if (!procList.length) return 0;
const failIdx = procList.findIndex((p: any) =>
(p.status || '').toUpperCase() === 'FAILED' || p.status === 'fail'
);
return failIdx >= 0 ? failIdx : 0;
}, [procList]);
const [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null);
const activeIndex = selectedProcessorIndex ?? defaultIndex;
const { data: snapshot } = useProcessorSnapshot(id ?? null, procList.length > 0 ? activeIndex : null);
const processors = useMemo(() => { const processors = useMemo(() => {
if (!detail?.children) return []; if (!procList.length) return [];
const result: any[] = []; const result: any[] = [];
let offset = 0; let offset = 0;
function walk(node: any) { function walk(node: any) {
@@ -39,9 +74,18 @@ export default function ExchangeDetail() {
offset += node.durationMs ?? 0; offset += node.durationMs ?? 0;
if (node.children) node.children.forEach(walk); if (node.children) node.children.forEach(walk);
} }
detail.children.forEach(walk); procList.forEach(walk);
return result; return result;
}, [detail]); }, [procList]);
const selectedProc = processors[activeIndex];
const isSelectedFailed = selectedProc?.status === 'fail';
// Parse snapshot headers
const inputHeaders = parseHeaders(snapshot?.inputHeaders);
const outputHeaders = parseHeaders(snapshot?.outputHeaders);
const inputBody = snapshot?.inputBody ?? null;
const outputBody = snapshot?.outputBody ?? null;
if (isLoading) return <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>; if (isLoading) return <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
if (!detail) return <InfoCallout variant="warning">Exchange not found</InfoCallout>; if (!detail) return <InfoCallout variant="warning">Exchange not found</InfoCallout>;
@@ -50,93 +94,120 @@ export default function ExchangeDetail() {
<div> <div>
<Breadcrumb items={[ <Breadcrumb items={[
{ label: 'Dashboard', href: '/apps' }, { label: 'Dashboard', href: '/apps' },
{ label: detail.groupName || 'App', href: `/apps/${detail.groupName}` }, { label: detail.applicationName || 'App', href: `/apps/${detail.applicationName}` },
{ label: id?.slice(0, 12) || '' }, { label: id?.slice(0, 12) || '' },
]} /> ]} />
{/* Exchange header card */}
<div className={styles.exchangeHeader}> <div className={styles.exchangeHeader}>
<div className={styles.headerRow}> <div className={styles.headerRow}>
<div className={styles.headerLeft}> <div className={styles.headerLeft}>
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} /> <StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} />
<div> <div>
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} /> <div className={styles.exchangeId}>
<MonoText>{id}</MonoText> <MonoText size="md">{id}</MonoText>
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} variant="filled" />
</div>
<div className={styles.exchangeRoute}>
Route: <span className={styles.routeLink} onClick={() => navigate(`/apps/${detail.applicationName}/${detail.routeId}`)}>{detail.routeId}</span>
{detail.applicationName && (
<>
<span className={styles.headerDivider}>&middot;</span>
App: <MonoText size="xs">{detail.applicationName}</MonoText>
</>
)}
</div>
</div> </div>
</div> </div>
<div className={styles.headerRight}> <div className={styles.headerRight}>
<div className={styles.headerStat}> <div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Duration</div> <div className={styles.headerStatLabel}>Duration</div>
<div className={styles.headerStatValue}>{detail.durationMs}ms</div> <div className={styles.headerStatValue}>{formatDuration(detail.durationMs)}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Agent</div>
<div className={styles.headerStatValue}>{detail.agentId}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Started</div>
<div className={styles.headerStatValue}>
{detail.startTime ? new Date(detail.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '—'}
</div>
</div> </div>
<div className={styles.headerStat}> <div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Processors</div> <div className={styles.headerStatLabel}>Processors</div>
<div className={styles.headerStatValue}>{countProcessors(detail.processors || detail.children || [])}</div> <div className={styles.headerStatValue}>{countProcessors(procList)}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Route</div>
<div className={styles.headerStatValue}>{detail.routeId}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Application</div>
<div className={styles.headerStatValue}>{detail.groupName || 'unknown'}</div>
</div> </div>
</div> </div>
</div> </div>
</div>
{correlationData?.data && correlationData.data.length > 1 && ( {/* Correlation Chain */}
<div className={styles.correlationChain}> {correlationData?.data && correlationData.data.length > 1 && (
<div className={styles.panelHeader}> <div className={styles.correlationChain}>
<span className={styles.panelTitle}>Correlation Chain</span> <span className={styles.chainLabel}>Correlated Exchanges</span>
</div> {correlationData.data.map((exec: any) => {
<div className={styles.chainRow}> const isCurrent = exec.executionId === id;
{correlationData.data.map((exec, i) => ( const variant = exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'running';
<React.Fragment key={exec.executionId}> const statusCls =
{i > 0 && <span className={styles.chainArrow}></span>} variant === 'success' ? styles.chainNodeSuccess
<a : variant === 'error' ? styles.chainNodeError
href={`/exchanges/${exec.executionId}`} : styles.chainNodeRunning;
className={`${styles.chainCard} ${exec.executionId === id ? styles.chainCardActive : ''}`} return (
onClick={(e) => { e.preventDefault(); navigate(`/exchanges/${exec.executionId}`); }} <button
key={exec.executionId}
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
onClick={() => { if (!isCurrent) navigate(`/exchanges/${exec.executionId}`); }}
title={`${exec.executionId}${exec.routeId}`}
> >
<StatusDot variant={exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'warning'} /> <StatusDot variant={variant as any} />
<span className={styles.chainRoute}>{exec.routeId}</span> <span>{exec.routeId}</span>
<span className={styles.chainDuration}>{exec.durationMs}ms</span> </button>
</a> );
</React.Fragment> })}
))}
{correlationData.total > 20 && ( {correlationData.total > 20 && (
<span className={styles.chainMore}>+{correlationData.total - 20} more</span> <span className={styles.chainMore}>+{correlationData.total - 20} more</span>
)} )}
</div> </div>
</div> )}
)} </div>
{/* Error callout */}
{detail.errorMessage && ( {detail.errorMessage && (
<InfoCallout variant="error"> <InfoCallout variant="error">
{detail.errorMessage} {detail.errorMessage}
</InfoCallout> </InfoCallout>
)} )}
{/* Processor Timeline / Flow Section */}
<div className={styles.timelineSection}> <div className={styles.timelineSection}>
<div className={styles.timelineHeader}> <div className={styles.timelineHeader}>
<span className={styles.timelineTitle}>Processors</span> <span className={styles.timelineTitle}>
<SegmentedTabs Processor Timeline
tabs={[ <span className={styles.procCount}>{processors.length} processors</span>
{ label: 'Timeline', value: 'timeline' }, </span>
{ label: 'Flow', value: 'flow' }, <div className={styles.timelineToggle}>
]} <button
active={viewMode} className={`${styles.toggleBtn} ${timelineView === 'gantt' ? styles.toggleBtnActive : ''}`}
onChange={(v) => setViewMode(v as 'timeline' | 'flow')} onClick={() => setTimelineView('gantt')}
/> >
Timeline
</button>
<button
className={`${styles.toggleBtn} ${timelineView === 'flow' ? styles.toggleBtnActive : ''}`}
onClick={() => setTimelineView('flow')}
>
Flow
</button>
</div>
</div> </div>
<div className={styles.timelineBody}> <div className={styles.timelineBody}>
{viewMode === 'timeline' ? ( {timelineView === 'gantt' ? (
processors.length > 0 ? ( processors.length > 0 ? (
<ProcessorTimeline <ProcessorTimeline
processors={processors} processors={processors}
totalMs={detail.durationMs} totalMs={detail.durationMs}
onProcessorClick={(_p, i) => setSelectedProcessor(i)} onProcessorClick={(_p, i) => setSelectedProcessorIndex(i)}
selectedIndex={selectedProcessor ?? undefined} selectedIndex={activeIndex}
/> />
) : ( ) : (
<InfoCallout>No processor data available</InfoCallout> <InfoCallout>No processor data available</InfoCallout>
@@ -144,9 +215,9 @@ export default function ExchangeDetail() {
) : ( ) : (
diagram ? ( diagram ? (
<RouteFlow <RouteFlow
nodes={mapDiagramToRouteNodes(diagram.nodes || [], detail.processors || detail.children || [])} nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
onNodeClick={(_node, i) => setSelectedProcessor(i)} onNodeClick={(_node, i) => setSelectedProcessorIndex(i)}
selectedIndex={selectedProcessor ?? undefined} selectedIndex={activeIndex}
/> />
) : ( ) : (
<Spinner /> <Spinner />
@@ -155,46 +226,102 @@ export default function ExchangeDetail() {
</div> </div>
</div> </div>
{snapshot && ( {/* Processor Detail: Message IN / Message OUT or Error */}
<> {selectedProc && snapshot && (
<div className={styles.sectionLabel}>Exchange Snapshot</div> <div className={styles.detailSplit}>
<div className={styles.detailSplit}> {/* Message IN */}
<div className={styles.detailPanel}> <div className={styles.detailPanel}>
<div className={styles.panelHeader}> <div className={styles.panelHeader}>
<span className={styles.panelTitle}>Input Body</span> <span className={styles.panelTitle}>
</div> <span className={styles.arrowIn}>&rarr;</span> Message IN
<div className={styles.panelBody}> </span>
<CodeBlock content={String(snapshot.inputBody ?? 'null')} /> <span className={styles.panelTag}>at processor #{activeIndex + 1} entry</span>
</div>
</div> </div>
<div className={styles.detailPanel}> <div className={styles.panelBody}>
<div className={styles.panelHeader}> {Object.keys(inputHeaders).length > 0 && (
<span className={styles.panelTitle}>Output Body</span> <div className={styles.headersSection}>
</div> <div className={styles.sectionLabel}>
<div className={styles.panelBody}> Headers <span className={styles.count}>{Object.keys(inputHeaders).length}</span>
<CodeBlock content={String(snapshot.outputBody ?? 'null')} /> </div>
<div className={styles.headerList}>
{Object.entries(inputHeaders).map(([key, value]) => (
<div key={key} className={styles.headerKvRow}>
<span className={styles.headerKey}>{key}</span>
<span className={styles.headerValue}>{value}</span>
</div>
))}
</div>
</div>
)}
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={inputBody ?? 'null'} />
</div> </div>
</div> </div>
</div> </div>
<div className={styles.detailSplit}>
<div className={styles.detailPanel}> {/* Message OUT or Error */}
{isSelectedFailed ? (
<div className={`${styles.detailPanel} ${styles.detailPanelError}`}>
<div className={styles.panelHeader}> <div className={styles.panelHeader}>
<span className={styles.panelTitle}>Input Headers</span> <span className={styles.panelTitle}>
<span className={styles.arrowError}>&times;</span> Error at Processor #{activeIndex + 1}
</span>
<Badge label="FAILED" color="error" variant="filled" />
</div> </div>
<div className={styles.panelBody}> <div className={styles.panelBody}>
<CodeBlock content={JSON.stringify(snapshot.inputHeaders ?? {}, null, 2)} /> {detail.errorMessage && (
<div className={styles.errorMessageBox}>{detail.errorMessage}</div>
)}
<div className={styles.errorDetailGrid}>
<span className={styles.errorDetailLabel}>Processor</span>
<span className={styles.errorDetailValue}>{selectedProc.name}</span>
<span className={styles.errorDetailLabel}>Duration</span>
<span className={styles.errorDetailValue}>{formatDuration(selectedProc.durationMs)}</span>
<span className={styles.errorDetailLabel}>Status</span>
<span className={styles.errorDetailValue}>{selectedProc.status.toUpperCase()}</span>
</div>
</div> </div>
</div> </div>
) : (
<div className={styles.detailPanel}> <div className={styles.detailPanel}>
<div className={styles.panelHeader}> <div className={styles.panelHeader}>
<span className={styles.panelTitle}>Output Headers</span> <span className={styles.panelTitle}>
<span className={styles.arrowOut}>&larr;</span> Message OUT
</span>
<span className={styles.panelTag}>after processor #{activeIndex + 1}</span>
</div> </div>
<div className={styles.panelBody}> <div className={styles.panelBody}>
<CodeBlock content={JSON.stringify(snapshot.outputHeaders ?? {}, null, 2)} /> {Object.keys(outputHeaders).length > 0 && (
<div className={styles.headersSection}>
<div className={styles.sectionLabel}>
Headers <span className={styles.count}>{Object.keys(outputHeaders).length}</span>
</div>
<div className={styles.headerList}>
{Object.entries(outputHeaders).map(([key, value]) => (
<div key={key} className={styles.headerKvRow}>
<span className={styles.headerKey}>{key}</span>
<span className={styles.headerValue}>{value}</span>
</div>
))}
</div>
</div>
)}
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={outputBody ?? 'null'} />
</div>
</div> </div>
</div> </div>
</div> )}
</> </div>
)}
{/* No snapshot loaded yet - show prompt */}
{selectedProc && !snapshot && procList.length > 0 && (
<div style={{ color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', padding: 20 }}>
Loading exchange snapshot...
</div>
)} )}
</div> </div>
); );

View File

@@ -51,7 +51,7 @@ export default function RouteDetail() {
timeFrom, timeFrom,
timeTo, timeTo,
routeId: routeId || undefined, routeId: routeId || undefined,
group: appId || undefined, application: appId || undefined,
offset: 0, offset: 0,
limit: 50, limit: 50,
}); });
@@ -59,7 +59,7 @@ export default function RouteDetail() {
timeFrom, timeFrom,
timeTo, timeTo,
routeId: routeId || undefined, routeId: routeId || undefined,
group: appId || undefined, application: appId || undefined,
status: 'FAILED', status: 'FAILED',
offset: 0, offset: 0,
limit: 200, limit: 200,

View File

@@ -1,6 +1,6 @@
.statStrip { .statStrip {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 10px; gap: 10px;
margin-bottom: 16px; margin-bottom: 16px;
} }

View File

@@ -47,13 +47,19 @@ export default function RoutesMetrics() {
); );
const chartData = useMemo(() => const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => ({ (timeseries?.buckets || []).map((b: any, i: number) => {
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), const ts = b.timestamp ? new Date(b.timestamp) : null;
throughput: b.totalCount, const time = ts && !isNaN(ts.getTime())
latency: b.avgDurationMs, ? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
errors: b.failedCount, : String(i);
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100, return {
})), time,
throughput: b.totalCount ?? 0,
latency: b.avgDurationMs ?? 0,
errors: b.failedCount ?? 0,
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
};
}),
[timeseries], [timeseries],
); );
@@ -81,18 +87,89 @@ export default function RoutesMetrics() {
}, },
]; ];
const errorRate = stats?.totalCount
? (((stats.failedCount ?? 0) / stats.totalCount) * 100)
: 0;
const prevErrorRate = stats?.prevTotalCount
? (((stats.prevFailedCount ?? 0) / stats.prevTotalCount) * 100)
: 0;
const errorTrend: 'up' | 'down' | 'neutral' = errorRate > prevErrorRate ? 'up' : errorRate < prevErrorRate ? 'down' : 'neutral';
const errorTrendValue = stats?.prevTotalCount
? `${Math.abs(errorRate - prevErrorRate).toFixed(2)}%`
: undefined;
const p99Ms = stats?.p99LatencyMs ?? 0;
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
const latencyTrend: 'up' | 'down' | 'neutral' = p99Ms > prevP99Ms ? 'up' : p99Ms < prevP99Ms ? 'down' : 'neutral';
const latencyTrendValue = prevP99Ms ? `${Math.abs(p99Ms - prevP99Ms)}ms` : undefined;
const totalCount = stats?.totalCount ?? 0;
const prevTotalCount = stats?.prevTotalCount ?? 0;
const throughputTrend: 'up' | 'down' | 'neutral' = totalCount > prevTotalCount ? 'up' : totalCount < prevTotalCount ? 'down' : 'neutral';
const throughputTrendValue = prevTotalCount
? `${Math.abs(((totalCount - prevTotalCount) / prevTotalCount) * 100).toFixed(0)}%`
: undefined;
const successRate = stats?.totalCount
? (((stats.totalCount - (stats.failedCount ?? 0)) / stats.totalCount) * 100)
: 100;
const activeCount = stats?.activeCount ?? 0;
const errorSparkline = (timeseries?.buckets || []).map((b: any) => b.failedCount as number);
const latencySparkline = (timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number);
return ( return (
<div> <div>
<div className={styles.statStrip}> <div className={styles.statStrip}>
<StatCard label="Total Throughput" value={stats?.totalCount ?? 0} sparkline={sparklineData} /> <StatCard
<StatCard label="Error Rate" value={stats?.totalCount ? `${(((stats.failedCount ?? 0) / stats.totalCount) * 100).toFixed(1)}%` : '0%'} accent="error" /> label="Total Throughput"
<StatCard label="P99 Latency" value={`${stats?.p99LatencyMs ?? 0}ms`} accent="warning" /> value={totalCount.toLocaleString()}
<StatCard label="Success Rate" value={stats?.totalCount ? `${(((stats.totalCount - (stats.failedCount ?? 0)) / stats.totalCount) * 100).toFixed(1)}%` : '100%'} accent="success" /> detail="exchanges"
trend={throughputTrend}
trendValue={throughputTrendValue}
accent="amber"
sparkline={sparklineData}
/>
<StatCard
label="System Error Rate"
value={`${errorRate.toFixed(2)}%`}
detail={`${stats?.failedCount ?? 0} errors / ${totalCount.toLocaleString()} total`}
trend={errorTrend}
trendValue={errorTrendValue}
accent={errorRate < 1 ? 'success' : 'error'}
sparkline={errorSparkline}
/>
<StatCard
label="P99 Latency"
value={`${p99Ms}ms`}
detail={`Avg: ${stats?.avgDurationMs ?? 0}ms`}
trend={latencyTrend}
trendValue={latencyTrendValue}
accent={p99Ms > 300 ? 'error' : p99Ms > 200 ? 'warning' : 'success'}
sparkline={latencySparkline}
/>
<StatCard
label="Success Rate"
value={`${successRate.toFixed(1)}%`}
detail={`${activeCount} active routes`}
accent="success"
sparkline={sparklineData.map((v, i) => {
const failed = errorSparkline[i] ?? 0;
return v > 0 ? ((v - failed) / v) * 100 : 100;
})}
/>
<StatCard
label="In-Flight"
value={activeCount}
detail="active exchanges"
accent="amber"
/>
</div> </div>
<div className={styles.tableSection}> <div className={styles.tableSection}>
<div className={styles.tableHeader}> <div className={styles.tableHeader}>
<span className={styles.tableTitle}>Route Metrics</span> <span className={styles.tableTitle}>Per-Route Performance</span>
<span className={styles.tableMeta}>{rows.length} routes</span> <span className={styles.tableMeta}>{rows.length} routes</span>
</div> </div>
<DataTable <DataTable
@@ -106,20 +183,25 @@ export default function RoutesMetrics() {
{chartData.length > 0 && ( {chartData.length > 0 && (
<div className={styles.chartGrid}> <div className={styles.chartGrid}>
<div className={styles.chartCard}> <div className={styles.chartCard}>
<div className={styles.chartTitle}>Throughput</div> <div className={styles.chartTitle}>Throughput (msg/s)</div>
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} height={200} /> <AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/s" height={200} />
</div> </div>
<div className={styles.chartCard}> <div className={styles.chartCard}>
<div className={styles.chartTitle}>Latency</div> <div className={styles.chartTitle}>Latency (ms)</div>
<LineChart series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]} height={200} /> <LineChart
series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]}
yLabel="ms"
height={200}
threshold={{ value: 300, label: 'SLA 300ms' }}
/>
</div> </div>
<div className={styles.chartCard}> <div className={styles.chartCard}>
<div className={styles.chartTitle}>Errors</div> <div className={styles.chartTitle}>Errors by Route</div>
<BarChart series={[{ label: 'Errors', data: chartData.map((d: any) => ({ x: d.time as string, y: d.errors })) }]} height={200} /> <BarChart series={[{ label: 'Errors', data: chartData.map((d: any) => ({ x: d.time as string, y: d.errors })) }]} height={200} />
</div> </div>
<div className={styles.chartCard}> <div className={styles.chartCard}>
<div className={styles.chartTitle}>Success Rate</div> <div className={styles.chartTitle}>Message Volume (msg/min)</div>
<AreaChart series={[{ label: 'Success Rate', data: chartData.map((d: any, i: number) => ({ x: i, y: d.successRate })) }]} height={200} /> <AreaChart series={[{ label: 'Volume', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/min" height={200} />
</div> </div>
</div> </div>
)} )}

View File

@@ -12,6 +12,7 @@ const RoutesMetrics = lazy(() => import('./pages/Routes/RoutesMetrics'));
const RouteDetail = lazy(() => import('./pages/Routes/RouteDetail')); const RouteDetail = lazy(() => import('./pages/Routes/RouteDetail'));
const AgentHealth = lazy(() => import('./pages/AgentHealth/AgentHealth')); const AgentHealth = lazy(() => import('./pages/AgentHealth/AgentHealth'));
const AgentInstance = lazy(() => import('./pages/AgentInstance/AgentInstance')); const AgentInstance = lazy(() => import('./pages/AgentInstance/AgentInstance'));
const AdminLayout = lazy(() => import('./pages/Admin/AdminLayout'));
const RbacPage = lazy(() => import('./pages/Admin/RbacPage')); const RbacPage = lazy(() => import('./pages/Admin/RbacPage'));
const AuditLogPage = lazy(() => import('./pages/Admin/AuditLogPage')); const AuditLogPage = lazy(() => import('./pages/Admin/AuditLogPage'));
const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage')); const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
@@ -47,12 +48,18 @@ export const router = createBrowserRouter([
{ path: 'agents', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> }, { path: 'agents', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> },
{ path: 'agents/:appId', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> }, { path: 'agents/:appId', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> },
{ path: 'agents/:appId/:instanceId', element: <SuspenseWrapper><AgentInstance /></SuspenseWrapper> }, { path: 'agents/:appId/:instanceId', element: <SuspenseWrapper><AgentInstance /></SuspenseWrapper> },
{ path: 'admin', element: <Navigate to="/admin/rbac" replace /> }, {
{ path: 'admin/rbac', element: <SuspenseWrapper><RbacPage /></SuspenseWrapper> }, path: 'admin',
{ path: 'admin/audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> }, element: <SuspenseWrapper><AdminLayout /></SuspenseWrapper>,
{ path: 'admin/oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> }, children: [
{ path: 'admin/database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> }, { index: true, element: <Navigate to="/admin/rbac" replace /> },
{ path: 'admin/opensearch', element: <SuspenseWrapper><OpenSearchAdminPage /></SuspenseWrapper> }, { path: 'rbac', element: <SuspenseWrapper><RbacPage /></SuspenseWrapper> },
{ path: 'audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
{ path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
{ path: 'opensearch', element: <SuspenseWrapper><OpenSearchAdminPage /></SuspenseWrapper> },
],
},
{ path: 'api-docs', element: <SuspenseWrapper><SwaggerPage /></SuspenseWrapper> }, { path: 'api-docs', element: <SuspenseWrapper><SwaggerPage /></SuspenseWrapper> },
], ],
}, },

View File

@@ -0,0 +1,55 @@
import type { RouteNode } from '@cameleer/design-system';
// Map NodeType strings to RouteNode types
function mapNodeType(type: string): RouteNode['type'] {
const lower = type?.toLowerCase() || '';
if (lower.includes('from') || lower === 'endpoint') return 'from';
if (lower.includes('to')) return 'to';
if (lower.includes('choice') || lower.includes('when') || lower.includes('otherwise')) return 'choice';
if (lower.includes('error') || lower.includes('dead')) return 'error-handler';
return 'process';
}
function mapStatus(status: string | undefined): RouteNode['status'] {
if (!status) return 'ok';
const s = status.toUpperCase();
if (s === 'FAILED') return 'fail';
if (s === 'RUNNING') return 'slow';
return 'ok';
}
/**
* Maps diagram PositionedNodes + execution ProcessorNodes to RouteFlow RouteNode[] format.
* Joins on diagramNodeId → node.id.
*/
export function mapDiagramToRouteNodes(
diagramNodes: Array<{ id?: string; label?: string; type?: string }>,
processors: Array<{ diagramNodeId?: string; processorId?: string; status?: string; durationMs?: number; children?: any[] }>
): RouteNode[] {
// Flatten processor tree
const flatProcessors: typeof processors = [];
function flatten(nodes: typeof processors) {
for (const n of nodes) {
flatProcessors.push(n);
if (n.children) flatten(n.children);
}
}
flatten(processors || []);
// Build lookup: diagramNodeId → processor
const procMap = new Map<string, (typeof flatProcessors)[0]>();
for (const p of flatProcessors) {
if (p.diagramNodeId) procMap.set(p.diagramNodeId, p);
}
return diagramNodes.map(node => {
const proc = procMap.get(node.id ?? '');
return {
name: node.label || node.id || '',
type: mapNodeType(node.type ?? ''),
durationMs: proc?.durationMs ?? 0,
status: mapStatus(proc?.status),
isBottleneck: false,
};
});
}

View File

@@ -0,0 +1 @@
{"root":["./src/config.ts","./src/main.tsx","./src/router.tsx","./src/swagger-ui-dist.d.ts","./src/vite-env.d.ts","./src/api/client.ts","./src/api/schema.d.ts","./src/api/types.ts","./src/api/queries/agents.ts","./src/api/queries/catalog.ts","./src/api/queries/diagrams.ts","./src/api/queries/executions.ts","./src/api/queries/admin/admin-api.ts","./src/api/queries/admin/audit.ts","./src/api/queries/admin/database.ts","./src/api/queries/admin/opensearch.ts","./src/api/queries/admin/rbac.ts","./src/api/queries/admin/thresholds.ts","./src/auth/loginpage.tsx","./src/auth/oidccallback.tsx","./src/auth/protectedroute.tsx","./src/auth/auth-store.ts","./src/auth/use-auth.ts","./src/components/layoutshell.tsx","./src/pages/admin/auditlogpage.tsx","./src/pages/admin/databaseadminpage.tsx","./src/pages/admin/oidcconfigpage.tsx","./src/pages/admin/opensearchadminpage.tsx","./src/pages/admin/rbacpage.tsx","./src/pages/agenthealth/agenthealth.tsx","./src/pages/agentinstance/agentinstance.tsx","./src/pages/dashboard/dashboard.tsx","./src/pages/exchangedetail/exchangedetail.tsx","./src/pages/routes/routesmetrics.tsx","./src/pages/swagger/swaggerpage.tsx"],"version":"5.9.3"}

View File

@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.9.3"}

1
ui/tsconfig.tsbuildinfo Normal file
View File

@@ -0,0 +1 @@
{"root":["./vite.config.ts","./src/config.ts","./src/main.tsx","./src/router.tsx","./src/swagger-ui-dist.d.ts","./src/vite-env.d.ts","./src/api/client.ts","./src/api/schema.d.ts","./src/api/types.ts","./src/api/queries/agents.ts","./src/api/queries/catalog.ts","./src/api/queries/diagrams.ts","./src/api/queries/executions.ts","./src/api/queries/admin/admin-api.ts","./src/api/queries/admin/audit.ts","./src/api/queries/admin/database.ts","./src/api/queries/admin/opensearch.ts","./src/api/queries/admin/rbac.ts","./src/api/queries/admin/thresholds.ts","./src/auth/loginpage.tsx","./src/auth/oidccallback.tsx","./src/auth/protectedroute.tsx","./src/auth/auth-store.ts","./src/auth/use-auth.ts","./src/components/layoutshell.tsx","./src/pages/admin/auditlogpage.tsx","./src/pages/admin/databaseadminpage.tsx","./src/pages/admin/oidcconfigpage.tsx","./src/pages/admin/opensearchadminpage.tsx","./src/pages/admin/rbacpage.tsx","./src/pages/agenthealth/agenthealth.tsx","./src/pages/agentinstance/agentinstance.tsx","./src/pages/dashboard/dashboard.tsx","./src/pages/exchangedetail/exchangedetail.tsx","./src/pages/routes/routesmetrics.tsx","./src/pages/swagger/swaggerpage.tsx"],"errors":true,"version":"5.9.3"}

View File

@@ -13,6 +13,11 @@ export default defineConfig({
target: apiTarget, target: apiTarget,
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
proxyReq.removeHeader('origin');
});
},
}, },
}, },
}, },