diff --git a/docs/superpowers/plans/2026-03-23-ui-mock-alignment.md b/docs/superpowers/plans/2026-03-23-ui-mock-alignment.md new file mode 100644 index 00000000..e1651486 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-ui-mock-alignment.md @@ -0,0 +1,1625 @@ +# UI Mock Alignment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close all visual and functional gaps between the `@cameleer/design-system` v0.0.2 mocks and the cameleer3-server UI. + +**Architecture:** Backend-first (new endpoints + migration), then frontend page-by-page alignment. Each task produces one git commit. Backend tasks use Spring Boot controllers querying TimescaleDB continuous aggregates. Frontend tasks modify React pages consuming design system components with TanStack Query hooks. + +**Tech Stack:** Java 17 / Spring Boot 3.4.3 / PostgreSQL+TimescaleDB / React 19 / Vite / @cameleer/design-system / TanStack Query / openapi-fetch / Zustand / CSS Modules + +**Spec:** `docs/superpowers/specs/2026-03-23-ui-mock-alignment-design.md` + +--- + +## File Structure + +### New backend files +- `cameleer3-server-app/src/main/resources/db/migration/V7__processor_stats_by_id.sql` +- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ProcessorMetrics.java` +- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentMetricsResponse.java` +- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/MetricBucket.java` +- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SetPasswordRequest.java` +- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentMetricsController.java` + +### Modified backend files +- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentInstanceResponse.java` — add `version`, `capabilities` +- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java` — map new fields +- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteMetricsController.java` — add processor stats method +- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java` — add password reset +- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java` — agent metrics rule +- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/OpenApiConfig.java` — register new DTOs + +### New frontend files +- `ui/src/pages/Routes/RouteDetail.tsx` + `RouteDetail.module.css` +- `ui/src/pages/Admin/UsersTab.tsx` +- `ui/src/pages/Admin/GroupsTab.tsx` +- `ui/src/pages/Admin/RolesTab.tsx` +- `ui/src/pages/Admin/UserManagement.module.css` +- `ui/src/api/queries/agent-metrics.ts` +- `ui/src/api/queries/processor-metrics.ts` +- `ui/src/api/queries/correlation.ts` + +### Modified frontend files +- `ui/package.json` — design system `^0.0.2` +- `ui/src/router.tsx` — RouteDetail route +- `ui/src/components/LayoutShell.tsx` — TopBar `onLogout`, ToastProvider +- `ui/src/pages/Dashboard/Dashboard.tsx` + `Dashboard.module.css` +- `ui/src/pages/ExchangeDetail/ExchangeDetail.tsx` + `ExchangeDetail.module.css` +- `ui/src/pages/Routes/RoutesMetrics.tsx` +- `ui/src/pages/AgentHealth/AgentHealth.tsx` + `AgentHealth.module.css` +- `ui/src/pages/AgentInstance/AgentInstance.tsx` + `AgentInstance.module.css` +- `ui/src/pages/Admin/RbacPage.tsx` +- `ui/src/pages/Admin/OidcConfigPage.tsx` +- `ui/src/api/schema.d.ts` + +--- + +## Task 1: V7 Migration — Processor Stats Continuous Aggregate + +**Files:** +- Create: `cameleer3-server-app/src/main/resources/db/migration/V7__processor_stats_by_id.sql` + +- [ ] **Step 1: Create migration file** + +```sql +-- V7: Per-processor-id continuous aggregate for route detail page +CREATE MATERIALIZED VIEW stats_1m_processor_detail +WITH (timescaledb.continuous) AS +SELECT + time_bucket('1 minute', start_time) AS bucket, + group_name, + route_id, + processor_id, + processor_type, + COUNT(*) AS total_count, + COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count, + SUM(duration_ms) AS duration_sum, + MAX(duration_ms) AS duration_max, + approx_percentile(0.99, percentile_agg(duration_ms)) AS p99_duration +FROM processor_executions +GROUP BY bucket, group_name, route_id, processor_id, processor_type; + +SELECT add_continuous_aggregate_policy('stats_1m_processor_detail', + start_offset => INTERVAL '1 hour', + end_offset => INTERVAL '1 minute', + schedule_interval => INTERVAL '1 minute'); +``` + +- [ ] **Step 2: Verify migration compiles** + +Run: `cd cameleer3-server-app && mvn clean compile -q 2>&1 | tail -5` +Expected: BUILD SUCCESS (Flyway picks up migration at runtime) + +- [ ] **Step 3: Commit** + +```bash +git add cameleer3-server-app/src/main/resources/db/migration/V7__processor_stats_by_id.sql +git commit -m "feat: add V7 migration for per-processor-id continuous aggregate" +``` + +--- + +## Task 2: Backend — Processor Stats Endpoint + +**Files:** +- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ProcessorMetrics.java` +- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteMetricsController.java` +- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/OpenApiConfig.java` + +- [ ] **Step 1: Create ProcessorMetrics DTO** + +```java +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 +) {} +``` + +- [ ] **Step 2: Add endpoint to RouteMetricsController** + +Add a new method `getProcessorMetrics` with `@GetMapping("/processors")` in `RouteMetricsController.java`. It should: +- Accept `@RequestParam String routeId` (required), `@RequestParam(required = false) String appId`, `@RequestParam(required = false) Instant from`, `@RequestParam(required = false) Instant to` +- Default `from` to 24h ago, `to` to now (same pattern as existing `getRouteMetrics`) +- Query `stats_1m_processor_detail` with SQL: + ```sql + SELECT processor_id, processor_type, route_id, group_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 = ? + GROUP BY processor_id, processor_type, route_id, group_name + ORDER BY SUM(total_count) DESC + ``` +- Add optional `AND group_name = ?` when `appId` is provided +- Map rows to `ProcessorMetrics` records, computing `errorRate = failedCount > 0 ? (double) failedCount / totalCount : 0.0` + +- [ ] **Step 3: Register DTO in OpenApiConfig** + +Add `ProcessorMetrics.class` to `ALL_FIELDS_REQUIRED` set in `OpenApiConfig.java`. + +- [ ] **Step 4: Verify build** + +Run: `mvn clean compile -q 2>&1 | tail -5` +Expected: BUILD SUCCESS + +- [ ] **Step 5: Commit** + +```bash +git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/ProcessorMetrics.java \ + cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RouteMetricsController.java \ + cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/OpenApiConfig.java +git commit -m "feat: add GET /routes/metrics/processors endpoint" +``` + +--- + +## Task 3: Backend — Agent Metrics Query Endpoint + +**Files:** +- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentMetricsResponse.java` +- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/MetricBucket.java` +- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentMetricsController.java` +- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java` +- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/OpenApiConfig.java` + +- [ ] **Step 1: Create DTOs** + +`MetricBucket.java`: +```java +package com.cameleer3.server.app.dto; + +import java.time.Instant; +import jakarta.validation.constraints.NotNull; + +public record MetricBucket( + @NotNull Instant time, + double value +) {} +``` + +`AgentMetricsResponse.java`: +```java +package com.cameleer3.server.app.dto; + +import java.util.List; +import java.util.Map; +import jakarta.validation.constraints.NotNull; + +public record AgentMetricsResponse( + @NotNull Map> metrics +) {} +``` + +- [ ] **Step 2: Create AgentMetricsController** + +```java +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.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 metricNames = Arrays.asList(names.split(",")); + long intervalMs = (to.toEpochMilli() - from.toEpochMilli()) / Math.max(buckets, 1); + String intervalStr = intervalMs + " milliseconds"; + + Map> result = new LinkedHashMap<>(); + for (String name : metricNames) { + result.put(name.trim(), new ArrayList<>()); + } + + // Query all requested metrics in one go + 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, from, to, namesArray); + + return new AgentMetricsResponse(result); + } +} +``` + +- [ ] **Step 3: Add SecurityConfig rule** + +In `SecurityConfig.java` (path: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java`), add a new matcher for the agent metrics path. Find the section with VIEWER role matchers and add: +```java +.requestMatchers(HttpMethod.GET, "/api/v1/agents/*/metrics").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") +``` +Place it **before** the existing `/api/v1/agents` exact-match rule to ensure it takes precedence. + +- [ ] **Step 4: Register DTOs in OpenApiConfig** + +Add `AgentMetricsResponse.class` and `MetricBucket.class` to `ALL_FIELDS_REQUIRED` set. + +- [ ] **Step 5: Verify build** + +Run: `mvn clean compile -q 2>&1 | tail -5` +Expected: BUILD SUCCESS + +- [ ] **Step 6: Commit** + +```bash +git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/MetricBucket.java \ + cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentMetricsResponse.java \ + cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentMetricsController.java \ + cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java \ + cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/OpenApiConfig.java +git commit -m "feat: add GET /agents/{id}/metrics endpoint for JVM metrics" +``` + +--- + +## Task 4: Backend — Enrich AgentInstanceResponse + Password Reset + +**Files:** +- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentInstanceResponse.java` +- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java` +- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SetPasswordRequest.java` +- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java` + +- [ ] **Step 1: Add fields to AgentInstanceResponse** + +Add two new fields to the record: +```java +String version, +Map capabilities +``` + +Update `from(AgentInfo)` factory method to populate: +```java +public static AgentInstanceResponse from(AgentInfo info) { + return new AgentInstanceResponse( + info.id(), info.name(), info.group(), info.state().name(), + info.routeIds(), info.registeredAt(), info.lastHeartbeat(), + 0.0, 0.0, 0, info.routeIds().size(), 0L, + info.version(), info.capabilities() + ); +} +``` + +Update `withMetrics` method to carry through the new fields: +```java +public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) { + return new AgentInstanceResponse( + id, name, group, status, routeIds, registeredAt, lastHeartbeat, + tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds, + version, capabilities + ); +} +``` + +- [ ] **Step 2: Create SetPasswordRequest DTO** + +```java +package com.cameleer3.server.app.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SetPasswordRequest( + @NotBlank String password +) {} +``` + +- [ ] **Step 3: Add password reset method to UserAdminController** + +Add after the existing `deleteUser` method: +```java +@PostMapping("/{userId}/password") +public ResponseEntity resetPassword( + @PathVariable String userId, + @Valid @RequestBody SetPasswordRequest request, + HttpServletRequest httpRequest) { + userRepository.updatePassword(userId, passwordEncoder.encode(request.password())); + auditService.log("reset_password", AuditCategory.USER_MGMT, userId, null, AuditResult.SUCCESS, httpRequest); + return ResponseEntity.noContent().build(); +} +``` + +Also check that `UserRepository` (or `PostgresUserRepository`) has an `updatePassword(userId, hash)` method. + +Additionally, add a frontend mutation hook to `ui/src/api/queries/admin/rbac.ts`: +```tsx +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'] }), + }); +} +``` If not, add one with SQL: +```sql +UPDATE users SET password_hash = ?, updated_at = NOW() WHERE user_id = ? +``` + +- [ ] **Step 4: Verify build** + +Run: `mvn clean compile -q 2>&1 | tail -5` +Expected: BUILD SUCCESS + +- [ ] **Step 5: Commit** + +```bash +git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AgentInstanceResponse.java \ + cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java \ + cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/SetPasswordRequest.java \ + cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java +git commit -m "feat: enrich AgentInstanceResponse with version/capabilities, add password reset endpoint" +``` + +--- + +## Task 5: Regenerate schema.d.ts + Update Design System + +**Files:** +- Modify: `ui/package.json` +- Modify: `ui/src/api/schema.d.ts` + +- [ ] **Step 1: Update design system version** + +In `ui/package.json`, change: +```json +"@cameleer/design-system": "^0.0.2" +``` + +- [ ] **Step 2: Install updated package** + +Run: `cd ui && npm install 2>&1 | tail -10` +Expected: added/updated packages, no errors + +- [ ] **Step 3: Start backend locally and regenerate schema** + +The backend must be running at localhost:8081 for this step. Start it, then: +Run: `cd ui && npm run generate-api:live 2>&1` + +If the backend isn't available locally, use the remote: +Run: `curl -s http://192.168.50.86:30090/api/v1/api-docs -o ui/src/api/openapi.json && cd ui && npx openapi-typescript src/api/openapi.json -o src/api/schema.d.ts 2>&1` + +**Note:** After regeneration, strip `/api/v1` prefix from paths in openapi.json (the existing post-processing script should handle this). Verify key new types exist: `ProcessorMetrics`, `AgentMetricsResponse`, `MetricBucket`, and that `AgentInstanceResponse` now has `version` and `capabilities`. + +- [ ] **Step 4: Verify UI builds** + +Run: `cd ui && npx tsc --noEmit 2>&1 | head -20` +Expected: No errors (or only pre-existing ones) + +- [ ] **Step 5: Commit** + +```bash +git add ui/package.json ui/package-lock.json ui/src/api/openapi.json ui/src/api/schema.d.ts +git commit -m "chore: update design system to v0.0.2, regenerate schema.d.ts" +``` + +--- + +## Task 6: LayoutShell — TopBar onLogout + ToastProvider + +**Files:** +- Modify: `ui/src/components/LayoutShell.tsx` + +- [ ] **Step 1: Read TopBar v0.0.2 props to confirm onLogout behavior** + +Check the design system's TopBar.tsx (already confirmed: it has `onLogout?: () => void` prop and renders a user dropdown with avatar when `user` and `onLogout` are both provided). + +- [ ] **Step 2: Update LayoutShell** + +In `LayoutShell.tsx`: + +1. Add `ToastProvider` import from `@cameleer/design-system` and wrap the component tree: +```tsx +import { ToastProvider } from '@cameleer/design-system'; +``` +Wrap the outermost provider in the return: +```tsx +return ( + + + + + + + +); +``` + +2. Pass `onLogout` and `user` props to TopBar: +```tsx + +``` + +3. Remove the manual `Dropdown` + `Avatar` logout code (the `
` containing Avatar and Dropdown menu items for "Signed in as" and "Logout"). + +4. Remove unused imports (`Dropdown`, `Avatar`) if they're no longer used elsewhere in the file. + +- [ ] **Step 3: Verify UI builds** + +Run: `cd ui && npx tsc --noEmit 2>&1 | head -20` + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/components/LayoutShell.tsx +git commit -m "feat: use TopBar onLogout prop, add ToastProvider" +``` + +--- + +## Task 7: Dashboard — Stat Card Alignment + Errors Section + +**Files:** +- Modify: `ui/src/pages/Dashboard/Dashboard.tsx` +- Modify: `ui/src/pages/Dashboard/Dashboard.module.css` + +- [ ] **Step 1: Update stat cards** + +Replace the current 5 StatCards with mock-aligned versions: + +```tsx +
+ + 0 ? 'error' : undefined} /> + + + +
+``` + +Calculate `timeWindowSeconds` from the global filter time range (e.g., `(to - from) / 1000`). + +- [ ] **Step 2: Add Errors section to DetailPanel** + +In the DetailPanel content, between Overview and Processors sections, add a conditional Errors section: + +```tsx +{detail?.errorMessage && ( +
+
Errors
+ + {detail.errorMessage.split(':')[0]} +
{detail.errorMessage.includes(':') ? detail.errorMessage.substring(detail.errorMessage.indexOf(':') + 1).trim() : ''}
+
+ {detail.errorStackTrace && ( + + + + )} +
+)} +``` + +Import `Alert`, `Collapsible`, `CodeBlock` from `@cameleer/design-system`. + +- [ ] **Step 3: Verify UI builds** + +Run: `cd ui && npx tsc --noEmit 2>&1 | head -20` + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/Dashboard/Dashboard.tsx ui/src/pages/Dashboard/Dashboard.module.css +git commit -m "feat: align Dashboard stat cards with mock, add errors section to DetailPanel" +``` + +--- + +## Task 8: Dashboard — DetailPanel RouteFlow Tab + +**Files:** +- Modify: `ui/src/pages/Dashboard/Dashboard.tsx` + +- [ ] **Step 1: Read RouteFlow component props interface** + +The RouteFlow component from the design system accepts: +```ts +interface RouteFlowProps { + nodes: RouteNode[]; + onNodeClick?: (node: RouteNode, index: number) => void; + selectedIndex?: number; + className?: string; +} + +interface RouteNode { + name: string; + type: 'from' | 'process' | 'to' | 'choice' | 'error-handler'; + durationMs: number; // required — use 0 as fallback for running/unknown + status: 'ok' | 'slow' | 'fail'; // required — map from execution status + isBottleneck?: boolean; +} +``` + +- [ ] **Step 2: Add RouteFlow tab to DetailPanel** + +Add `useDiagramByRoute` import from `../api/queries/diagrams`. + +In the DetailPanel, change from two sections (Overview + Processors) to a `Tabs` component with three tabs: + +```tsx + +``` + +Add `const [detailTab, setDetailTab] = useState('overview');` state. + +For the "flow" tab content, map the diagram data + execution data to RouteFlow nodes: +```tsx +{detailTab === 'flow' && diagram && detail && ( + { /* select processor by index */ }} + selectedIndex={selectedProcessorIndex} + /> +)} +``` + +Create a helper function `mapDiagramToRouteNodes` in `ui/src/utils/diagram-mapping.ts` (shared by Tasks 8, 10, and 11) that: +1. Takes the diagram's `PositionedNode[]` and the execution's `ProcessorNode[]` +2. For each diagram node, finds the matching processor by `diagramNodeId` +3. Maps to `RouteNode` format: name = node.label, type = mapNodeType(node.type), durationMs = processor.durationMs, status = mapStatus(processor.status) + +- [ ] **Step 3: Verify UI builds** + +Run: `cd ui && npx tsc --noEmit 2>&1 | head -20` + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/Dashboard/Dashboard.tsx +git commit -m "feat: add Route Flow tab to Dashboard DetailPanel" +``` + +--- + +## Task 9: Exchange Detail — Header Enrichment + Correlation Chain + +**Files:** +- Modify: `ui/src/pages/ExchangeDetail/ExchangeDetail.tsx` +- Modify: `ui/src/pages/ExchangeDetail/ExchangeDetail.module.css` +- Create: `ui/src/api/queries/correlation.ts` + +- [ ] **Step 1: Create useCorrelationChain hook** + +```tsx +// ui/src/api/queries/correlation.ts +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, + limit: 20, + sortField: 'startTime', + sortDir: 'asc', + }, + }); + return data; + }, + enabled: !!correlationId, + }); +} +``` + +- [ ] **Step 2: Add processor count to header** + +In `ExchangeDetail.tsx`, add a utility to count processors recursively: +```tsx +function countProcessors(nodes: any[]): number { + return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0); +} +``` + +Add to the header's right section: +```tsx +
+
Processors
+
{countProcessors(detail.processors || [])}
+
+``` + +- [ ] **Step 3: Add Correlation Chain section** + +Below the header card, add: +```tsx +{correlationData && correlationData.data && correlationData.data.length > 1 && ( +
+ Correlation Chain +
+ {correlationData.data.map((exec, i) => ( + + {i > 0 && } + { e.preventDefault(); navigate(`/exchanges/${exec.executionId}`); }} + > + + {exec.routeId} + {exec.durationMs}ms + + + ))} + {correlationData.total > 20 && ( + +{correlationData.total - 20} more + )} +
+
+)} +``` + +- [ ] **Step 4: Add CSS classes** + +In `ExchangeDetail.module.css`, add: +```css +.correlationChain { + margin-bottom: 16px; +} +.chainRow { + display: flex; + align-items: center; + gap: 8px; + overflow-x: auto; + padding: 12px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); +} +.chainArrow { + color: var(--text-muted); + font-size: 16px; + flex-shrink: 0; +} +.chainCard { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: var(--bg-raised); + border: 1px solid var(--border-subtle); + border-radius: 6px; + font-size: 12px; + text-decoration: none; + color: var(--text-primary); + flex-shrink: 0; + cursor: pointer; +} +.chainCard:hover { background: var(--bg-hover); } +.chainCardActive { border-color: var(--accent); background: var(--bg-hover); } +.chainRoute { font-weight: 600; } +.chainDuration { color: var(--text-muted); font-family: var(--font-mono); font-size: 11px; } +.chainMore { color: var(--text-muted); font-size: 11px; font-style: italic; } +``` + +- [ ] **Step 5: Verify UI builds** + +Run: `cd ui && npx tsc --noEmit 2>&1 | head -20` + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/api/queries/correlation.ts \ + ui/src/pages/ExchangeDetail/ExchangeDetail.tsx \ + ui/src/pages/ExchangeDetail/ExchangeDetail.module.css +git commit -m "feat: add correlation chain and processor count to Exchange Detail" +``` + +--- + +## Task 10: Exchange Detail — Timeline / Flow Toggle + +**Files:** +- Modify: `ui/src/pages/ExchangeDetail/ExchangeDetail.tsx` +- Modify: `ui/src/pages/ExchangeDetail/ExchangeDetail.module.css` + +- [ ] **Step 1: Add SegmentedTabs toggle** + +Import `SegmentedTabs` and `RouteFlow` from design system. Add `useDiagramByRoute` from queries. + +Add state: `const [viewMode, setViewMode] = useState<'timeline' | 'flow'>('timeline');` + +Replace the processor timeline section header with a toggle: +```tsx +
+ Processors + setViewMode(v as 'timeline' | 'flow')} + /> +
+``` + +- [ ] **Step 2: Conditionally render Timeline or RouteFlow** + +```tsx +
+ {viewMode === 'timeline' ? ( + + ) : ( + diagram ? ( + handleProcessorSelect(i)} + selectedIndex={selectedProcessorIndex} + /> + ) : ( + + ) + )} +
+``` + +Reuse the same `mapDiagramToRouteNodes` helper from Task 8 — extract it to a shared utility if needed, or duplicate in this file since it's small. + +- [ ] **Step 3: Verify UI builds** + +Run: `cd ui && npx tsc --noEmit 2>&1 | head -20` + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/ExchangeDetail/ExchangeDetail.tsx \ + ui/src/pages/ExchangeDetail/ExchangeDetail.module.css +git commit -m "feat: add Timeline/Flow toggle to Exchange Detail" +``` + +--- + +## Task 11: Route Detail Page + +**Files:** +- Create: `ui/src/pages/Routes/RouteDetail.tsx` +- Create: `ui/src/pages/Routes/RouteDetail.module.css` +- Create: `ui/src/api/queries/processor-metrics.ts` +- Modify: `ui/src/router.tsx` + +- [ ] **Step 1: Create useProcessorMetrics hook** + +```tsx +// ui/src/api/queries/processor-metrics.ts +import { useQuery } from '@tanstack/react-query'; +import { config } from '../client'; +import { useAuthStore } from '../../auth/auth-store'; + +export function useProcessorMetrics(routeId: string | null, appId?: string) { + const token = useAuthStore((s) => s.accessToken); + return useQuery({ + queryKey: ['processor-metrics', routeId, appId], + queryFn: async () => { + 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, + }); +} +``` + +- [ ] **Step 2: Create RouteDetail.module.css** + +```css +.headerCard { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 16px; + margin-bottom: 16px; +} +.headerRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} +.headerLeft { display: flex; align-items: center; gap: 12px; } +.headerRight { display: flex; gap: 20px; } +.headerStat { text-align: center; } +.headerStatLabel { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 2px; } +.headerStatValue { font-size: 14px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); } +.diagramStatsGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 20px; +} +.diagramPane, .statsPane { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 16px; + overflow: hidden; +} +.paneTitle { font-size: 13px; font-weight: 700; color: var(--text-primary); margin-bottom: 12px; } +.tabSection { margin-top: 20px; } +.chartGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +.chartCard { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 16px; + overflow: hidden; +} +.chartTitle { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 12px; } +.executionsTable { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; +} +.errorPatterns { + display: flex; + flex-direction: column; + gap: 8px; +} +.errorRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + font-size: 12px; +} +.errorMessage { flex: 1; font-family: var(--font-mono); color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 400px; } +.errorCount { font-weight: 700; color: var(--error); margin: 0 12px; } +.errorTime { color: var(--text-muted); font-size: 11px; } +``` + +- [ ] **Step 3: Create RouteDetail.tsx** + +Build the page component with: +1. `useParams()` to get `appId` and `routeId` +2. `useRouteCatalog()` to get health/exchange count +3. `useDiagramByRoute(appId, routeId)` for the route diagram +4. `useProcessorMetrics(routeId, appId)` for processor stats table +5. `useStatsTimeseries(from, to, routeId, appId)` for charts +6. `useSearchExecutions({ routeId, group: appId, ... })` for recent executions +7. Separate search for failed executions for error patterns tab + +Layout: +- Header card with route name, app, health badge, exchange count, last seen, back link +- Two-column grid: RouteFlow on left, DataTable of processor stats on right +- Tabs below: Performance (2x2 chart grid), Recent Executions (DataTable), Error Patterns (grouped errors) + +For error patterns, group client-side: +```tsx +const errorPatterns = useMemo(() => { + if (!failedExecs?.data) return []; + const groups = new Map(); + for (const exec of failedExecs.data) { + const msg = exec.errorMessage || 'Unknown error'; + const existing = groups.get(msg); + if (!existing || exec.startTime > existing.lastSeen) { + groups.set(msg, { count: (existing?.count || 0) + 1, lastSeen: exec.startTime, sampleId: exec.executionId }); + } else { + existing.count++; + } + } + return [...groups.entries()].sort((a, b) => b[1].count - a[1].count); +}, [failedExecs]); +``` + +- [ ] **Step 4: Update router** + +In `router.tsx`, change the `/routes/:appId/:routeId` route to lazy-load `RouteDetail` instead of `RoutesMetrics`: + +```tsx +const RouteDetail = lazy(() => import('./pages/Routes/RouteDetail')); + +// In the routes array, replace: +{ path: ':routeId', element: } +``` + +- [ ] **Step 5: Verify UI builds** + +Run: `cd ui && npx tsc --noEmit 2>&1 | head -20` + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/pages/Routes/RouteDetail.tsx \ + ui/src/pages/Routes/RouteDetail.module.css \ + ui/src/api/queries/processor-metrics.ts \ + ui/src/router.tsx +git commit -m "feat: add Route Detail page with diagram, processor stats, and tabbed sections" +``` + +--- + +## Task 12: Agent Health — Stat Cards, Scope Trail, Alert Banners, Table Enrichment + +**Files:** +- Modify: `ui/src/pages/AgentHealth/AgentHealth.tsx` +- Modify: `ui/src/pages/AgentHealth/AgentHealth.module.css` + +- [ ] **Step 1: Update stat cards to 5-card layout** + +Replace current 4 cards: +```tsx +const liveCount = agents.filter(a => a.status === 'LIVE').length; +const staleCount = agents.filter(a => a.status === 'STALE').length; +const deadCount = agents.filter(a => a.status === 'DEAD').length; +const uniqueApps = new Set(agents.map(a => a.group)).size; +const activeRoutes = agents.filter(a => a.status === 'LIVE').reduce((sum, a) => sum + (a.activeRoutes || 0), 0); +const totalTps = agents.filter(a => a.status === 'LIVE').reduce((sum, a) => sum + (a.tps || 0), 0); + +
+ + + + + 0 ? 'error' : undefined} /> +
+``` + +Update CSS: `.statStrip` grid to `grid-template-columns: repeat(5, 1fr);` + +- [ ] **Step 2: Add scope trail breadcrumb** + +Below stat cards: +```tsx +
+ + {!appId && } + {appId && } +
+``` + +CSS: +```css +.scopeTrail { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} +``` + +- [ ] **Step 3: Add alert banners to GroupCards** + +In each GroupCard's content, before the instance list: +```tsx +{deadInGroup.length > 0 && ( + {deadInGroup.length} instance(s) unreachable +)} +``` + +- [ ] **Step 4: Enrich instance table rows** + +Update instance rows to show more columns: +```tsx +
setSelectedAgent(agent)} role="option" tabIndex={0}> + + {agent.name} + + {formatUptime(agent.uptimeSeconds)} + {agent.tps?.toFixed(1)} tps + {(agent.errorRate * 100).toFixed(1)}% + {formatRelativeTime(agent.lastHeartbeat)} + e.stopPropagation()}>→ +
+``` + +Add utility functions: +```tsx +function formatUptime(seconds?: number): string { + if (!seconds) return '—'; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const mins = Math.floor((seconds % 3600) / 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +function formatRelativeTime(iso?: string): string { + if (!iso) return '—'; + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} +``` + +- [ ] **Step 5: Add CSS for new elements** + +```css +.instanceMeta { font-size: 11px; color: var(--text-muted); font-family: var(--font-mono); } +.instanceLink { color: var(--text-muted); text-decoration: none; font-size: 14px; padding: 4px; } +.instanceLink:hover { color: var(--text-primary); } +``` + +- [ ] **Step 6: Verify UI builds** + +Run: `cd ui && npx tsc --noEmit 2>&1 | head -20` + +- [ ] **Step 7: Commit** + +```bash +git add ui/src/pages/AgentHealth/AgentHealth.tsx \ + ui/src/pages/AgentHealth/AgentHealth.module.css +git commit -m "feat: align Agent Health with mock — stat cards, scope trail, alerts, enriched table" +``` + +--- + +## Task 13: Agent Health — DetailPanel + +**Files:** +- Modify: `ui/src/pages/AgentHealth/AgentHealth.tsx` +- Modify: `ui/src/pages/AgentHealth/AgentHealth.module.css` +- Create: `ui/src/api/queries/agent-metrics.ts` + +- [ ] **Step 1: Create useAgentMetrics hook** + +```tsx +// ui/src/api/queries/agent-metrics.ts +import { useQuery } from '@tanstack/react-query'; +import { useAuthStore } from '../../auth/auth-store'; +import { config } from '../client'; + +export function useAgentMetrics(agentId: string | null, names: string[], buckets = 60) { + const token = useAuthStore((s) => s.accessToken); + return useQuery({ + queryKey: ['agent-metrics', agentId, names.join(','), buckets], + queryFn: async () => { + 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> }>; + }, + enabled: !!agentId && names.length > 0, + refetchInterval: 30_000, + }); +} +``` + +- [ ] **Step 2: Add DetailPanel to AgentHealth** + +Add state: `const [selectedAgent, setSelectedAgent] = useState(null);` + +After the main content (GroupCard grid + EventFeed), add: +```tsx +{selectedAgent && ( + setSelectedAgent(null)} + tabs={[ + { label: 'Overview', value: 'overview', content: }, + { label: 'Performance', value: 'performance', content: }, + ]} + /> +)} +``` + +`AgentOverviewPane` renders: StatusDot + Badge, app name, version, uptime, last heartbeat, TPS, error rate, active/total routes, CPU ProgressBar, Memory ProgressBar. + +CPU and Memory data comes from `useAgentMetrics(selectedAgent.id, ['jvm.cpu.process', 'jvm.memory.heap.used', 'jvm.memory.heap.max'], 1)`. + +`AgentPerformancePane` renders two LineCharts using agent timeseries data. + +- [ ] **Step 3: Wire instance row click to select agent** + +The `onClick` handler on instance rows (from Task 12) sets `setSelectedAgent(agent)`. + +- [ ] **Step 4: Verify UI builds** + +Run: `cd ui && npx tsc --noEmit 2>&1 | head -20` + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/api/queries/agent-metrics.ts \ + ui/src/pages/AgentHealth/AgentHealth.tsx \ + ui/src/pages/AgentHealth/AgentHealth.module.css +git commit -m "feat: add DetailPanel with Overview + Performance tabs to Agent Health" +``` + +--- + +## Task 14: Agent Instance — JVM Charts, Process Info, Stat Cards + +**Files:** +- Modify: `ui/src/pages/AgentInstance/AgentInstance.tsx` +- Modify: `ui/src/pages/AgentInstance/AgentInstance.module.css` + +- [ ] **Step 1: Update stat cards to 5-card layout** + +Replace current 4 cards. CPU and Memory use `useAgentMetrics` with `buckets=1`: +```tsx +const { data: latestMetrics } = useAgentMetrics( + agent?.id || null, + ['jvm.cpu.process', 'jvm.memory.heap.used', 'jvm.memory.heap.max'], + 1 +); + +const cpuPct = latestMetrics?.metrics?.['jvm.cpu.process']?.[0]?.value; +const heapUsed = latestMetrics?.metrics?.['jvm.memory.heap.used']?.[0]?.value; +const heapMax = latestMetrics?.metrics?.['jvm.memory.heap.max']?.[0]?.value; +const memPct = heapMax ? (heapUsed / heapMax) * 100 : undefined; + +
+ + + + 0 ? 'error' : undefined} /> + +
+``` + +Update CSS: `.statStrip` to `repeat(5, 1fr)`. + +- [ ] **Step 2: Add Process Information card** + +After scope trail, before charts: +```tsx + + Process Information +
+ {agent?.capabilities?.jvmVersion &&
JVM{agent.capabilities.jvmVersion}
} + {agent?.capabilities?.camelVersion &&
Camel{agent.capabilities.camelVersion}
} + {agent?.capabilities?.springBootVersion &&
Spring Boot{agent.capabilities.springBootVersion}
} +
Started{agent?.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '—'}
+
Capabilities + + {Object.entries(agent?.capabilities || {}).filter(([k, v]) => typeof v === 'boolean' && v).map(([k]) => ( + + ))} + +
+
+
+``` + +CSS additions: +```css +.infoGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 16px; font-size: 13px; } +.infoLabel { font-weight: 700; font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); display: block; margin-bottom: 2px; } +.capTags { display: flex; gap: 4px; flex-wrap: wrap; } +``` + +- [ ] **Step 3: Expand charts to 3x2 grid** + +Fetch JVM metrics: +```tsx +const { data: jvmMetrics } = useAgentMetrics( + agent?.id || null, + ['jvm.cpu.process', 'jvm.memory.heap.used', 'jvm.memory.heap.max', 'jvm.threads.count', 'jvm.gc.time'], + 60 +); +``` + +Update `.chartsGrid` CSS to `grid-template-columns: repeat(3, 1fr);` + +Render 6 chart cards: +1. CPU Usage (AreaChart) — `jvm.cpu.process` values × 100 for percentage +2. Memory Heap (AreaChart) — two series: `jvm.memory.heap.used` and `jvm.memory.heap.max` +3. Throughput (AreaChart) — existing timeseries data +4. Error Rate (LineChart) — existing timeseries data +5. Thread Count (LineChart) — `jvm.threads.count` +6. GC Pauses (BarChart) — `jvm.gc.time` + +Each chart wrapped in: +```tsx +
+
CPU Usage
+ {cpuData?.length ? : } +
+``` + +- [ ] **Step 4: Add version badge to breadcrumb** + +```tsx + +{agent?.version && } + + +``` + +- [ ] **Step 5: Add Application Log placeholder** + +After EventFeed: +```tsx + +``` + +- [ ] **Step 6: Verify UI builds** + +Run: `cd ui && npx tsc --noEmit 2>&1 | head -20` + +- [ ] **Step 7: Commit** + +```bash +git add ui/src/pages/AgentInstance/AgentInstance.tsx \ + ui/src/pages/AgentInstance/AgentInstance.module.css +git commit -m "feat: align Agent Instance with mock — JVM charts, process info, stat cards, log placeholder" +``` + +--- + +## Task 15: OIDC Config — Default Roles + ConfirmDialog + +**Files:** +- Modify: `ui/src/pages/Admin/OidcConfigPage.tsx` + +- [ ] **Step 1: Add Default Roles section** + +Import `Tag`, `ConfirmDialog` from design system. Import `useRoles` from admin RBAC hooks. + +After the existing form fields, add: +```tsx +Default Roles +
+ {(config.defaultRoles || []).map(role => ( + { + setConfig(prev => ({ ...prev, defaultRoles: prev.defaultRoles.filter(r => r !== role) })); + }} /> + ))} +
+
+ setNewRole(e.target.value)} /> + +
+``` + +Add state: `const [newRole, setNewRole] = useState('');` + +- [ ] **Step 2: Replace delete button with ConfirmDialog** + +```tsx +const [deleteOpen, setDeleteOpen] = useState(false); + + + setDeleteOpen(false)} + onConfirm={handleDelete} + title="Delete OIDC Configuration" + message="Delete OIDC configuration? All OIDC users will lose access." + confirmText="DELETE" +/> +``` + +- [ ] **Step 3: Verify UI builds** + +Run: `cd ui && npx tsc --noEmit 2>&1 | head -20` + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/Admin/OidcConfigPage.tsx +git commit -m "feat: add default roles and ConfirmDialog to OIDC config" +``` + +--- + +## Task 16: RBAC — CSS Module + Container Restructure + +**Files:** +- Create: `ui/src/pages/Admin/UserManagement.module.css` +- Modify: `ui/src/pages/Admin/RbacPage.tsx` + +- [ ] **Step 1: Create CSS module** + +Create `UserManagement.module.css` with all the split-pane layout classes from the spec: +- `.statStrip` — `display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 16px;` +- `.splitPane` — `display: grid; grid-template-columns: 52fr 48fr; height: calc(100vh - 200px);` +- `.listPane` — `overflow-y: auto; border-right: 1px solid var(--border-subtle); padding-right: 16px;` +- `.detailPane` — `overflow-y: auto; padding-left: 16px;` +- `.listHeader` — `display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;` +- `.entityList` — `display: flex; flex-direction: column; gap: 2px;` +- `.entityItem` — flex row, padding, cursor pointer, border-radius, transition +- `.entityItemSelected` — `background: var(--bg-raised);` +- `.entityInfo` — flex column, gap 2px +- `.entityName` — `font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 6px;` +- `.entityMeta` — `font-size: 11px; color: var(--text-muted);` +- `.entityTags` — `display: flex; gap: 4px; flex-wrap: wrap; margin-top: 2px;` +- `.createForm` — surface card, padding, margin-bottom, border +- `.createFormActions` — `display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px;` +- `.detailHeader` — flex row, gap 12px, margin-bottom 16px, padding-bottom 16px, border-bottom +- `.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);` +- `.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, border, border-radius, margin-bottom +- `.resetForm` — `display: flex; gap: 8px; margin-top: 8px;` +- `.emptyDetail` — centered flex, muted text, full height + +- [ ] **Step 2: Restructure RbacPage as container** + +Slim down `RbacPage.tsx` to be a container that renders stat cards, tabs, and delegates to tab components: + +```tsx +import { useState } from 'react'; +import { StatCard, Tabs } from '@cameleer/design-system'; +import { useRbacStats } from '../../api/queries/admin/rbac'; +import UsersTab from './UsersTab'; +import GroupsTab from './GroupsTab'; +import RolesTab from './RolesTab'; +import styles from './UserManagement.module.css'; + +export default function RbacPage() { + const { data: stats } = useRbacStats(); + const [tab, setTab] = useState('users'); + + return ( +
+

User Management

+
+ + + +
+ + {tab === 'users' && } + {tab === 'groups' && } + {tab === 'roles' && } +
+ ); +} +``` + +- [ ] **Step 3: Verify UI builds (may have TS errors from missing tab components — that's expected)** + +Run: `cd ui && npx tsc --noEmit 2>&1 | head -20` + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/Admin/UserManagement.module.css \ + ui/src/pages/Admin/RbacPage.tsx +git commit -m "refactor: restructure RBAC page to container + tab components, add CSS module" +``` + +--- + +## Task 17: RBAC — Users Tab + +**Files:** +- Create: `ui/src/pages/Admin/UsersTab.tsx` + +- [ ] **Step 1: Build UsersTab component** + +Full split-pane component with: + +**Left pane (list):** +- Search input filtering users by username, displayName, email +- "+ Add User" button toggling inline create form +- Create form: username, displayName, email, password inputs + Cancel/Create buttons +- Scrollable entity list with Avatar, name, provider badge, email, role/group tags +- Click to select, keyboard accessible (Enter/Space) + +**Right pane (detail):** +- Empty state when nothing selected +- When selected: + - Header: Avatar (lg), display name (InlineEdit), email, Delete button + - Status Tag "Active" + - Metadata grid: user ID (MonoText), created, provider + - Security section: password reset form (local) or InfoCallout (OIDC) + - Group membership: removable Tags + MultiSelect to add + - Effective roles: direct (removable Tags) + inherited (dashed Badges with ↑ source) + - Delete: ConfirmDialog with type-to-confirm + +Use all existing RBAC hooks from `ui/src/api/queries/admin/rbac.ts`. + +Import `useToast` for mutation feedback (success/error toasts). + +- [ ] **Step 2: Verify UI builds** + +Run: `cd ui && npx tsc --noEmit 2>&1 | head -20` + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/Admin/UsersTab.tsx +git commit -m "feat: add Users tab with split-pane layout, inline create, detail panel" +``` + +--- + +## Task 18: RBAC — Groups Tab + +**Files:** +- Create: `ui/src/pages/Admin/GroupsTab.tsx` + +- [ ] **Step 1: Build GroupsTab component** + +Same split-pane pattern as UsersTab: + +**Left pane:** +- Search, "+ Add Group" inline form (name + parent Select) +- Entity list: Avatar, group name, parent info, child/member counts, role tags + +**Right pane:** +- Header: group name (InlineEdit for non-built-in), parent, Delete button +- Metadata: group ID +- Members: removable Tags + MultiSelect +- Child groups: removable Tags + MultiSelect (circular reference prevention) +- Roles: removable Tags + MultiSelect (warning on removal if affects members) + +- [ ] **Step 2: Verify and commit** + +```bash +git add ui/src/pages/Admin/GroupsTab.tsx +git commit -m "feat: add Groups tab with hierarchy management and member/role assignment" +``` + +--- + +## Task 19: RBAC — Roles Tab + +**Files:** +- Create: `ui/src/pages/Admin/RolesTab.tsx` + +- [ ] **Step 1: Build RolesTab component** + +Same split-pane pattern: + +**Left pane:** +- Search, "+ Add Role" inline form (name auto-uppercase + description) +- Entity list: Avatar, role name, system badge, description, assignment count, group/user tags + +**Right pane:** +- Header: role name, description, Delete button (disabled for system) +- Metadata: ID, scope, type +- Assigned to groups: view-only Tags +- Assigned to users: view-only Tags +- Effective principals: filled Badges (direct) + dashed Badges (inherited) + +- [ ] **Step 2: Verify and commit** + +```bash +git add ui/src/pages/Admin/RolesTab.tsx +git commit -m "feat: add Roles tab with system role protection and principal display" +``` + +--- + +## Task 20: Final Build Verification + Push + +- [ ] **Step 1: Full backend build** + +Run: `mvn clean compile -q 2>&1 | tail -10` +Expected: BUILD SUCCESS + +- [ ] **Step 2: Full frontend build** + +Run: `cd ui && npx tsc --noEmit 2>&1 | head -30` +Run: `cd ui && npm run build 2>&1 | tail -10` +Expected: No errors + +- [ ] **Step 3: Fix any TypeScript errors** + +If there are TS errors, fix them. Common issues: +- Missing imports from design system +- Type mismatches between schema.d.ts and hook return types +- Optional chaining needed on nullable fields + +- [ ] **Step 4: Push to remote** + +Run: `git push` diff --git a/docs/superpowers/specs/2026-03-23-ui-mock-alignment-design.md b/docs/superpowers/specs/2026-03-23-ui-mock-alignment-design.md new file mode 100644 index 00000000..d51125ec --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-ui-mock-alignment-design.md @@ -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` +```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> 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 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 + +``` + +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