43 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
hsiegeln
752d7ec0e7 feat: add Users tab with split-pane layout, inline create, detail panel
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:32:45 +01:00
hsiegeln
9ab38dfc59 feat: add Groups tab with hierarchy management and member/role assignment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:32:18 +01:00
hsiegeln
907bcd5017 feat: add Roles tab with system role protection and principal display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:32:07 +01:00
hsiegeln
83caf4be5b feat: align Agent Instance with mock — JVM charts, process info, stat cards, log placeholder
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:29:25 +01:00
hsiegeln
1533bea2a6 refactor: restructure RBAC page to container + tab components, add CSS module
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:28:52 +01:00
hsiegeln
94d1e81852 feat: add Route Detail page with diagram, processor stats, and tabbed sections
Replaces the filtered RoutesMetrics view at /routes/:appId/:routeId with a
dedicated RouteDetail page showing route diagram, processor stats table,
performance charts, recent executions, and client-side grouped error patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:25:58 +01:00
hsiegeln
8e27f45a2b feat: add default roles and ConfirmDialog to OIDC config
Adds a Default Roles section with Tag components for viewing/removing roles and an Input+Button for adding new ones. Replaces the plain delete button with a ConfirmDialog requiring typed confirmation. Introduces OidcConfigPage.module.css for CSS module layout classes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:25:14 +01:00
hsiegeln
a86f56f588 feat: add Timeline/Flow toggle to Exchange Detail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:22:45 +01:00
hsiegeln
651cf9de6e feat: add correlation chain and processor count to Exchange Detail
Adds a recursive processor count stat to the exchange header, and a
Correlation Chain section that visualises related executions sharing
the same correlationId, with the current exchange highlighted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:19:50 +01:00
hsiegeln
63d8078688 feat: align Dashboard stat cards with mock, add errors section to DetailPanel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:19:33 +01:00
hsiegeln
ee69dbedfc feat: use TopBar onLogout prop, add ToastProvider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:17:38 +01:00
hsiegeln
313d871948 chore: update design system to v0.0.2, regenerate schema.d.ts
Bumped @cameleer/design-system from ^0.0.1 to ^0.0.2 (adds onLogout prop to TopBar).
Fetched openapi.json from remote backend, stripped /api/v1 prefix, patched
ExecutionDetail with groupName and children fields to match UI expectations,
then regenerated schema.d.ts via openapi-typescript. TypeScript compiles clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:16:15 +01:00
hsiegeln
f4d2693561 feat: enrich AgentInstanceResponse with version/capabilities, add password reset endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:13:37 +01:00
hsiegeln
2051572ee2 feat: add GET /agents/{id}/metrics endpoint for JVM metrics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:11:22 +01:00
hsiegeln
cc433b4215 feat: add GET /routes/metrics/processors endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:10:54 +01:00
hsiegeln
31b60c4e24 feat: add V7 migration for per-processor-id continuous aggregate 2026-03-23 18:09:24 +01:00
hsiegeln
017a0c218e docs: add UI mock alignment design spec and implementation plan
Comprehensive spec and 20-task plan to close all gaps between
@cameleer/design-system v0.0.2 mocks and the current server UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:06:26 +01:00
117 changed files with 30884 additions and 965 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

@@ -33,7 +33,8 @@ public class OpenApiConfig {
"SearchResultExecutionSummary", "UserInfo", "SearchResultExecutionSummary", "UserInfo",
"ProcessorNode", "ProcessorNode",
"AppCatalogEntry", "RouteSummary", "AgentSummary", "AppCatalogEntry", "RouteSummary", "AgentSummary",
"RouteMetrics", "AgentEventResponse", "AgentInstanceResponse" "RouteMetrics", "AgentEventResponse", "AgentInstanceResponse",
"ProcessorMetrics", "AgentMetricsResponse", "MetricBucket"
); );
@Bean @Bean

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

@@ -0,0 +1,66 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.AgentMetricsResponse;
import com.cameleer3.server.app.dto.MetricBucket;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
@RestController
@RequestMapping("/api/v1/agents/{agentId}/metrics")
public class AgentMetricsController {
private final JdbcTemplate jdbc;
public AgentMetricsController(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@GetMapping
public AgentMetricsResponse getMetrics(
@PathVariable String agentId,
@RequestParam String names,
@RequestParam(required = false) Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "60") int buckets) {
if (from == null) from = Instant.now().minus(1, ChronoUnit.HOURS);
if (to == null) to = Instant.now();
List<String> metricNames = Arrays.asList(names.split(","));
long intervalMs = (to.toEpochMilli() - from.toEpochMilli()) / Math.max(buckets, 1);
String intervalStr = intervalMs + " milliseconds";
Map<String, List<MetricBucket>> result = new LinkedHashMap<>();
for (String name : metricNames) {
result.put(name.trim(), new ArrayList<>());
}
String sql = """
SELECT time_bucket(CAST(? AS interval), collected_at) AS bucket,
metric_name,
AVG(metric_value) AS avg_value
FROM agent_metrics
WHERE agent_id = ?
AND collected_at >= ? AND collected_at < ?
AND metric_name = ANY(?)
GROUP BY bucket, metric_name
ORDER BY bucket
""";
String[] namesArray = metricNames.stream().map(String::trim).toArray(String[]::new);
jdbc.query(sql, rs -> {
String metricName = rs.getString("metric_name");
Instant bucket = rs.getTimestamp("bucket").toInstant();
double value = rs.getDouble("avg_value");
result.computeIfAbsent(metricName, k -> new ArrayList<>())
.add(new MetricBucket(bucket, value));
}, intervalStr, agentId, Timestamp.from(from), Timestamp.from(to), namesArray);
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

@@ -1,5 +1,6 @@
package com.cameleer3.server.app.controller; package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.ProcessorMetrics;
import com.cameleer3.server.app.dto.RouteMetrics; import com.cameleer3.server.app.dto.RouteMetrics;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -43,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, " +
@@ -54,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");
@@ -75,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());
@@ -92,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),
@@ -108,4 +109,56 @@ public class RouteMetricsController {
return ResponseEntity.ok(metrics); return ResponseEntity.ok(metrics);
} }
@GetMapping("/metrics/processors")
@Operation(summary = "Get processor metrics",
description = "Returns aggregated performance metrics per processor for the given route and time window")
@ApiResponse(responseCode = "200", description = "Metrics returned")
public ResponseEntity<List<ProcessorMetrics>> getProcessorMetrics(
@RequestParam String routeId,
@RequestParam(required = false) String appId,
@RequestParam(required = false) Instant from,
@RequestParam(required = false) Instant to) {
Instant toInstant = to != null ? to : Instant.now();
Instant fromInstant = from != null ? from : toInstant.minus(24, ChronoUnit.HOURS);
var sql = new StringBuilder(
"SELECT processor_id, processor_type, route_id, application_name, " +
"SUM(total_count) AS total_count, " +
"SUM(failed_count) AS failed_count, " +
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum)::double precision / SUM(total_count) ELSE 0 END AS avg_duration_ms, " +
"MAX(p99_duration) AS p99_duration_ms " +
"FROM stats_1m_processor_detail " +
"WHERE bucket >= ? AND bucket < ? AND route_id = ?");
var params = new ArrayList<Object>();
params.add(Timestamp.from(fromInstant));
params.add(Timestamp.from(toInstant));
params.add(routeId);
if (appId != null) {
sql.append(" AND application_name = ?");
params.add(appId);
}
sql.append(" GROUP BY processor_id, processor_type, route_id, application_name");
sql.append(" ORDER BY SUM(total_count) DESC");
List<ProcessorMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
long totalCount = rs.getLong("total_count");
long failedCount = rs.getLong("failed_count");
double errorRate = failedCount > 0 ? (double) failedCount / totalCount : 0.0;
return new ProcessorMetrics(
rs.getString("processor_id"),
rs.getString("processor_type"),
rs.getString("route_id"),
rs.getString("application_name"),
totalCount,
failedCount,
rs.getDouble("avg_duration_ms"),
rs.getDouble("p99_duration_ms"),
errorRate);
}, params.toArray());
return ResponseEntity.ok(metrics);
}
} }

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

@@ -1,5 +1,6 @@
package com.cameleer3.server.app.controller; package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.SetPasswordRequest;
import com.cameleer3.server.core.admin.AuditCategory; import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult; import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService; import com.cameleer3.server.core.admin.AuditService;
@@ -12,6 +13,7 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
@@ -172,6 +174,18 @@ public class UserAdminController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PostMapping("/{userId}/password")
@Operation(summary = "Reset user password")
@ApiResponse(responseCode = "204", description = "Password reset")
public ResponseEntity<Void> resetPassword(
@PathVariable String userId,
@Valid @RequestBody SetPasswordRequest request,
HttpServletRequest httpRequest) {
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
auditService.log("reset_password", AuditCategory.USER_MGMT, userId, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
public record CreateUserRequest(String username, String displayName, String email, String password) {} public record CreateUserRequest(String username, String displayName, String email, String password) {}
public record UpdateUserRequest(String displayName, String email) {} public record UpdateUserRequest(String displayName, String email) {}
} }

View File

@@ -7,16 +7,19 @@ import jakarta.validation.constraints.NotNull;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map;
@Schema(description = "Agent instance summary with runtime metrics") @Schema(description = "Agent instance summary with runtime metrics")
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,
@NotNull Instant lastHeartbeat, @NotNull Instant lastHeartbeat,
String version,
Map<String, Object> capabilities,
double tps, double tps,
double errorRate, double errorRate,
int activeRoutes, int activeRoutes,
@@ -26,9 +29,10 @@ 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(),
0.0, 0.0, 0.0, 0.0,
0, info.routeIds() != null ? info.routeIds().size() : 0, 0, info.routeIds() != null ? info.routeIds().size() : 0,
uptime uptime
@@ -37,7 +41,8 @@ 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,
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
); );
} }

View File

@@ -0,0 +1,9 @@
package com.cameleer3.server.app.dto;
import java.util.List;
import java.util.Map;
import jakarta.validation.constraints.NotNull;
public record AgentMetricsResponse(
@NotNull Map<String, List<MetricBucket>> metrics
) {}

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

@@ -0,0 +1,9 @@
package com.cameleer3.server.app.dto;
import java.time.Instant;
import jakarta.validation.constraints.NotNull;
public record MetricBucket(
@NotNull Instant time,
double value
) {}

View File

@@ -0,0 +1,15 @@
package com.cameleer3.server.app.dto;
import jakarta.validation.constraints.NotNull;
public record ProcessorMetrics(
@NotNull String processorId,
@NotNull String processorType,
@NotNull String routeId,
@NotNull String appId,
long totalCount,
long failedCount,
double avgDurationMs,
double p99DurationMs,
double errorRate
) {}

View File

@@ -0,0 +1,7 @@
package com.cameleer3.server.app.dto;
import jakarta.validation.constraints.NotBlank;
public record SetPasswordRequest(
@NotBlank String password
) {}

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

@@ -80,6 +80,7 @@ public class SecurityConfig {
// Read-only data endpoints — viewer+ // Read-only data endpoints — viewer+
.requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/agents/*/metrics").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/agents/events-log").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/agents/events-log").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")

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

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

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,576 @@
# UI Mock Alignment Design
**Date:** 2026-03-23
**Status:** Reviewed
**Scope:** Close all gaps between `@cameleer/design-system` mocks and the cameleer3-server UI
## Context
The `@cameleer/design-system` package (v0.0.2) contains fully realized mock pages demonstrating the target UX for the Cameleer3 monitoring platform. The current server UI was built as a first pass and has significant deviations from these mocks across every page. This spec defines the work to align them.
**Out of scope:**
- Business context columns (Order ID, Customer) — not applicable to current data model
- Application log streaming — agent does not send logs; placeholder only
## 1. Backend — New Endpoints
### 1a. Processor Stats Endpoint
**`GET /api/v1/routes/metrics/processors`**
Exposes per-processor statistics. The current `stats_1m_processor` continuous aggregate groups by `(bucket, group_name, route_id, processor_type)` and lacks `processor_id`. TimescaleDB continuous aggregates cannot be ALTERed to add GROUP BY columns.
**Migration:** Add `V7__processor_stats_by_id.sql` creating a new continuous aggregate `stats_1m_processor_detail`:
```sql
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;
```
Leave the original `stats_1m_processor` intact (used elsewhere).
**Controller:** Add new method in existing `RouteMetricsController.java` (shares `/api/v1/routes/metrics` base path) rather than a separate controller.
**Query params:**
- `routeId` (required) — filter by route
- `appId` (optional) — filter by application
- `from` / `to` (optional) — time window, defaults to last 24h
**Response:** `List<ProcessorMetrics>`
```java
record ProcessorMetrics(
String processorId, // unique processor ID within the route
String processorType, // e.g. "to", "process", "choice"
String routeId,
String appId,
long totalCount,
long failedCount,
double avgDurationMs,
double p99DurationMs,
double errorRate // failedCount / totalCount
)
```
**Security:** VIEWER+ role. Already covered by existing `GET /api/v1/routes/**` wildcard in `SecurityConfig`.
### 1b. Agent Metrics Query Endpoint
**`GET /api/v1/agents/{agentId}/metrics`**
Queries the `agent_metrics` hypertable and returns time-bucketed series.
**Query params:**
- `names` (required) — comma-separated metric names (e.g. `jvm.cpu.process,jvm.memory.heap.used`)
- `from` / `to` (optional) — time window, defaults to last 1h
- `buckets` (optional, default 60) — number of time buckets
**Response:** `AgentMetricsResponse`
```java
record AgentMetricsResponse(
Map<String, List<MetricBucket>> metrics
)
record MetricBucket(
Instant time,
double value // avg within bucket
)
```
**Implementation:** Use `time_bucket()` on `agent_metrics.collected_at`, grouped by `metric_name`, averaged by `metric_value`. Filter by `agent_id` and optional `tags` if needed.
**Security:** VIEWER+ role. Requires new `SecurityConfig` rule: `GET /api/v1/agents/*/metrics` (existing `/api/v1/agents` rule is exact-match only, does not cover sub-paths).
### 1c. Enrich AgentInstanceResponse
Add fields to existing `AgentInstanceResponse`:
```java
// existing fields...
String version, // from AgentInfo.version in registry
Map<String, Object> capabilities // from AgentInfo.capabilities
```
These values are already stored in the `AgentRegistry`'s `AgentInfo` objects. The `AgentRegistrationController.listAgents()` method just needs to map them into the response DTO.
### 1d. Password Reset Endpoint
**`POST /api/v1/admin/users/{userId}/password`**
The current `UpdateUserRequest` has no password field. Add a dedicated endpoint for admin password reset.
**Request body:** `SetPasswordRequest`
```java
record SetPasswordRequest(
@NotBlank String password
)
```
**Response:** 204 No Content
**Implementation:** Hash password with same BCrypt encoder used in `createUser`, update `users.password_hash` column.
**Security:** ADMIN role required (same as other user management endpoints).
**New files:** `cameleer3-server-app/.../dto/SetPasswordRequest.java`; new method in `UserAdminController`.
## 2. Dashboard Enhancements
### 2a. DetailPanel — Errors Section
When the selected execution has a non-null `errorMessage`:
- Insert an "Errors" section between Overview and Processors in the DetailPanel
- Display:
- Error class: parsed from `errorMessage` (text before `:` or first line)
- Error message: remainder of `errorMessage`
- Stack trace: `errorStackTrace` in a collapsible `CodeBlock` (content prop)
- Use `Alert` variant="error" for the error class/message, `Collapsible` + `CodeBlock` for the stack trace
### 2b. DetailPanel — Route Flow Tab
Add a third tab to the DetailPanel tabs: **Overview | Processors | Route Flow**
- Fetch diagram via `useDiagramByRoute(execution.groupName, execution.routeId)`
- Render `RouteFlow` component from design system
- Overlay execution data: map each `ProcessorNode` status onto diagram nodes using `diagramNodeId`
- Color nodes by status: success (green), failed (red), running (blue)
- Show duration labels on nodes
- **RouteFlow overlay API:** The `RouteFlow` component accepts execution data to color nodes. During implementation, read the `RouteFlow.tsx` source in the design system to confirm the exact props interface (likely an `overlays` or `nodeStates` prop mapping node IDs to status/duration). Map `ProcessorNode.diagramNodeId``PositionedNode.id` to connect execution data to diagram nodes.
### 2c. Stat Card Alignment
Change the 5 stat cards to match mock semantics:
| Position | Label | Value | Source |
|----------|-------|-------|--------|
| 1 | Throughput | exchanges/s | `totalCount / timeWindowSeconds` from stats |
| 2 | Error Rate | % | `failedCount / totalCount * 100` from stats |
| 3 | Avg Latency | ms | `avgDurationMs` from stats |
| 4 | P99 Latency | ms | `p99LatencyMs` from stats |
| 5 | In-Flight | count | `activeCount` from stats |
Each card includes:
- `Sparkline` from timeseries buckets (existing)
- Trend arrow: compare current vs `prev*` fields, show up/down indicator
## 3. Exchange Detail Enhancements
### 3a. Correlation Chain
Below the exchange header card, add a "Correlation Chain" section:
- **Data source:** `POST /search/executions` with filter `{ correlationId: execution.correlationId }`
- **Rendering:** Horizontal chain of small cards connected by arrows
- Each card: route name, `StatusDot`, duration, relative timestamp
- Current exchange highlighted
- Click navigates to that exchange (`/exchanges/:id`)
- **Conditional:** Only show section when correlationId is present and search returns > 1 result
- **Limit:** Request with `limit: 20` to prevent excessive results. If more exist, show "+N more" link
- **Hook:** `useCorrelationChain(correlationId)` — new query hook wrapping the search call
### 3b. Timeline / Flow Toggle
Above the processor timeline section:
- Add `SegmentedTabs` with options: **Timeline** | **Flow**
- **Timeline** (default): existing `ProcessorTimeline` component (Gantt view)
- **Flow**: `RouteFlow` component with execution overlay
- Fetch diagram via `useDiagramByRoute(execution.groupName, execution.routeId)` (same as 2b)
- Color nodes by processor status, show duration labels
- Clicking a processor node in either view selects it and loads its snapshot
### 3c. Header Enrichment
Add to the exchange header:
- Processor count: `execution.processors.length` (or recursive count for nested trees)
- Display as a stat in the header's right section alongside duration
## 4. Route Detail Page (NEW)
**New page** at `/routes/:appId/:routeId`. Currently this path renders a filtered `RoutesMetrics`; replace with a dedicated route detail page. The filtered table/chart view from `RoutesMetrics` is not lost — it is subsumed by the Performance and Recent Executions tabs in the new page, which provide the same data in a richer context alongside the route diagram.
Update `router.tsx`: the `/routes/:appId/:routeId` route imports a new `RouteDetail` component instead of `RoutesMetrics`. The `/routes` and `/routes/:appId` routes remain unchanged (continue to render `RoutesMetrics`).
### 4a. Route Header Card
Card displaying:
- Route name (`routeId`) and application name (`appId`)
- Health status from route catalog (`useRouteCatalog()` filtered)
- Exchange count (last 24h)
- Last seen timestamp
- Back link to `/routes/:appId`
### 4b. Route Diagram + Processor Stats (Side-by-Side)
Two-column grid:
- **Left:** `RouteFlow` component rendering the route diagram
- Data from `useDiagramByRoute(appId, routeId)` or `useDiagramLayout(contentHash)`
- **Right:** Processor stats table from new endpoint (1a)
- `DataTable` columns: Processor ID, Type, Executions, Avg Duration, P99 Duration, Error Rate
- Data from `useProcessorMetrics(routeId, appId)`
### 4c. Tabbed Section
`Tabs` component with three tabs:
**Performance tab:**
- 2x2 chart grid (same pattern as RoutesMetrics) filtered to this specific route
- Data from `useStatsTimeseries(from, to, routeId, appId)`
**Recent Executions tab:**
- `DataTable` showing recent executions for this route
- Data from `useSearchExecutions({ routeId, group: appId, limit: 20, sortField: 'startTime', sortDir: 'desc' })`
- Columns: Status, Execution ID, Duration, Start Time, Error Message
- Row click navigates to `/exchanges/:id`
**Error Patterns tab:**
- Group failed executions by `errorMessage`
- Display: error message (truncated), count, last occurrence timestamp, link to sample execution
- Data from `useSearchExecutions({ routeId, group: appId, status: 'FAILED', limit: 100 })` — client-side grouping by `errorMessage`
### 4d. CSS Module
`RouteDetail.module.css` with classes:
- `.headerCard` — surface card, padding, margin-bottom
- `.diagramStatsGrid` — 2-column grid
- `.diagramPane` / `.statsPane` — surface cards
- `.tabSection` — margin-top
- `.chartGrid` — 2x2 grid (reuse pattern from RoutesMetrics)
## 5. Agent Health Enhancements
### 5a. DetailPanel (Slide-In)
Add a `DetailPanel` from the design system, triggered by clicking an instance row in a GroupCard.
**Overview tab:**
- Status with `StatusDot` and `Badge`
- Application name, version (from enriched AgentInstanceResponse, section 1c)
- Uptime (formatted), last heartbeat (relative time)
- TPS, error rate
- Active routes / total routes
- Memory usage: `ProgressBar` — data from `GET /agents/{id}/metrics?names=jvm.memory.heap.used,jvm.memory.heap.max&buckets=1` (single latest bucket gives the most recent averaged value)
- CPU usage: `ProgressBar` — data from `GET /agents/{id}/metrics?names=jvm.cpu.process&buckets=1` (single latest bucket)
**Performance tab:**
- Two `LineChart` components:
- Throughput over time (from timeseries stats filtered by agentId, or from agent metrics)
- Error rate over time
### 5b. Instance Table Enrichment
Add columns to the instance rows within each `GroupCard`:
| Column | Source |
|--------|--------|
| Status dot | `agent.status` (existing) |
| Instance name | `agent.name` (existing) |
| State badge | `agent.status` (existing) |
| Uptime | `agent.uptimeSeconds` → formatted "2d 4h" / "15m" |
| TPS | `agent.tps` (existing) |
| Error rate | `agent.errorRate` → percentage |
| Last heartbeat | `agent.lastHeartbeat` → relative "2m ago" |
| Link | Icon button → `/agents/:appId/:instanceId` |
### 5c. Alert Banners
When a `GroupCard` contains instances with status `DEAD`:
- Show `Alert` variant="error" at the top of the card body
- Message: `"N instance(s) unreachable"` where N is the count of DEAD instances
### 5d. Stat Card Alignment
Replace current 4 cards (Total, Live, Stale, Dead) with 5 cards matching mock:
| Label | Value | Accent |
|-------|-------|--------|
| Total Agents | count (subtitle: "N live / N stale / N dead") | default |
| Applications | count of unique appIds | default |
| Active Routes | sum of activeRoutes across live agents | default |
| Total TPS | sum of tps across live agents | default |
| Dead | count of dead agents | error |
### 5e. Scope Trail
Add breadcrumb below stat cards:
- All agents view: `Agents` with live `Badge` showing "N live"
- Filtered by app: `Agents` > `{appName}` with health `Badge` (live/stale/dead color)
## 6. Agent Instance Enhancements
### 6a. JVM Metrics Charts (3x2 Grid)
Replace current 2-column chart grid with 3x2 grid. All data from new endpoint (1b).
| Chart | Type | Metric Name(s) |
|-------|------|----------------|
| CPU Usage | AreaChart | `jvm.cpu.process` (0-1 scale, display as %) |
| Memory (Heap) | AreaChart | `jvm.memory.heap.used` + `jvm.memory.heap.max` (two series) |
| Throughput | AreaChart | from `useStatsTimeseries` filtered by agent (existing) |
| Error Rate | LineChart | from `useStatsTimeseries` filtered by agent (existing) |
| Thread Count | LineChart | `jvm.threads.count` |
| GC Pauses | BarChart | `jvm.gc.time` |
**Hook:** `useAgentMetrics(agentId, metricNames[], from, to, buckets)` — wraps endpoint 1b.
### 6b. Process Information Card
Card with key-value pairs:
| Key | Source |
|-----|--------|
| JVM Version | `agent.capabilities.jvmVersion` or parse from registration |
| Camel Version | `agent.capabilities.camelVersion` |
| Spring Boot | `agent.capabilities.springBootVersion` |
| Started | `agent.registeredAt` formatted |
| Capabilities | render as tags: tracing, metrics, diagrams, replay |
Data from enriched `AgentInstanceResponse` (section 1c). If version details aren't in current capabilities, they can be added to agent registration in a future iteration — show what's available.
### 6c. Stat Card Alignment
Replace current 4 cards with 5 cards matching mock:
| Label | Value | Source |
|-------|-------|--------|
| CPU | % | latest `jvm.cpu.process` from agent metrics |
| Memory | % | latest `heap.used / heap.max * 100` |
| Throughput | req/s | `agent.tps` |
| Errors | % | `agent.errorRate` |
| Uptime | formatted | `agent.uptimeSeconds` |
CPU and Memory require a small fetch from endpoint 1b (latest single value).
### 6d. Application Log Placeholder
Below the EventFeed card, add an `EmptyState` component:
- Title: "Application Logs"
- Description: "Application log streaming is not yet available"
- No action button
### 6e. Version Badge in Scope Trail
Breadcrumb: `Agents` > `{appName}` > `{instanceName}`
- Add `Badge` next to instance name showing version (from enriched response)
- Add `StatusDot` + status `Badge` for visual state
## 7. Admin & Miscellaneous
### 7a. OIDC Config — Default Roles
Add a "Default Roles" section to the OIDC config page:
- Display current default roles as `Tag` components (removable, click X to remove)
- `Input` + "Add" `Button` to add a role
- Validate against existing roles from `useRoles()` query
- Persist via existing OIDC config save endpoint
### 7b. OIDC Config — ConfirmDialog on Delete
Replace direct delete button with `ConfirmDialog`:
- Message: "Delete OIDC configuration? All OIDC users will lose access."
- Require typing "DELETE" to confirm
### 7c. Design System Update
Update `@cameleer/design-system` from `^0.0.1` to `^0.0.2` in `ui/package.json`.
**TopBar `onLogout` prop:** Replace the custom `Dropdown` + `Avatar` logout hack in `LayoutShell.tsx` with the TopBar's new `onLogout` prop:
```tsx
<TopBar
breadcrumb={breadcrumb}
user={{ name: username }}
onLogout={handleLogout}
/>
```
Remove the manual Avatar/Dropdown logout code.
**Verification needed during implementation:** Confirm that the TopBar v0.0.2 renders a user avatar/menu internally when `user` + `onLogout` are provided. If it only renders a bare logout button without the "Signed in as" display, keep the custom Avatar/Dropdown and just wire up the TopBar's `onLogout` as an additional trigger.
### 7d. Regenerate schema.d.ts
After backend endpoints are added, regenerate types from the running server:
```bash
npm run generate-api:live
```
This ensures all new DTOs (`ProcessorMetrics`, `AgentMetricsResponse`, `MetricBucket`, enriched `AgentInstanceResponse`) are accurately typed.
## 8. RBAC / User Management Overhaul
The current RBAC page is a basic DataTable + Modal CRUD interface. The design system mock implements a split-pane detail-oriented admin panel with rich interactions. This section describes the full rebuild.
### 8a. Layout — Split-Pane Replaces DataTable
All three tabs (Users, Groups, Roles) adopt the same layout pattern:
```
┌─────────────────────┬────────────────────┐
│ List Pane (52%) │ Detail Pane (48%) │
│ │ │
│ [Search input] │ [Selected entity │
│ [+ Create button] │ detail view] │
│ │ │
│ [Inline create form]│ │
│ │ │
│ [Scrollable entity │ │
│ list with avatars, │ │
│ badges, tags] │ │
│ │ │
└─────────────────────┴────────────────────┘
```
**New file:** `ui/src/pages/Admin/UserManagement.module.css`
- `.splitPane` — CSS grid `52fr 48fr`, full height
- `.listPane` — scrollable, border-right
- `.detailPane` — scrollable, padding
- `.entityItem` / `.entityItemSelected` — list items with hover/selected states
- `.entityInfo`, `.entityName`, `.entityMeta`, `.entityTags` — list item layout
- `.createForm`, `.createFormActions` — inline form styling
- `.metaGrid` — key-value metadata layout
- `.sectionTags` — tag group with wrap
- `.inheritedNote` — small italic annotation text
- `.securitySection` / `.resetForm` — password management styling
**Keep existing stat cards** above tabs — these are a useful addition not present in the mock.
### 8b. Users Tab
**List pane:**
- **Search:** `Input` with search icon, filters across username, displayName, email (client-side)
- **Create button:** "+ Add User" opens inline form (not modal)
- **Inline create form:**
- `Input`: Username (required), Display Name, Email
- `Input`: Password (required)
- Client-side validation: duplicate username check, required fields
- Cancel + Create buttons
- **Note:** Admin-created users are always local. OIDC users are auto-provisioned on first login (no admin creation needed). The create form does not include a provider selector.
- **Entity list:** `role="listbox"`, each item `role="option"` with `tabIndex={0}`
- `Avatar` (initials, size sm)
- Display name + provider `Badge` (if not local)
- Email + group path in meta line
- Direct roles and groups as small `Badge` tags
- Click or Enter/Space to select → populates detail pane
**Detail pane (when user selected):**
- **Header:** `Avatar` (lg) + Display name (`InlineEdit` for rename) + Email + Delete button
- **Status:** "Active" `Tag`
- **Metadata grid:** User ID (`MonoText`), Created (formatted date+time), Provider
- **Security section:**
- Local users: masked password display + "Reset password" button → toggles inline form (new password `Input` + Cancel/Set)
- OIDC users: `InfoCallout` "Password managed by identity provider"
- **Group membership:**
- Current groups as removable `Tag` components
- `MultiSelect` dropdown to add groups
- Warning on removal if inherited roles would be revoked
- **Effective roles:**
- Direct roles: removable `Tag` (warning color)
- Inherited roles: dashed `Badge` with "↑ groupName" source notation (opacity 0.65, non-removable)
- `MultiSelect` to add direct roles
- Note: "Roles with ↑ are inherited through group membership"
- **Delete:** `ConfirmDialog` requiring username to be typed. Self-delete guard (can't delete own account).
**API hooks used:** `useUsers`, `useUser`, `useCreateUser`, `useUpdateUser`, `useDeleteUser`, `useAssignRoleToUser`, `useRemoveRoleFromUser`, `useAddUserToGroup`, `useRemoveUserFromGroup`, `useGroups`, `useRoles`
### 8c. Groups Tab
**List pane:**
- **Search:** filter by group name
- **Create form:** inline with name + parent group `Select` dropdown (options: "Top-level" + all existing groups)
- **Entity list:**
- `Avatar` + group name
- Meta: "Child of {parent}" or "Top-level" + child count + member count
- Role tags
**Detail pane:**
- **Header:** Group name (`InlineEdit` for non-built-in) + parent info + Delete button (disabled for built-in Admins group)
- **Metadata:** Group ID (`MonoText`)
- **Parent group:** display current parent
- **Members:** removable `Tag` list + `MultiSelect` to add users. Note: "+ all members of child groups" if applicable
- **Child groups:** removable `Tag` list + `MultiSelect` to add existing groups as children. Circular reference prevention (can't add ancestor as child)
- **Assigned roles:** removable `Tag` list + `MultiSelect` to add roles. Warning on removal: "Removing {role} from {group} will affect N member(s). Continue?"
- **Delete:** `ConfirmDialog`. Guard: built-in Admins group cannot be deleted.
**API hooks used:** `useGroups`, `useGroup`, `useCreateGroup`, `useUpdateGroup`, `useDeleteGroup`, `useAssignRoleToGroup`, `useRemoveRoleFromGroup`
### 8d. Roles Tab
**List pane:**
- **Search:** filter by role name
- **Create form:** inline with name (auto-uppercase) + description
- **Entity list:**
- `Avatar` + role name + "system" `Badge` (if system role)
- Meta: description + assignment count
- Tags: assigned groups (success color) + direct users
**Detail pane:**
- **Header:** Role name + description + Delete button (disabled for system roles)
- **Metadata:** Role ID (`MonoText`), scope, type (system/custom — "System role (read-only)")
- **Assigned to groups:** view-only `Tag` list (shows which groups have this role)
- **Assigned to users (direct):** view-only `Tag` list
- **Effective principals:** filled `Badge` (direct assignment) + dashed `Badge` (inherited via group). Note: "Dashed entries inherit this role through group membership"
**API hooks used:** `useRoles`, `useRole`, `useCreateRole`, `useUpdateRole`, `useDeleteRole`
### 8e. Shared Patterns
- **Toast notifications** for all mutations (create, update, delete, assign, remove) — use `useToast` from design system
- **Cascade warnings** when actions affect other entities (removing role from group, removing user from group with roles)
- **Keyboard accessibility:** Enter/Space to select, ARIA roles (`listbox`, `option`), `aria-selected`
- **Mutation button states:** disable while in-flight, show spinner
- **ToastProvider:** Add `ToastProvider` from design system to `LayoutShell.tsx` (or app root in `main.tsx`) to enable `useToast()` hook across admin pages
- **Graceful empty states:** When agent metrics are unavailable (agent not sending a particular metric), show per-chart empty state rather than crashing. Check metric name existence in response before rendering.
## File Impact Summary
### New files:
- `ui/src/pages/Routes/RouteDetail.tsx` + `RouteDetail.module.css`
- `ui/src/pages/Admin/UserManagement.module.css`
- `ui/src/pages/Admin/UsersTab.tsx`
- `ui/src/pages/Admin/GroupsTab.tsx`
- `ui/src/pages/Admin/RolesTab.tsx`
- `ui/src/api/queries/agent-metrics.ts` (useAgentMetrics hook)
- `ui/src/api/queries/processor-metrics.ts` (useProcessorMetrics hook)
- `ui/src/api/queries/correlation.ts` (useCorrelationChain hook)
- `cameleer3-server-app/.../controller/AgentMetricsController.java`
- `cameleer3-server-app/.../dto/ProcessorMetrics.java`
- `cameleer3-server-app/.../dto/AgentMetricsResponse.java`
- `cameleer3-server-app/.../dto/MetricBucket.java`
- `cameleer3-server-app/.../dto/SetPasswordRequest.java`
- `cameleer3-server-app/src/main/resources/db/migration/V7__processor_stats_by_id.sql`
### Modified files:
- `ui/package.json` — design system `^0.0.2`
- `ui/src/router.tsx` — add RouteDetail route
- `ui/src/components/LayoutShell.tsx` — TopBar `onLogout` prop, remove Dropdown/Avatar
- `ui/src/pages/Dashboard/Dashboard.tsx` — error section, RouteFlow tab, stat card changes
- `ui/src/pages/Dashboard/Dashboard.module.css` — new classes
- `ui/src/pages/ExchangeDetail/ExchangeDetail.tsx` — correlation chain, flow toggle, processor count
- `ui/src/pages/ExchangeDetail/ExchangeDetail.module.css` — new classes
- `ui/src/pages/Routes/RoutesMetrics.tsx` — stat card adjustments
- `ui/src/pages/AgentHealth/AgentHealth.tsx` — DetailPanel, table enrichment, alert banners, stat cards, scope trail
- `ui/src/pages/AgentHealth/AgentHealth.module.css` — new classes
- `ui/src/pages/AgentInstance/AgentInstance.tsx` — 3x2 charts, process info, stat cards, log placeholder, version badge
- `ui/src/pages/AgentInstance/AgentInstance.module.css` — new classes
- `ui/src/pages/Admin/RbacPage.tsx` — restructured to container with split-pane tabs
- `ui/src/pages/Admin/OidcConfigPage.tsx` — default roles, ConfirmDialog
- `ui/src/api/schema.d.ts` — regenerated with new types
- `cameleer3-server-app/.../dto/AgentInstanceResponse.java` — add version, capabilities
- `cameleer3-server-app/.../controller/AgentRegistrationController.java` — map version/capabilities
- `cameleer3-server-app/.../controller/RouteMetricsController.java` — add processor stats method
- `cameleer3-server-app/.../controller/UserAdminController.java` — add password reset method
- `cameleer3-server-app/.../SecurityConfig.java` — add rule for `GET /api/v1/agents/*/metrics`
- `ui/src/main.tsx` or `ui/src/components/LayoutShell.tsx` — add `ToastProvider`
- `cameleer3-server-app/.../OpenApiConfig.java` — register new DTOs
### Backend migration:
- `V7__processor_stats_by_id.sql` — new `stats_1m_processor_detail` continuous aggregate with `processor_id` grouping

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

98
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.1", "@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",
@@ -19,10 +19,12 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2",
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@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",
@@ -274,9 +276,9 @@
} }
}, },
"node_modules/@cameleer/design-system": { "node_modules/@cameleer/design-system": {
"version": "0.0.1", "version": "0.0.3",
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.1/design-system-0.0.1.tgz", "resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.3/design-system-0.0.3.tgz",
"integrity": "sha512-8rMAp7JhZBlAw4jcTnSBLuZe8cd94lPAgL96KDtVIk2QpXKdsJLoVfk7CuPG635/h6pu4YKplfBhJmKpsS8A8g==", "integrity": "sha512-x1mZvgYz7j57xFB26pMh9hn5waSJA1CcRWTgkzleLfaO/CmhekLup1HHlbh0b9SxVci6g2HzbcJldr4kvM1yzg==",
"dependencies": { "dependencies": {
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -322,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",
@@ -608,6 +617,22 @@
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@redocly/ajv": { "node_modules/@redocly/ajv": {
"version": "8.11.2", "version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
@@ -1612,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",
@@ -2763,6 +2806,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pluralize": { "node_modules/pluralize": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.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.1", "@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",
@@ -23,10 +25,12 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2",
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@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",

1
ui/src/api/openapi.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -198,6 +198,19 @@ export function useDeleteUser() {
}); });
} }
export function useSetPassword() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ userId, password }: { userId: string; password: string }) => {
await adminFetch(`/users/${userId}/password`, {
method: 'POST',
body: JSON.stringify({ password }),
});
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'users'] }),
});
}
export function useAssignRoleToUser() { export function useAssignRoleToUser() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({

View File

@@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
export function useAgentMetrics(agentId: string | null, names: string[], buckets = 60) {
return useQuery({
queryKey: ['agent-metrics', agentId, names.join(','), buckets],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams({
names: names.join(','),
buckets: String(buckets),
});
const res = await fetch(`${config.apiBaseUrl}/agents/${agentId}/metrics?${params}`, {
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
},
});
if (!res.ok) throw new Error(`${res.status}`);
return res.json() as Promise<{ metrics: Record<string, Array<{ time: string; value: number }>> }>;
},
enabled: !!agentId && names.length > 0,
refetchInterval: 30_000,
});
}

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

@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../client';
export function useCorrelationChain(correlationId: string | null) {
return useQuery({
queryKey: ['correlation-chain', correlationId],
queryFn: async () => {
const { data } = await api.POST('/search/executions', {
body: {
correlationId: correlationId!,
limit: 20,
sortField: 'startTime',
sortDir: 'asc',
},
});
return data;
},
enabled: !!correlationId,
});
}

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

@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
export function useProcessorMetrics(routeId: string | null, appId?: string) {
return useQuery({
queryKey: ['processor-metrics', routeId, appId],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams();
if (routeId) params.set('routeId', routeId);
if (appId) params.set('appId', appId);
const res = await fetch(`${config.apiBaseUrl}/routes/metrics/processors?${params}`, {
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
},
});
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
},
enabled: !!routeId,
refetchInterval: 30_000,
});
}

3770
ui/src/api/schema.d.ts vendored

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { Outlet, useNavigate, useLocation } from 'react-router'; import { Outlet, useNavigate, useLocation } from 'react-router';
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, useCommandPalette, Dropdown, Avatar } from '@cameleer/design-system'; import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, useCommandPalette } from '@cameleer/design-system';
import { useRouteCatalog } from '../api/queries/catalog'; import { useRouteCatalog } from '../api/queries/catalog';
import { useAuthStore } from '../auth/auth-store'; import { useAuthStore } from '../auth/auth-store';
import { useMemo, useCallback } from 'react'; import { useMemo, useCallback } from 'react';
@@ -41,6 +41,11 @@ function LayoutContent() {
})); }));
}, [location.pathname]); }, [location.pathname]);
const handleLogout = useCallback(() => {
logout();
navigate('/login');
}, [logout, navigate]);
const handlePaletteSelect = useCallback((result: any) => { const handlePaletteSelect = useCallback((result: any) => {
if (result.path) navigate(result.path); if (result.path) navigate(result.path);
setPaletteOpen(false); setPaletteOpen(false);
@@ -56,22 +61,11 @@ function LayoutContent() {
/> />
} }
> >
<div style={{ display: 'flex', alignItems: 'center' }}> <TopBar
<TopBar breadcrumb={breadcrumb}
breadcrumb={breadcrumb} user={username ? { name: username } : undefined}
user={username ? { name: username } : undefined} onLogout={handleLogout}
/> />
{username && (
<Dropdown
trigger={<Avatar name={username} size="sm" />}
items={[
{ label: `Signed in as ${username}`, disabled: true },
{ divider: true, label: '' },
{ label: 'Logout', onClick: () => { logout(); navigate('/login'); } },
]}
/>
)}
</div>
<CommandPalette <CommandPalette
open={paletteOpen} open={paletteOpen}
onClose={() => setPaletteOpen(false)} onClose={() => setPaletteOpen(false)}
@@ -87,10 +81,12 @@ function LayoutContent() {
export function LayoutShell() { export function LayoutShell() {
return ( return (
<CommandPaletteProvider> <ToastProvider>
<GlobalFilterProvider> <CommandPaletteProvider>
<LayoutContent /> <GlobalFilterProvider>
</GlobalFilterProvider> <LayoutContent />
</CommandPaletteProvider> </GlobalFilterProvider>
</CommandPaletteProvider>
</ToastProvider>
); );
} }

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

@@ -0,0 +1,402 @@
import { useState } from 'react';
import {
Avatar,
Badge,
Button,
Input,
MonoText,
Tag,
Select,
ConfirmDialog,
Spinner,
InlineEdit,
useToast,
} from '@cameleer/design-system';
import {
useGroups,
useGroup,
useCreateGroup,
useUpdateGroup,
useDeleteGroup,
useAssignRoleToGroup,
useRemoveRoleFromGroup,
useAddUserToGroup,
useRemoveUserFromGroup,
useUsers,
useRoles,
} from '../../api/queries/admin/rbac';
import styles from './UserManagement.module.css';
const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010';
export default function GroupsTab() {
const [search, setSearch] = useState('');
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [newGroupParentId, setNewGroupParentId] = useState<string>('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [addMemberUserId, setAddMemberUserId] = useState<string>('');
const [addRoleId, setAddRoleId] = useState<string>('');
const { toast } = useToast();
const { data: groups = [], isLoading: groupsLoading } = useGroups();
const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedGroupId);
const { data: users = [] } = useUsers();
const { data: roles = [] } = useRoles();
const createGroup = useCreateGroup();
const updateGroup = useUpdateGroup();
const deleteGroup = useDeleteGroup();
const assignRoleToGroup = useAssignRoleToGroup();
const removeRoleFromGroup = useRemoveRoleFromGroup();
const addUserToGroup = useAddUserToGroup();
const removeUserFromGroup = useRemoveUserFromGroup();
const filteredGroups = groups.filter((g) =>
g.name.toLowerCase().includes(search.toLowerCase())
);
const parentOptions = [
{ value: '', label: 'Top-level' },
...groups.map((g) => ({ value: g.id, label: g.name })),
];
const parentName = (parentGroupId: string | null) => {
if (!parentGroupId) return 'Top-level';
const parent = groups.find((g) => g.id === parentGroupId);
return parent ? parent.name : parentGroupId;
};
const handleCreate = async () => {
const name = newGroupName.trim();
if (!name) return;
try {
await createGroup.mutateAsync({
name,
parentGroupId: newGroupParentId || null,
});
toast({ title: 'Group created', variant: 'success' });
setNewGroupName('');
setNewGroupParentId('');
setShowCreate(false);
} catch {
toast({ title: 'Failed to create group', variant: 'error' });
}
};
const handleRename = async (newName: string) => {
if (!selectedGroup) return;
try {
await updateGroup.mutateAsync({
id: selectedGroup.id,
name: newName,
parentGroupId: selectedGroup.parentGroupId,
});
toast({ title: 'Group renamed', variant: 'success' });
} catch {
toast({ title: 'Failed to rename group', variant: 'error' });
}
};
const handleDelete = async () => {
if (!selectedGroup) return;
try {
await deleteGroup.mutateAsync(selectedGroup.id);
toast({ title: 'Group deleted', variant: 'success' });
setSelectedGroupId(null);
setDeleteOpen(false);
} catch {
toast({ title: 'Failed to delete group', variant: 'error' });
}
};
const handleAddMember = async () => {
if (!selectedGroup || !addMemberUserId) return;
try {
await addUserToGroup.mutateAsync({
userId: addMemberUserId,
groupId: selectedGroup.id,
});
toast({ title: 'Member added', variant: 'success' });
setAddMemberUserId('');
} catch {
toast({ title: 'Failed to add member', variant: 'error' });
}
};
const handleRemoveMember = async (userId: string) => {
if (!selectedGroup) return;
try {
await removeUserFromGroup.mutateAsync({ userId, groupId: selectedGroup.id });
toast({ title: 'Member removed', variant: 'success' });
} catch {
toast({ title: 'Failed to remove member', variant: 'error' });
}
};
const handleAddRole = async () => {
if (!selectedGroup || !addRoleId) return;
try {
await assignRoleToGroup.mutateAsync({
groupId: selectedGroup.id,
roleId: addRoleId,
});
toast({ title: 'Role assigned', variant: 'success' });
setAddRoleId('');
} catch {
toast({ title: 'Failed to assign role', variant: 'error' });
}
};
const handleRemoveRole = async (roleId: string) => {
if (!selectedGroup) return;
try {
await removeRoleFromGroup.mutateAsync({ groupId: selectedGroup.id, roleId });
toast({ title: 'Role removed', variant: 'success' });
} catch {
toast({ title: 'Failed to remove role', variant: 'error' });
}
};
const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID;
// Build sets for quick lookup of already-assigned items
const memberUserIds = new Set((selectedGroup?.members ?? []).map((m) => m.userId));
const assignedRoleIds = new Set((selectedGroup?.directRoles ?? []).map((r) => r.id));
const availableUsers = users.filter((u) => !memberUserIds.has(u.userId));
const availableRoles = roles.filter((r) => !assignedRoleIds.has(r.id));
return (
<div className={styles.splitPane}>
{/* Left pane */}
<div className={styles.listPane}>
<div className={styles.listHeader}>
<Input
placeholder="Search groups..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch('')}
/>
<Button
size="sm"
variant="secondary"
onClick={() => setShowCreate((v) => !v)}
>
+ Add Group
</Button>
</div>
{showCreate && (
<div className={styles.createForm}>
<Input
placeholder="Group name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
/>
<div style={{ marginTop: 8 }}>
<Select
options={parentOptions}
value={newGroupParentId}
onChange={(e) => setNewGroupParentId(e.target.value)}
/>
</div>
<div className={styles.createFormActions}>
<Button
size="sm"
variant="ghost"
onClick={() => {
setShowCreate(false);
setNewGroupName('');
setNewGroupParentId('');
}}
>
Cancel
</Button>
<Button
size="sm"
variant="primary"
loading={createGroup.isPending}
onClick={handleCreate}
disabled={!newGroupName.trim()}
>
Create
</Button>
</div>
</div>
)}
{groupsLoading ? (
<Spinner />
) : (
<div className={styles.entityList} role="listbox">
{filteredGroups.map((group) => {
const isSelected = group.id === selectedGroupId;
return (
<div
key={group.id}
role="option"
aria-selected={isSelected}
className={
styles.entityItem +
(isSelected ? ' ' + styles.entityItemSelected : '')
}
onClick={() => setSelectedGroupId(group.id)}
>
<Avatar name={group.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{group.name}</div>
<div className={styles.entityMeta}>
{group.parentGroupId
? `Child of ${parentName(group.parentGroupId)}`
: 'Top-level'}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Right pane */}
<div className={styles.detailPane}>
{!selectedGroupId ? (
<div className={styles.emptyDetail}>Select a group to view details</div>
) : detailLoading ? (
<Spinner />
) : selectedGroup ? (
<div>
{/* Header */}
<div className={styles.detailHeader}>
<Avatar name={selectedGroup.name} size="md" />
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEdit
value={selectedGroup.name}
onSave={handleRename}
disabled={isBuiltinAdmins}
/>
<div className={styles.entityMeta}>
{selectedGroup.parentGroupId
? `Child of ${parentName(selectedGroup.parentGroupId)}`
: 'Top-level'}
</div>
</div>
<Button
variant="danger"
size="sm"
disabled={isBuiltinAdmins}
onClick={() => setDeleteOpen(true)}
>
Delete
</Button>
</div>
{/* Metadata */}
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>Group ID</span>
<MonoText size="xs">{selectedGroup.id}</MonoText>
<span className={styles.metaLabel}>Parent</span>
<span>{parentName(selectedGroup.parentGroupId)}</span>
</div>
{/* Members */}
<div className={styles.sectionTitle}>Members</div>
<div className={styles.sectionTags}>
{(selectedGroup.members ?? []).map((member) => (
<Tag
key={member.userId}
label={member.displayName}
onRemove={() => handleRemoveMember(member.userId)}
/>
))}
{(selectedGroup.members ?? []).length === 0 && (
<span className={styles.inheritedNote}>No members</span>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Select
options={[
{ value: '', label: 'Add member...' },
...availableUsers.map((u) => ({
value: u.userId,
label: u.displayName,
})),
]}
value={addMemberUserId}
onChange={(e) => setAddMemberUserId(e.target.value)}
/>
<Button
size="sm"
variant="secondary"
onClick={handleAddMember}
disabled={!addMemberUserId || addUserToGroup.isPending}
>
Add
</Button>
</div>
{/* Assigned roles */}
<div className={styles.sectionTitle}>Assigned Roles</div>
<div className={styles.sectionTags}>
{(selectedGroup.directRoles ?? []).map((role) => (
<Badge
key={role.id}
label={role.name}
variant="outlined"
onRemove={() => handleRemoveRole(role.id)}
/>
))}
{(selectedGroup.directRoles ?? []).length === 0 && (
<span className={styles.inheritedNote}>No roles assigned</span>
)}
</div>
{(selectedGroup.effectiveRoles ?? []).length >
(selectedGroup.directRoles ?? []).length && (
<div className={styles.inheritedNote}>
+
{(selectedGroup.effectiveRoles ?? []).length -
(selectedGroup.directRoles ?? []).length}{' '}
inherited role(s)
</div>
)}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Select
options={[
{ value: '', label: 'Assign role...' },
...availableRoles.map((r) => ({
value: r.id,
label: r.name,
})),
]}
value={addRoleId}
onChange={(e) => setAddRoleId(e.target.value)}
/>
<Button
size="sm"
variant="secondary"
onClick={handleAddRole}
disabled={!addRoleId || assignRoleToGroup.isPending}
>
Add
</Button>
</div>
</div>
) : null}
</div>
{/* Delete confirmation */}
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDelete}
title="Delete Group"
message={`Delete group "${selectedGroup?.name}"? This action cannot be undone.`}
confirmText="DELETE"
variant="danger"
loading={deleteGroup.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,28 @@
.section {
display: grid;
gap: 0.5rem;
}
.section h3 {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
}
.tagRow {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 2rem;
align-items: center;
}
.addRow {
display: flex;
gap: 0.5rem;
align-items: center;
}
.addRow input {
flex: 1;
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, Input, Toggle, FormField, Card, Alert, SectionHeader } from '@cameleer/design-system'; import { Button, Input, Toggle, FormField, Card, Alert, SectionHeader, Tag, ConfirmDialog } from '@cameleer/design-system';
import { adminFetch } from '../../api/queries/admin/admin-api'; import { adminFetch } from '../../api/queries/admin/admin-api';
import styles from './OidcConfigPage.module.css';
interface OidcConfig { interface OidcConfig {
enabled: boolean; enabled: boolean;
@@ -18,6 +19,8 @@ export default function OidcConfigPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [newRole, setNewRole] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => { useEffect(() => {
adminFetch<OidcConfig>('/oidc') adminFetch<OidcConfig>('/oidc')
@@ -64,15 +67,44 @@ export default function OidcConfigPage() {
<FormField label="Display Name Claim"><Input value={config.displayNameClaim} onChange={(e) => setConfig({ ...config, displayNameClaim: e.target.value })} /></FormField> <FormField label="Display Name Claim"><Input value={config.displayNameClaim} onChange={(e) => setConfig({ ...config, displayNameClaim: e.target.value })} /></FormField>
<Toggle checked={config.autoSignup} onChange={(e) => setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" /> <Toggle checked={config.autoSignup} onChange={(e) => setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" />
<div className={styles.section}>
<h3>Default Roles</h3>
<div className={styles.tagRow}>
{(config.defaultRoles || []).map(role => (
<Tag key={role} label={role} onRemove={() => {
setConfig(prev => ({ ...prev!, defaultRoles: prev!.defaultRoles.filter(r => r !== role) }));
}} />
))}
</div>
<div className={styles.addRow}>
<Input placeholder="Add role..." value={newRole} onChange={e => setNewRole(e.target.value)} />
<Button onClick={() => {
if (newRole.trim() && !config.defaultRoles?.includes(newRole.trim())) {
setConfig(prev => ({ ...prev!, defaultRoles: [...(prev!.defaultRoles || []), newRole.trim()] }));
setNewRole('');
}
}}>Add</Button>
</div>
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}> <div style={{ display: 'flex', gap: '0.75rem' }}>
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button> <Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
<Button variant="danger" onClick={handleDelete}>Remove Config</Button> <Button variant="danger" onClick={() => setDeleteOpen(true)}>Delete Configuration</Button>
</div> </div>
{error && <Alert variant="error">{error}</Alert>} {error && <Alert variant="error">{error}</Alert>}
{success && <Alert variant="success">Configuration saved</Alert>} {success && <Alert variant="success">Configuration saved</Alert>}
</div> </div>
</Card> </Card>
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDelete}
title="Delete OIDC Configuration"
message="Delete OIDC configuration? All OIDC users will lose access."
confirmText="DELETE"
/>
</div> </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,178 +1,35 @@
import { useState, useMemo } from 'react'; import { useState } from 'react';
import { import { StatCard, Tabs } from '@cameleer/design-system';
Tabs, DataTable, Badge, Avatar, Button, Input, Modal, FormField, import { useRbacStats } from '../../api/queries/admin/rbac';
Select, AlertDialog, StatCard, Spinner, import styles from './UserManagement.module.css';
} from '@cameleer/design-system'; import UsersTab from './UsersTab';
import type { Column } from '@cameleer/design-system'; import GroupsTab from './GroupsTab';
import { import RolesTab from './RolesTab';
useUsers, useUser, useGroups, useGroup, useRoles, useRole, useRbacStats,
useCreateUser, useUpdateUser, useDeleteUser,
useAssignRoleToUser, useRemoveRoleFromUser,
useAddUserToGroup, useRemoveUserFromGroup,
useCreateGroup, useUpdateGroup, useDeleteGroup,
useCreateRole, useUpdateRole, useDeleteRole,
useAssignRoleToGroup, useRemoveRoleFromGroup,
} from '../../api/queries/admin/rbac';
export default function RbacPage() { export default function RbacPage() {
const [tab, setTab] = useState('users');
const { data: stats } = useRbacStats(); const { data: stats } = useRbacStats();
const [tab, setTab] = useState('users');
return ( return (
<div> <div>
<h2 style={{ marginBottom: '1rem' }}>RBAC Management</h2> <h2 style={{ margin: '0 0 16px' }}>User Management</h2>
<div className={styles.statStrip}>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<StatCard label="Users" value={stats?.userCount ?? 0} /> <StatCard label="Users" value={stats?.userCount ?? 0} />
<StatCard label="Groups" value={stats?.groupCount ?? 0} /> <StatCard label="Groups" value={stats?.groupCount ?? 0} />
<StatCard label="Roles" value={stats?.roleCount ?? 0} /> <StatCard label="Roles" value={stats?.roleCount ?? 0} />
</div> </div>
<Tabs <Tabs
tabs={[ tabs={[
{ label: 'Users', value: 'users', count: stats?.userCount }, { label: 'Users', value: 'users' },
{ label: 'Groups', value: 'groups', count: stats?.groupCount }, { label: 'Groups', value: 'groups' },
{ label: 'Roles', value: 'roles', count: stats?.roleCount }, { label: 'Roles', value: 'roles' },
]} ]}
active={tab} active={tab}
onChange={setTab} onChange={setTab}
/> />
{tab === 'users' && <UsersTab />}
<div style={{ marginTop: '1rem' }}> {tab === 'groups' && <GroupsTab />}
{tab === 'users' && <UsersTab />} {tab === 'roles' && <RolesTab />}
{tab === 'groups' && <GroupsTab />}
{tab === 'roles' && <RolesTab />}
</div>
</div>
);
}
function UsersTab() {
const { data: users, isLoading } = useUsers();
const [createOpen, setCreateOpen] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [form, setForm] = useState({ username: '', displayName: '', email: '', password: '' });
const createUser = useCreateUser();
const deleteUser = useDeleteUser();
const columns: Column<any>[] = [
{ key: 'userId', header: 'Username', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
{ key: 'displayName', header: 'Display Name' },
{ key: 'email', header: 'Email' },
{ key: 'provider', header: 'Provider', render: (v) => <Badge label={String(v)} /> },
{
key: 'effectiveRoles', header: 'Roles',
render: (v) => (
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
{(v as any[] || []).map((r: any) => <Badge key={r.id || r.name} label={r.name} color="auto" />)}
</div>
),
},
];
if (isLoading) return <Spinner />;
const rows = (users || []).map((u: any) => ({ ...u, id: u.userId }));
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create User</Button>
</div>
<DataTable columns={columns} data={rows} pageSize={20} />
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create User">
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
<FormField label="Username" required><Input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /></FormField>
<FormField label="Display Name"><Input value={form.displayName} onChange={(e) => setForm({ ...form, displayName: e.target.value })} /></FormField>
<FormField label="Email"><Input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></FormField>
<FormField label="Password"><Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} /></FormField>
<Button variant="primary" onClick={() => { createUser.mutate(form); setCreateOpen(false); setForm({ username: '', displayName: '', email: '', password: '' }); }}>Create</Button>
</div>
</Modal>
<AlertDialog
open={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={() => { if (deleteId) deleteUser.mutate(deleteId); setDeleteId(null); }}
title="Delete User"
description={`Are you sure you want to delete user "${deleteId}"?`}
confirmLabel="Delete"
variant="danger"
/>
</div>
);
}
function GroupsTab() {
const { data: groups, isLoading } = useGroups();
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: '' });
const createGroup = useCreateGroup();
const columns: Column<any>[] = [
{ key: 'name', header: 'Name', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
{ key: 'members', header: 'Members', render: (v) => String((v as any[])?.length ?? 0) },
{
key: 'effectiveRoles', header: 'Roles',
render: (v) => (
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
{(v as any[] || []).map((r: any) => <Badge key={r.id || r.name} label={r.name} color="auto" />)}
</div>
),
},
];
if (isLoading) return <Spinner />;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create Group</Button>
</div>
<DataTable columns={columns} data={groups || []} pageSize={20} />
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create Group">
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
<FormField label="Name" required><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></FormField>
<Button variant="primary" onClick={() => { createGroup.mutate(form); setCreateOpen(false); setForm({ name: '' }); }}>Create</Button>
</div>
</Modal>
</div>
);
}
function RolesTab() {
const { data: roles, isLoading } = useRoles();
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: '', description: '', scope: '' });
const createRole = useCreateRole();
const columns: Column<any>[] = [
{ key: 'name', header: 'Name', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
{ key: 'description', header: 'Description' },
{ key: 'scope', header: 'Scope', render: (v) => v ? <Badge label={String(v)} /> : null },
{ key: 'system', header: 'System', render: (v) => v ? <Badge label="System" color="warning" /> : null },
{ key: 'effectivePrincipals', header: 'Users', render: (v) => String((v as any[])?.length ?? 0) },
];
if (isLoading) return <Spinner />;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create Role</Button>
</div>
<DataTable columns={columns} data={roles || []} pageSize={20} />
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create Role">
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
<FormField label="Name" required><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></FormField>
<FormField label="Description"><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></FormField>
<FormField label="Scope"><Input value={form.scope} onChange={(e) => setForm({ ...form, scope: e.target.value })} /></FormField>
<Button variant="primary" onClick={() => { createRole.mutate(form); setCreateOpen(false); setForm({ name: '', description: '', scope: '' }); }}>Create</Button>
</div>
</Modal>
</div> </div>
); );
} }

View File

@@ -0,0 +1,305 @@
import { useState } from 'react';
import {
Avatar,
Badge,
Button,
ConfirmDialog,
Input,
MonoText,
Spinner,
Tag,
useToast,
} from '@cameleer/design-system';
import {
useRoles,
useRole,
useCreateRole,
useDeleteRole,
} from '../../api/queries/admin/rbac';
import type { RoleDetail } from '../../api/queries/admin/rbac';
import styles from './UserManagement.module.css';
export default function RolesTab() {
const { data: roles, isLoading } = useRoles();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [newDescription, setNewDescription] = useState('');
const [confirmDelete, setConfirmDelete] = useState(false);
const { data: detail, isLoading: detailLoading } = useRole(selectedId);
const createRole = useCreateRole();
const deleteRole = useDeleteRole();
const { toast } = useToast();
const filtered = (roles ?? []).filter((r) =>
r.name.toLowerCase().includes(search.toLowerCase()),
);
function handleCreate() {
if (!newName.trim()) return;
createRole.mutate(
{ name: newName.trim(), description: newDescription.trim() || undefined },
{
onSuccess: () => {
toast({ title: 'Role created', variant: 'success' });
setShowCreate(false);
setNewName('');
setNewDescription('');
},
onError: () => {
toast({ title: 'Failed to create role', variant: 'error' });
},
},
);
}
function handleDelete() {
if (!selectedId) return;
deleteRole.mutate(selectedId, {
onSuccess: () => {
toast({ title: 'Role deleted', variant: 'success' });
setSelectedId(null);
setConfirmDelete(false);
},
onError: () => {
toast({ title: 'Failed to delete role', variant: 'error' });
setConfirmDelete(false);
},
});
}
return (
<div className={styles.splitPane}>
{/* Left pane — list */}
<div className={styles.listPane}>
<div className={styles.listHeader}>
<Input
placeholder="Search roles…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Button
variant="secondary"
size="sm"
onClick={() => setShowCreate((v) => !v)}
>
+ Add Role
</Button>
</div>
{showCreate && (
<div className={styles.createForm}>
<Input
placeholder="Role name (e.g. EDITOR)"
value={newName}
onChange={(e) => setNewName(e.target.value.toUpperCase())}
style={{ marginBottom: 8 }}
/>
<Input
placeholder="Description (optional)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
/>
<div className={styles.createFormActions}>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowCreate(false);
setNewName('');
setNewDescription('');
}}
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
loading={createRole.isPending}
disabled={!newName.trim()}
onClick={handleCreate}
>
Create
</Button>
</div>
</div>
)}
{isLoading ? (
<Spinner />
) : (
<div className={styles.entityList} role="listbox">
{filtered.map((role) => {
const assignmentCount =
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0);
return (
<div
key={role.id}
className={
styles.entityItem +
(selectedId === role.id ? ' ' + styles.entityItemSelected : '')
}
role="option"
aria-selected={selectedId === role.id}
onClick={() => setSelectedId(role.id)}
>
<Avatar name={role.name} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>
{role.name}
{role.system && <Badge label="system" variant="outlined" />}
</div>
<div className={styles.entityMeta}>
{role.description || '—'} · {assignmentCount} assignment
{assignmentCount !== 1 ? 's' : ''}
</div>
{((role.assignedGroups?.length ?? 0) > 0 ||
(role.directUsers?.length ?? 0) > 0) && (
<div className={styles.entityTags}>
{(role.assignedGroups ?? []).map((g) => (
<Tag key={g.id} label={g.name} color="success" />
))}
{(role.directUsers ?? []).map((u) => (
<Tag key={u.userId} label={u.displayName} />
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Right pane — detail */}
<div className={styles.detailPane}>
{!selectedId ? (
<div className={styles.emptyDetail}>Select a role to view details</div>
) : detailLoading || !detail ? (
<Spinner />
) : (
<RoleDetailPanel
role={detail}
onDeleteRequest={() => setConfirmDelete(true)}
/>
)}
</div>
{detail && (
<ConfirmDialog
open={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={handleDelete}
title="Delete role"
message={`Delete role "${detail.name}"? This cannot be undone.`}
confirmText={detail.name}
confirmLabel="Delete"
variant="danger"
loading={deleteRole.isPending}
/>
)}
</div>
);
}
// ── Detail panel ──────────────────────────────────────────────────────────────
interface RoleDetailPanelProps {
role: RoleDetail;
onDeleteRequest: () => void;
}
function RoleDetailPanel({ role, onDeleteRequest }: RoleDetailPanelProps) {
// Build a set of directly-assigned user IDs for distinguishing inherited principals
const directUserIds = new Set((role.directUsers ?? []).map((u) => u.userId));
return (
<div>
{/* Header */}
<div className={styles.detailHeader}>
<Avatar name={role.name} size="md" />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 16 }}>{role.name}</div>
{role.description && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{role.description}
</div>
)}
</div>
<Button
variant="danger"
size="sm"
disabled={role.system}
onClick={onDeleteRequest}
>
Delete
</Button>
</div>
{/* Metadata */}
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{role.id}</MonoText>
<span className={styles.metaLabel}>Scope</span>
<span>{role.scope || '—'}</span>
<span className={styles.metaLabel}>Type</span>
<span>{role.system ? 'System role (read-only)' : 'Custom role'}</span>
</div>
{/* Assigned to groups */}
<div className={styles.sectionTitle}>Assigned to groups</div>
<div className={styles.sectionTags}>
{(role.assignedGroups ?? []).length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
) : (
(role.assignedGroups ?? []).map((g) => (
<Tag key={g.id} label={g.name} color="success" />
))
)}
</div>
{/* Assigned to users (direct) */}
<div className={styles.sectionTitle}>Assigned to users (direct)</div>
<div className={styles.sectionTags}>
{(role.directUsers ?? []).length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
) : (
(role.directUsers ?? []).map((u) => (
<Tag key={u.userId} label={u.displayName} />
))
)}
</div>
{/* Effective principals */}
<div className={styles.sectionTitle}>Effective principals</div>
<div className={styles.sectionTags}>
{(role.effectivePrincipals ?? []).length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
) : (
(role.effectivePrincipals ?? []).map((u) => {
const isDirect = directUserIds.has(u.userId);
return isDirect ? (
<Badge key={u.userId} label={u.displayName} variant="filled" />
) : (
<Badge
key={u.userId}
label={`${u.displayName}`}
variant="dashed"
/>
);
})
)}
</div>
{(role.effectivePrincipals ?? []).some((u) => !directUserIds.has(u.userId)) && (
<div className={styles.inheritedNote}>
Dashed entries inherit this role through group membership
</div>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,535 @@
import { useState, useMemo } from 'react';
import {
Avatar,
Badge,
Button,
Input,
MonoText,
Tag,
InfoCallout,
ConfirmDialog,
Select,
Spinner,
InlineEdit,
useToast,
} from '@cameleer/design-system';
import {
useUsers,
useCreateUser,
useDeleteUser,
useAssignRoleToUser,
useRemoveRoleFromUser,
useAddUserToGroup,
useRemoveUserFromGroup,
useSetPassword,
useGroups,
useRoles,
} from '../../api/queries/admin/rbac';
import { useAuthStore } from '../../auth/auth-store';
import styles from './UserManagement.module.css';
export default function UsersTab() {
const { data: users, isLoading } = useUsers();
const { data: allGroups } = useGroups();
const { data: allRoles } = useRoles();
const currentUsername = useAuthStore((s) => s.username);
const { toast } = useToast();
const [search, setSearch] = useState('');
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
// Create form state
const [createUsername, setCreateUsername] = useState('');
const [createDisplayName, setCreateDisplayName] = useState('');
const [createEmail, setCreateEmail] = useState('');
const [createPassword, setCreatePassword] = useState('');
// Detail pane state
const [showPasswordForm, setShowPasswordForm] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [addGroupId, setAddGroupId] = useState('');
const [addRoleId, setAddRoleId] = useState('');
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Mutations
const createUser = useCreateUser();
const deleteUser = useDeleteUser();
const assignRole = useAssignRoleToUser();
const removeRole = useRemoveRoleFromUser();
const addToGroup = useAddUserToGroup();
const removeFromGroup = useRemoveUserFromGroup();
const setPassword = useSetPassword();
// Filtered user list
const filteredUsers = useMemo(() => {
if (!users) return [];
const q = search.toLowerCase();
if (!q) return users;
return users.filter(
(u) =>
u.displayName.toLowerCase().includes(q) ||
(u.email ?? '').toLowerCase().includes(q) ||
u.userId.toLowerCase().includes(q),
);
}, [users, search]);
const selectedUser = useMemo(
() => users?.find((u) => u.userId === selectedUserId) ?? null,
[users, selectedUserId],
);
// ── Handlers ──────────────────────────────────────────────────────────
function handleCreateUser() {
if (!createUsername.trim() || !createPassword.trim()) return;
createUser.mutate(
{
username: createUsername.trim(),
displayName: createDisplayName.trim() || undefined,
email: createEmail.trim() || undefined,
password: createPassword,
},
{
onSuccess: () => {
toast({ title: 'User created', variant: 'success' });
setShowCreateForm(false);
setCreateUsername('');
setCreateDisplayName('');
setCreateEmail('');
setCreatePassword('');
},
onError: () => {
toast({ title: 'Failed to create user', variant: 'error' });
},
},
);
}
function handleResetPassword() {
if (!selectedUser || !newPassword.trim()) return;
setPassword.mutate(
{ userId: selectedUser.userId, password: newPassword },
{
onSuccess: () => {
toast({ title: 'Password updated', variant: 'success' });
setShowPasswordForm(false);
setNewPassword('');
},
onError: () => {
toast({ title: 'Failed to update password', variant: 'error' });
},
},
);
}
function handleAddGroup() {
if (!selectedUser || !addGroupId) return;
addToGroup.mutate(
{ userId: selectedUser.userId, groupId: addGroupId },
{
onSuccess: () => {
toast({ title: 'Added to group', variant: 'success' });
setAddGroupId('');
},
onError: () => {
toast({ title: 'Failed to add group', variant: 'error' });
},
},
);
}
function handleAddRole() {
if (!selectedUser || !addRoleId) return;
assignRole.mutate(
{ userId: selectedUser.userId, roleId: addRoleId },
{
onSuccess: () => {
toast({ title: 'Role assigned', variant: 'success' });
setAddRoleId('');
},
onError: () => {
toast({ title: 'Failed to assign role', variant: 'error' });
},
},
);
}
function handleDeleteUser() {
if (!selectedUser) return;
deleteUser.mutate(selectedUser.userId, {
onSuccess: () => {
toast({ title: 'User deleted', variant: 'success' });
setSelectedUserId(null);
setShowDeleteDialog(false);
},
onError: () => {
toast({ title: 'Failed to delete user', variant: 'error' });
setShowDeleteDialog(false);
},
});
}
// Derived data for detail pane
const directGroupIds = new Set(selectedUser?.directGroups.map((g) => g.id) ?? []);
const directRoleIds = new Set(selectedUser?.directRoles.map((r) => r.id) ?? []);
const inheritedRoles = selectedUser?.effectiveRoles.filter((r) => !directRoleIds.has(r.id)) ?? [];
const availableGroups = (allGroups ?? [])
.filter((g) => !directGroupIds.has(g.id))
.map((g) => ({ value: g.id, label: g.name }));
const availableRoles = (allRoles ?? [])
.filter((r) => !directRoleIds.has(r.id))
.map((r) => ({ value: r.id, label: r.name }));
// Find group name for inherited role display
function findInheritingGroupName(roleId: string): string {
if (!selectedUser) return '';
for (const g of selectedUser.effectiveGroups) {
// We don't have group→roles in the summary, so just show "group"
void roleId;
return g.name;
}
return 'group';
}
const isSelf =
currentUsername != null &&
selectedUser != null &&
selectedUser.displayName === currentUsername;
// ── Render ────────────────────────────────────────────────────────────
return (
<div className={styles.splitPane}>
{/* ── Left pane ── */}
<div className={styles.listPane}>
<div className={styles.listHeader}>
<Input
placeholder="Search users…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch('')}
style={{ flex: 1 }}
/>
<Button
variant="secondary"
size="sm"
onClick={() => setShowCreateForm((v) => !v)}
>
{showCreateForm ? '✕ Cancel' : '+ Add User'}
</Button>
</div>
{showCreateForm && (
<div className={styles.createForm}>
<Input
placeholder="Username (required)"
value={createUsername}
onChange={(e) => setCreateUsername(e.target.value)}
style={{ marginBottom: 6 }}
/>
<Input
placeholder="Display Name"
value={createDisplayName}
onChange={(e) => setCreateDisplayName(e.target.value)}
style={{ marginBottom: 6 }}
/>
<Input
placeholder="Email"
type="email"
value={createEmail}
onChange={(e) => setCreateEmail(e.target.value)}
style={{ marginBottom: 6 }}
/>
<Input
placeholder="Password (required)"
type="password"
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
style={{ marginBottom: 6 }}
/>
<div className={styles.createFormActions}>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowCreateForm(false);
setCreateUsername('');
setCreateDisplayName('');
setCreateEmail('');
setCreatePassword('');
}}
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
loading={createUser.isPending}
disabled={!createUsername.trim() || !createPassword.trim()}
onClick={handleCreateUser}
>
Create
</Button>
</div>
</div>
)}
{isLoading && <Spinner size="md" />}
<div className={styles.entityList} role="listbox">
{filteredUsers.map((user) => (
<div
key={user.userId}
className={
styles.entityItem +
(user.userId === selectedUserId ? ' ' + styles.entityItemSelected : '')
}
role="option"
aria-selected={user.userId === selectedUserId}
tabIndex={0}
onClick={() => setSelectedUserId(user.userId)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedUserId(user.userId);
}
}}
>
<Avatar name={user.displayName} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>
{user.displayName}
{user.provider !== 'local' && (
<Badge label={user.provider} variant="outlined" />
)}
</div>
<div className={styles.entityMeta}>
{user.email || user.userId}
{user.directGroups.length > 0 && ` · ${user.directGroups.map((g) => g.name).join(', ')}`}
{user.directGroups.length === 0 && ' · no groups'}
</div>
{(user.directRoles.length > 0 || user.directGroups.length > 0) && (
<div className={styles.entityTags}>
{user.directRoles.map((r) => (
<Badge key={r.id} label={r.name} variant="filled" color="primary" />
))}
{user.directGroups.map((g) => (
<Badge key={g.id} label={g.name} variant="outlined" />
))}
</div>
)}
</div>
</div>
))}
</div>
</div>
{/* ── Right pane ── */}
<div className={styles.detailPane}>
{!selectedUser ? (
<div className={styles.emptyDetail}>Select a user to view details</div>
) : (
<>
{/* Header */}
<div className={styles.detailHeader}>
<Avatar name={selectedUser.displayName} size="lg" />
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEdit
value={selectedUser.displayName}
onSave={(val) => {
// useUpdateUser not imported here to keep things clean;
// display only — wired via displayName update if desired
void val;
}}
/>
{selectedUser.email && (
<div className={styles.entityMeta}>{selectedUser.email}</div>
)}
</div>
<Button
variant="danger"
size="sm"
disabled={isSelf}
onClick={() => setShowDeleteDialog(true)}
>
Delete
</Button>
</div>
{/* Metadata grid */}
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>User ID</span>
<MonoText size="sm">{selectedUser.userId}</MonoText>
<span className={styles.metaLabel}>Created</span>
<span>{new Date(selectedUser.createdAt).toLocaleString()}</span>
<span className={styles.metaLabel}>Provider</span>
<span>{selectedUser.provider}</span>
</div>
{/* Security section */}
<div className={styles.securitySection}>
<div className={styles.sectionTitle}>Security</div>
{selectedUser.provider === 'local' ? (
<>
{!showPasswordForm ? (
<Button
variant="secondary"
size="sm"
onClick={() => setShowPasswordForm(true)}
>
Reset password
</Button>
) : (
<div className={styles.resetForm}>
<Input
placeholder="New password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
style={{ flex: 1 }}
/>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowPasswordForm(false);
setNewPassword('');
}}
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
loading={setPassword.isPending}
disabled={!newPassword.trim()}
onClick={handleResetPassword}
>
Set
</Button>
</div>
)}
</>
) : (
<InfoCallout variant="info">
Password managed by identity provider
</InfoCallout>
)}
</div>
{/* Group membership */}
<div className={styles.sectionTitle}>Group Membership</div>
<div className={styles.sectionTags}>
{selectedUser.directGroups.map((g) => (
<Tag
key={g.id}
label={g.name}
onRemove={() =>
removeFromGroup.mutate(
{ userId: selectedUser.userId, groupId: g.id },
{
onError: () =>
toast({ title: 'Failed to remove group', variant: 'error' }),
},
)
}
/>
))}
</div>
{availableGroups.length > 0 && (
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Select
options={[{ value: '', label: 'Add to group…' }, ...availableGroups]}
value={addGroupId}
onChange={(e) => setAddGroupId(e.target.value)}
style={{ flex: 1 }}
/>
<Button
variant="secondary"
size="sm"
disabled={!addGroupId}
onClick={handleAddGroup}
loading={addToGroup.isPending}
>
Add
</Button>
</div>
)}
{/* Effective roles */}
<div className={styles.sectionTitle}>Roles</div>
<div className={styles.sectionTags}>
{selectedUser.directRoles.map((r) => (
<Tag
key={r.id}
label={r.name}
color="warning"
onRemove={() =>
removeRole.mutate(
{ userId: selectedUser.userId, roleId: r.id },
{
onError: () =>
toast({ title: 'Failed to remove role', variant: 'error' }),
},
)
}
/>
))}
{inheritedRoles.map((r) => (
<span key={r.id} style={{ opacity: 0.65 }}>
<Badge
label={`${findInheritingGroupName(r.id)} / ${r.name}`}
variant="dashed"
/>
</span>
))}
</div>
{inheritedRoles.length > 0 && (
<div className={styles.inheritedNote}>
Roles with are inherited through group membership
</div>
)}
{availableRoles.length > 0 && (
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Select
options={[{ value: '', label: 'Assign role…' }, ...availableRoles]}
value={addRoleId}
onChange={(e) => setAddRoleId(e.target.value)}
style={{ flex: 1 }}
/>
<Button
variant="secondary"
size="sm"
disabled={!addRoleId}
onClick={handleAddRole}
loading={assignRole.isPending}
>
Add
</Button>
</div>
)}
{/* Delete confirmation */}
<ConfirmDialog
open={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
onConfirm={handleDeleteUser}
title="Delete user"
message={`This will permanently delete the user "${selectedUser.displayName}". Type their username to confirm.`}
confirmText={selectedUser.displayName}
confirmLabel="Delete"
variant="danger"
loading={deleteUser.isPending}
/>
</>
)}
</div>
</div>
);
}

View File

@@ -1,10 +1,17 @@
.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;
} }
.scopeTrail {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.groupGrid { .groupGrid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -12,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 {
@@ -31,16 +77,54 @@
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);
} }
.instanceTps { .instanceMeta {
margin-left: auto;
font-size: 11px; font-size: 11px;
font-family: var(--font-mono);
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-mono);
}
.instanceError {
font-size: 11px;
color: var(--error);
font-family: var(--font-mono);
}
.instanceHeartbeatDead {
font-size: 11px;
color: var(--error);
font-family: var(--font-mono);
}
.instanceHeartbeatStale {
font-size: 11px;
color: var(--warning);
font-family: var(--font-mono);
}
.instanceLink {
color: var(--text-muted);
text-decoration: none;
font-size: 14px;
padding: 4px;
margin-left: auto;
}
.instanceLink:hover {
color: var(--text-primary);
} }
.eventCard { .eventCard {
@@ -64,3 +148,132 @@
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
} }
/* DetailPanel: Overview tab */
.overviewContent {
display: flex;
flex-direction: column;
gap: 16px;
padding: 4px 0;
}
.overviewRow {
display: flex;
align-items: center;
gap: 8px;
}
.detailList {
display: flex;
flex-direction: column;
gap: 0;
margin: 0;
padding: 0;
}
.detailRow {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 6px 0;
border-bottom: 1px solid var(--border-subtle);
font-size: 12px;
}
.detailRow:last-child {
border-bottom: none;
}
.detailRow dt {
color: var(--text-muted);
font-weight: 500;
}
.detailRow dd {
margin: 0;
color: var(--text-primary);
text-align: right;
}
.metricsSection {
display: flex;
flex-direction: column;
gap: 6px;
}
.metricLabel {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* DetailPanel: Performance tab */
.performanceContent {
display: flex;
flex-direction: column;
gap: 20px;
padding: 4px 0;
}
.chartSection {
display: flex;
flex-direction: column;
gap: 6px;
}
.chartLabel {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.emptyChart {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
background: var(--bg-surface-raised);
border: 1px dashed var(--border-subtle);
border-radius: var(--radius-md);
font-size: 12px;
color: var(--text-muted);
}
/* Status breakdown in stat card */
.statusBreakdown {
display: flex;
gap: 8px;
font-size: 11px;
}
.statusLive { color: var(--success); }
.statusStale { color: var(--warning); }
.statusDead { color: var(--error); }
/* Scope trail */
.scopeLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
/* DetailPanel override */
.detailPanelOverride {
position: fixed;
top: 0;
right: 0;
height: 100vh;
z-index: 100;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
}
.panelDivider {
border-top: 1px solid var(--border-subtle);
margin: 16px 0;
}

Some files were not shown because too many files have changed in this diff Show More