# 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<String, List<MetricBucket>> 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<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: `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<String, Object> 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<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`:
```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 (
  <CommandPaletteProvider>
    <GlobalFilterProvider>
      <ToastProvider>
        <LayoutContent />
      </ToastProvider>
    </GlobalFilterProvider>
  </CommandPaletteProvider>
);
```

2. Pass `onLogout` and `user` props to TopBar:
```tsx
<TopBar
  breadcrumb={breadcrumb}
  user={{ name: username }}
  onLogout={handleLogout}
/>
```

3. Remove the manual `Dropdown` + `Avatar` logout code (the `<div>` 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
<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:

```tsx
{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**

```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
<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:
```tsx
{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**

```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
<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:
```tsx
{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:
```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
<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**

```tsx
<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**

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

```tsx
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**

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

<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:
```tsx
<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:
```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 && (
  <Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert>
)}
```

- [ ] **Step 4: Enrich instance table rows**

Update instance rows to show more columns:
```tsx
<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:
```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<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:
```tsx
{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**

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

<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:
```tsx
<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:
```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
<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**

```tsx
<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:
```tsx
<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**

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

```tsx
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**

```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 (
    <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**

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