Files
cameleer-server/docs/superpowers/plans/2026-03-23-ui-mock-alignment.md
hsiegeln cb3ebfea7c
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
chore: rename cameleer3 to cameleer
Rename Java packages from com.cameleer3 to com.cameleer, module
directories from cameleer3-* to cameleer-*, and all references
throughout workflows, Dockerfiles, docs, migrations, and pom.xml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:28:42 +02:00

54 KiB
Raw Blame History

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

  • cameleer-server-app/src/main/resources/db/migration/V7__processor_stats_by_id.sql
  • cameleer-server-app/src/main/java/com/cameleer/server/app/dto/ProcessorMetrics.java
  • cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AgentMetricsResponse.java
  • cameleer-server-app/src/main/java/com/cameleer/server/app/dto/MetricBucket.java
  • cameleer-server-app/src/main/java/com/cameleer/server/app/dto/SetPasswordRequest.java
  • cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentMetricsController.java

Modified backend files

  • cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AgentInstanceResponse.java — add version, capabilities
  • cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java — map new fields
  • cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteMetricsController.java — add processor stats method
  • cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java — add password reset
  • cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java — agent metrics rule
  • cameleer-server-app/src/main/java/com/cameleer/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: cameleer-server-app/src/main/resources/db/migration/V7__processor_stats_by_id.sql

  • Step 1: Create migration file

-- 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 cameleer-server-app && mvn clean compile -q 2>&1 | tail -5 Expected: BUILD SUCCESS (Flyway picks up migration at runtime)

  • Step 3: Commit
git add cameleer-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: cameleer-server-app/src/main/java/com/cameleer/server/app/dto/ProcessorMetrics.java

  • Modify: cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteMetricsController.java

  • Modify: cameleer-server-app/src/main/java/com/cameleer/server/app/config/OpenApiConfig.java

  • Step 1: Create ProcessorMetrics DTO

package com.cameleer.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:

    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
git add cameleer-server-app/src/main/java/com/cameleer/server/app/dto/ProcessorMetrics.java \
       cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteMetricsController.java \
       cameleer-server-app/src/main/java/com/cameleer/server/app/config/OpenApiConfig.java
git commit -m "feat: add GET /routes/metrics/processors endpoint"

Task 3: Backend — Agent Metrics Query Endpoint

Files:

  • Create: cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AgentMetricsResponse.java

  • Create: cameleer-server-app/src/main/java/com/cameleer/server/app/dto/MetricBucket.java

  • Create: cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentMetricsController.java

  • Modify: cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java

  • Modify: cameleer-server-app/src/main/java/com/cameleer/server/app/config/OpenApiConfig.java

  • Step 1: Create DTOs

MetricBucket.java:

package com.cameleer.server.app.dto;

import java.time.Instant;
import jakarta.validation.constraints.NotNull;

public record MetricBucket(
    @NotNull Instant time,
    double value
) {}

AgentMetricsResponse.java:

package com.cameleer.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
) {}
  • Step 2: Create AgentMetricsController
package com.cameleer.server.app.controller;

import com.cameleer.server.app.dto.AgentMetricsResponse;
import com.cameleer.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<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<>());
        }

        // 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: cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java), add a new matcher for the agent metrics path. Find the section with VIEWER role matchers and add:

.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
git add cameleer-server-app/src/main/java/com/cameleer/server/app/dto/MetricBucket.java \
       cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AgentMetricsResponse.java \
       cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentMetricsController.java \
       cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java \
       cameleer-server-app/src/main/java/com/cameleer/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: cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AgentInstanceResponse.java

  • Modify: cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java

  • Create: cameleer-server-app/src/main/java/com/cameleer/server/app/dto/SetPasswordRequest.java

  • Modify: cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java

  • Step 1: Add fields to AgentInstanceResponse

Add two new fields to the record:

String version,
Map<String, Object> capabilities

Update from(AgentInfo) factory method to populate:

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:

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
package com.cameleer.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:

@PostMapping("/{userId}/password")
public ResponseEntity<Void> 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:

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
git add cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AgentInstanceResponse.java \
       cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java \
       cameleer-server-app/src/main/java/com/cameleer/server/app/dto/SetPasswordRequest.java \
       cameleer-server-app/src/main/java/com/cameleer/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:

"@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
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:
import { ToastProvider } from '@cameleer/design-system';

Wrap the outermost provider in the return:

return (
  <CommandPaletteProvider>
    <GlobalFilterProvider>
      <ToastProvider>
        <LayoutContent />
      </ToastProvider>
    </GlobalFilterProvider>
  </CommandPaletteProvider>
);
  1. Pass onLogout and user props to TopBar:
<TopBar
  breadcrumb={breadcrumb}
  user={{ name: username }}
  onLogout={handleLogout}
/>
  1. Remove the manual Dropdown + Avatar logout code (the <div> containing Avatar and Dropdown menu items for "Signed in as" and "Logout").

  2. 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
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:

<div className={styles.healthStrip}>
  <StatCard label="Throughput" value={stats ? `${(stats.totalCount / timeWindowSeconds).toFixed(1)}/s` : '—'} />
  <StatCard label="Error Rate" value={stats ? `${((stats.failedCount / Math.max(stats.totalCount, 1)) * 100).toFixed(1)}%` : '—'} accent={stats && stats.failedCount > 0 ? 'error' : undefined} />
  <StatCard label="Avg Latency" value={stats ? `${stats.avgDurationMs}ms` : '—'} />
  <StatCard label="P99 Latency" value={stats ? `${stats.p99LatencyMs}ms` : '—'} />
  <StatCard label="In-Flight" value={stats?.activeCount ?? 0} />
</div>

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:

{detail?.errorMessage && (
  <div className={styles.panelSection}>
    <div className={styles.panelSectionTitle}>Errors</div>
    <Alert variant="error">
      <strong>{detail.errorMessage.split(':')[0]}</strong>
      <div>{detail.errorMessage.includes(':') ? detail.errorMessage.substring(detail.errorMessage.indexOf(':') + 1).trim() : ''}</div>
    </Alert>
    {detail.errorStackTrace && (
      <Collapsible title="Stack Trace">
        <CodeBlock content={detail.errorStackTrace} />
      </Collapsible>
    )}
  </div>
)}

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

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:

<Tabs
  tabs={[
    { label: 'Overview', value: 'overview' },
    { label: 'Processors', value: 'processors' },
    { label: 'Route Flow', value: 'flow' },
  ]}
  active={detailTab}
  onChange={setDetailTab}
/>

Add const [detailTab, setDetailTab] = useState('overview'); state.

For the "flow" tab content, map the diagram data + execution data to RouteFlow nodes:

{detailTab === 'flow' && diagram && detail && (
  <RouteFlow
    nodes={mapDiagramToRouteNodes(diagram, detail.processors)}
    onNodeClick={(node, i) => { /* 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
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

// 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:

function countProcessors(nodes: any[]): number {
  return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0);
}

Add to the header's right section:

<div className={styles.headerStat}>
  <div className={styles.headerStatLabel}>Processors</div>
  <div className={styles.headerStatValue}>{countProcessors(detail.processors || [])}</div>
</div>
  • Step 3: Add Correlation Chain section

Below the header card, add:

{correlationData && correlationData.data && correlationData.data.length > 1 && (
  <div className={styles.correlationChain}>
    <SectionHeader>Correlation Chain</SectionHeader>
    <div className={styles.chainRow}>
      {correlationData.data.map((exec, i) => (
        <React.Fragment key={exec.executionId}>
          {i > 0 && <span className={styles.chainArrow}></span>}
          <a
            href={`/exchanges/${exec.executionId}`}
            className={`${styles.chainCard} ${exec.executionId === id ? styles.chainCardActive : ''}`}
            onClick={(e) => { e.preventDefault(); navigate(`/exchanges/${exec.executionId}`); }}
          >
            <StatusDot variant={exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'warning'} />
            <span className={styles.chainRoute}>{exec.routeId}</span>
            <span className={styles.chainDuration}>{exec.durationMs}ms</span>
          </a>
        </React.Fragment>
      ))}
      {correlationData.total > 20 && (
        <span className={styles.chainMore}>+{correlationData.total - 20} more</span>
      )}
    </div>
  </div>
)}
  • Step 4: Add CSS classes

In ExchangeDetail.module.css, add:

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

<div className={styles.timelineHeader}>
  <span className={styles.timelineTitle}>Processors</span>
  <SegmentedTabs
    tabs={[
      { label: 'Timeline', value: 'timeline' },
      { label: 'Flow', value: 'flow' },
    ]}
    active={viewMode}
    onChange={(v) => setViewMode(v as 'timeline' | 'flow')}
  />
</div>
  • Step 2: Conditionally render Timeline or RouteFlow
<div className={styles.timelineBody}>
  {viewMode === 'timeline' ? (
    <ProcessorTimeline processors={flatProcessors} ... />
  ) : (
    diagram ? (
      <RouteFlow
        nodes={mapDiagramToRouteNodes(diagram, detail.processors)}
        onNodeClick={(node, i) => handleProcessorSelect(i)}
        selectedIndex={selectedProcessorIndex}
      />
    ) : (
      <Spinner />
    )
  )}
</div>

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

// 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
.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:

const errorPatterns = useMemo(() => {
  if (!failedExecs?.data) return [];
  const groups = new Map<string, { count: number; lastSeen: string; sampleId: string }>();
  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:

const RouteDetail = lazy(() => import('./pages/Routes/RouteDetail'));

// In the routes array, replace:
{ path: ':routeId', element: <SuspenseWrapper><RouteDetail /></SuspenseWrapper> }
  • Step 5: Verify UI builds

Run: cd ui && npx tsc --noEmit 2>&1 | head -20

  • Step 6: Commit
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:

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

<div className={styles.statStrip}>
  <StatCard label="Total Agents" value={agents.length} detail={`${liveCount} live / ${staleCount} stale / ${deadCount} dead`} />
  <StatCard label="Applications" value={uniqueApps} />
  <StatCard label="Active Routes" value={activeRoutes} />
  <StatCard label="Total TPS" value={totalTps.toFixed(1)} />
  <StatCard label="Dead" value={deadCount} accent={deadCount > 0 ? 'error' : undefined} />
</div>

Update CSS: .statStrip grid to grid-template-columns: repeat(5, 1fr);

  • Step 2: Add scope trail breadcrumb

Below stat cards:

<div className={styles.scopeTrail}>
  <Breadcrumb items={[
    { label: 'Agents', href: '/agents' },
    ...(appId ? [{ label: appId }] : []),
  ]} />
  {!appId && <Badge label={`${liveCount} live`} variant="outlined" />}
  {appId && <Badge label={health} color={health === 'live' ? 'success' : health === 'stale' ? 'warning' : 'error'} />}
</div>

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:

{deadInGroup.length > 0 && (
  <Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert>
)}
  • Step 4: Enrich instance table rows

Update instance rows to show more columns:

<div className={styles.instanceRow} onClick={() => setSelectedAgent(agent)} role="option" tabIndex={0}>
  <StatusDot variant={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
  <span className={styles.instanceName}>{agent.name}</span>
  <Badge label={agent.status} />
  <span className={styles.instanceMeta}>{formatUptime(agent.uptimeSeconds)}</span>
  <span className={styles.instanceTps}>{agent.tps?.toFixed(1)} tps</span>
  <span className={styles.instanceMeta}>{(agent.errorRate * 100).toFixed(1)}%</span>
  <span className={styles.instanceMeta}>{formatRelativeTime(agent.lastHeartbeat)}</span>
  <a href={`/agents/${agent.group}/${agent.id}`} className={styles.instanceLink} onClick={(e) => e.stopPropagation()}></a>
</div>

Add utility functions:

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

// 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<string, Array<{ time: string; value: number }>> }>;
    },
    enabled: !!agentId && names.length > 0,
    refetchInterval: 30_000,
  });
}
  • Step 2: Add DetailPanel to AgentHealth

Add state: const [selectedAgent, setSelectedAgent] = useState<any>(null);

After the main content (GroupCard grid + EventFeed), add:

{selectedAgent && (
  <DetailPanel
    open={!!selectedAgent}
    title={selectedAgent.name}
    onClose={() => setSelectedAgent(null)}
    tabs={[
      { label: 'Overview', value: 'overview', content: <AgentOverviewPane agent={selectedAgent} /> },
      { label: 'Performance', value: 'performance', content: <AgentPerformancePane agent={selectedAgent} /> },
    ]}
  />
)}

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

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;

<div className={styles.statStrip}>
  <StatCard label="CPU" value={cpuPct != null ? `${(cpuPct * 100).toFixed(0)}%` : '—'} />
  <StatCard label="Memory" value={memPct != null ? `${memPct.toFixed(0)}%` : '—'} />
  <StatCard label="Throughput" value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '—'} />
  <StatCard label="Errors" value={agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : '—'} accent={agent?.errorRate > 0 ? 'error' : undefined} />
  <StatCard label="Uptime" value={formatUptime(agent?.uptimeSeconds)} />
</div>

Update CSS: .statStrip to repeat(5, 1fr).

  • Step 2: Add Process Information card

After scope trail, before charts:

<Card className={styles.infoCard}>
  <SectionHeader>Process Information</SectionHeader>
  <div className={styles.infoGrid}>
    {agent?.capabilities?.jvmVersion && <div><span className={styles.infoLabel}>JVM</span><span>{agent.capabilities.jvmVersion}</span></div>}
    {agent?.capabilities?.camelVersion && <div><span className={styles.infoLabel}>Camel</span><span>{agent.capabilities.camelVersion}</span></div>}
    {agent?.capabilities?.springBootVersion && <div><span className={styles.infoLabel}>Spring Boot</span><span>{agent.capabilities.springBootVersion}</span></div>}
    <div><span className={styles.infoLabel}>Started</span><span>{agent?.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '—'}</span></div>
    <div><span className={styles.infoLabel}>Capabilities</span>
      <span className={styles.capTags}>
        {Object.entries(agent?.capabilities || {}).filter(([k, v]) => typeof v === 'boolean' && v).map(([k]) => (
          <Badge key={k} label={k} variant="outlined" />
        ))}
      </span>
    </div>
  </div>
</Card>

CSS additions:

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

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:

<div className={styles.chartCard}>
  <div className={styles.chartTitle}>CPU Usage</div>
  {cpuData?.length ? <AreaChart series={[{ name: 'CPU %', data: cpuData }]} /> : <EmptyState title="No data" />}
</div>
  • Step 4: Add version badge to breadcrumb
<Breadcrumb items={[
  { label: 'Agents', href: '/agents' },
  { label: agent?.group || appId, href: `/agents/${appId}` },
  { label: agent?.name || instanceId },
]} />
{agent?.version && <Badge label={agent.version} variant="outlined" />}
<StatusDot variant={agent?.status === 'LIVE' ? 'success' : agent?.status === 'STALE' ? 'warning' : 'error'} />
<Badge label={agent?.status || ''} />
  • Step 5: Add Application Log placeholder

After EventFeed:

<EmptyState title="Application Logs" description="Application log streaming is not yet available" />
  • Step 6: Verify UI builds

Run: cd ui && npx tsc --noEmit 2>&1 | head -20

  • Step 7: Commit
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:

<SectionHeader>Default Roles</SectionHeader>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 8 }}>
  {(config.defaultRoles || []).map(role => (
    <Tag key={role} label={role} onRemove={() => {
      setConfig(prev => ({ ...prev, defaultRoles: prev.defaultRoles.filter(r => r !== role) }));
    }} />
  ))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
  <Input placeholder="Add role..." value={newRole} onChange={e => setNewRole(e.target.value)} />
  <Button onClick={() => {
    if (newRole.trim()) {
      setConfig(prev => ({ ...prev, defaultRoles: [...(prev.defaultRoles || []), newRole.trim()] }));
      setNewRole('');
    }
  }}>Add</Button>
</div>

Add state: const [newRole, setNewRole] = useState('');

  • Step 2: Replace delete button with ConfirmDialog
const [deleteOpen, setDeleteOpen] = useState(false);

<Button variant="danger" onClick={() => setDeleteOpen(true)}>Delete Configuration</Button>
<ConfirmDialog
  open={deleteOpen}
  onClose={() => 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
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:

  • .statStripdisplay: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 16px;

  • .splitPanedisplay: grid; grid-template-columns: 52fr 48fr; height: calc(100vh - 200px);

  • .listPaneoverflow-y: auto; border-right: 1px solid var(--border-subtle); padding-right: 16px;

  • .detailPaneoverflow-y: auto; padding-left: 16px;

  • .listHeaderdisplay: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;

  • .entityListdisplay: flex; flex-direction: column; gap: 2px;

  • .entityItem — flex row, padding, cursor pointer, border-radius, transition

  • .entityItemSelectedbackground: var(--bg-raised);

  • .entityInfo — flex column, gap 2px

  • .entityNamefont-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 6px;

  • .entityMetafont-size: 11px; color: var(--text-muted);

  • .entityTagsdisplay: flex; gap: 4px; flex-wrap: wrap; margin-top: 2px;

  • .createForm — surface card, padding, margin-bottom, border

  • .createFormActionsdisplay: flex; gap: 8px; justify-content: flex-end; margin-top: 8px;

  • .detailHeader — flex row, gap 12px, margin-bottom 16px, padding-bottom 16px, border-bottom

  • .metaGriddisplay: grid; grid-template-columns: 100px 1fr; gap: 6px 12px; font-size: 13px; margin-bottom: 16px;

  • .metaLabelfont-weight: 700; font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted);

  • .sectionTagsdisplay: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px;

  • .inheritedNotefont-size: 11px; font-style: italic; color: var(--text-muted); margin-top: 4px;

  • .securitySection — padding, border, border-radius, margin-bottom

  • .resetFormdisplay: 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:

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 (
    <div>
      <h2>User Management</h2>
      <div className={styles.statStrip}>
        <StatCard label="Users" value={stats?.userCount ?? 0} />
        <StatCard label="Groups" value={stats?.groupCount ?? 0} />
        <StatCard label="Roles" value={stats?.roleCount ?? 0} />
      </div>
      <Tabs
        tabs={[
          { label: 'Users', value: 'users' },
          { label: 'Groups', value: 'groups' },
          { label: 'Roles', value: 'roles' },
        ]}
        active={tab}
        onChange={setTab}
      />
      {tab === 'users' && <UsersTab />}
      {tab === 'groups' && <GroupsTab />}
      {tab === 'roles' && <RolesTab />}
    </div>
  );
}
  • 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
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
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

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

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