docs: add historical implementation plans
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m7s
CI / docker (push) Successful in 37s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Has been cancelled

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-04 15:45:49 +02:00
parent c2d4d38bfb
commit 574f82b731
5 changed files with 3360 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,431 @@
# ClickHouse Phase 3: Stats & Analytics — 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:** Replace TimescaleDB continuous aggregates with ClickHouse materialized views and implement a `ClickHouseStatsStore` that reads from them using `-Merge` aggregate functions.
**Architecture:** 5 DDL scripts create AggregatingMergeTree target tables + materialized views that trigger on INSERT to `executions` and `processor_executions`. A `ClickHouseStatsStore` implements the existing `StatsStore` interface, translating `time_bucket()``toStartOfInterval()`, `SUM(total_count)``countMerge(total_count)`, `approx_percentile``quantileMerge`, etc. SLA and topErrors queries hit the raw `executions` / `processor_executions` tables with `FINAL`. Feature flag `cameleer.storage.stats=postgres|clickhouse` controls which implementation is active.
**Tech Stack:** ClickHouse 24.12, AggregatingMergeTree, `-State`/`-Merge` combinators, JdbcTemplate, Testcontainers
**Design Spec:** `docs/superpowers/specs/2026-03-31-clickhouse-migration-design.md` (Materialized Views + Stats Query Translation sections)
---
## File Structure
| File | Responsibility |
|------|----------------|
| `cameleer3-server-app/.../resources/clickhouse/V4__stats_tables_and_mvs.sql` | DDL for all 5 stats tables + 5 materialized views |
| `cameleer3-server-app/.../storage/ClickHouseStatsStore.java` | StatsStore impl using -Merge functions on AggregatingMergeTree tables |
| `cameleer3-server-app/.../config/StorageBeanConfig.java` | Modified: add CH stats store bean with feature flag |
| `cameleer3-server-app/.../storage/PostgresStatsStore.java` | Modified: add ConditionalOnProperty |
| `cameleer3-server-app/.../resources/application.yml` | Modified: add `cameleer.storage.stats` flag |
| `deploy/base/server.yaml` | Modified: add `CAMELEER_STORAGE_STATS` env var |
| `cameleer3-server-app/...test.../storage/ClickHouseStatsStoreIT.java` | Integration test for CH stats queries |
---
## Query Translation Reference
| TimescaleDB (PostgresStatsStore) | ClickHouse (ClickHouseStatsStore) |
|----------------------------------|-----------------------------------|
| `time_bucket(N * INTERVAL '1 second', bucket)` | `toStartOfInterval(bucket, INTERVAL N SECOND)` |
| `SUM(total_count)` | `countMerge(total_count)` |
| `SUM(failed_count)` | `countIfMerge(failed_count)` |
| `SUM(running_count)` | `countIfMerge(running_count)` |
| `SUM(duration_sum)` | `sumMerge(duration_sum)` |
| `MAX(p99_duration)` | `quantileMerge(0.99)(p99_duration)` |
| `MAX(duration_max)` | `maxMerge(duration_max)` |
| `SUM(duration_sum) / SUM(total_count)` | `sumMerge(duration_sum) / countMerge(total_count)` |
| `COUNT(*) FILTER (WHERE ...)` | `countIf(...)` |
| `EXTRACT(DOW FROM bucket)` | `toDayOfWeek(bucket, 1) % 7` (1=Mon in CH, shift to 0=Sun) |
| `EXTRACT(HOUR FROM bucket)` | `toHour(bucket)` |
| `LEFT(error_message, 200)` | `substring(error_message, 1, 200)` |
| `COUNT(DISTINCT ...)` | `uniq(...)` or `COUNT(DISTINCT ...)` |
---
### Task 1: DDL for Stats Tables and Materialized Views
**Files:**
- Create: `cameleer3-server-app/src/main/resources/clickhouse/V4__stats_tables_and_mvs.sql`
All 5 table+MV pairs in a single DDL file. Tables use `AggregatingMergeTree()`. MVs use `-State` combinators and trigger on INSERT to `executions` or `processor_executions`.
- [ ] **Step 1: Create the DDL file**
```sql
-- V4__stats_tables_and_mvs.sql
-- Materialized views replacing TimescaleDB continuous aggregates.
-- Tables use AggregatingMergeTree; MVs use -State combinators.
-- ── stats_1m_all (global) ────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS stats_1m_all (
tenant_id LowCardinality(String),
bucket DateTime,
total_count AggregateFunction(count, UInt64),
failed_count AggregateFunction(countIf, UInt64, UInt8),
running_count AggregateFunction(countIf, UInt64, UInt8),
duration_sum AggregateFunction(sum, Nullable(Int64)),
duration_max AggregateFunction(max, Nullable(Int64)),
p99_duration AggregateFunction(quantile(0.99), Nullable(Int64))
)
ENGINE = AggregatingMergeTree()
PARTITION BY (tenant_id, toYYYYMM(bucket))
ORDER BY (tenant_id, bucket)
TTL bucket + INTERVAL 365 DAY DELETE;
CREATE MATERIALIZED VIEW IF NOT EXISTS stats_1m_all_mv TO stats_1m_all AS
SELECT
tenant_id,
toStartOfMinute(start_time) AS bucket,
countState() AS total_count,
countIfState(status = 'FAILED') AS failed_count,
countIfState(status = 'RUNNING') AS running_count,
sumState(duration_ms) AS duration_sum,
maxState(duration_ms) AS duration_max,
quantileState(0.99)(duration_ms) AS p99_duration
FROM executions
GROUP BY tenant_id, bucket;
-- ── stats_1m_app (per-application) ───────────────────────────────────
CREATE TABLE IF NOT EXISTS stats_1m_app (
tenant_id LowCardinality(String),
application_name LowCardinality(String),
bucket DateTime,
total_count AggregateFunction(count, UInt64),
failed_count AggregateFunction(countIf, UInt64, UInt8),
running_count AggregateFunction(countIf, UInt64, UInt8),
duration_sum AggregateFunction(sum, Nullable(Int64)),
duration_max AggregateFunction(max, Nullable(Int64)),
p99_duration AggregateFunction(quantile(0.99), Nullable(Int64))
)
ENGINE = AggregatingMergeTree()
PARTITION BY (tenant_id, toYYYYMM(bucket))
ORDER BY (tenant_id, application_name, bucket)
TTL bucket + INTERVAL 365 DAY DELETE;
CREATE MATERIALIZED VIEW IF NOT EXISTS stats_1m_app_mv TO stats_1m_app AS
SELECT
tenant_id,
application_name,
toStartOfMinute(start_time) AS bucket,
countState() AS total_count,
countIfState(status = 'FAILED') AS failed_count,
countIfState(status = 'RUNNING') AS running_count,
sumState(duration_ms) AS duration_sum,
maxState(duration_ms) AS duration_max,
quantileState(0.99)(duration_ms) AS p99_duration
FROM executions
GROUP BY tenant_id, application_name, bucket;
-- ── stats_1m_route (per-route) ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS stats_1m_route (
tenant_id LowCardinality(String),
application_name LowCardinality(String),
route_id LowCardinality(String),
bucket DateTime,
total_count AggregateFunction(count, UInt64),
failed_count AggregateFunction(countIf, UInt64, UInt8),
running_count AggregateFunction(countIf, UInt64, UInt8),
duration_sum AggregateFunction(sum, Nullable(Int64)),
duration_max AggregateFunction(max, Nullable(Int64)),
p99_duration AggregateFunction(quantile(0.99), Nullable(Int64))
)
ENGINE = AggregatingMergeTree()
PARTITION BY (tenant_id, toYYYYMM(bucket))
ORDER BY (tenant_id, application_name, route_id, bucket)
TTL bucket + INTERVAL 365 DAY DELETE;
CREATE MATERIALIZED VIEW IF NOT EXISTS stats_1m_route_mv TO stats_1m_route AS
SELECT
tenant_id,
application_name,
route_id,
toStartOfMinute(start_time) AS bucket,
countState() AS total_count,
countIfState(status = 'FAILED') AS failed_count,
countIfState(status = 'RUNNING') AS running_count,
sumState(duration_ms) AS duration_sum,
maxState(duration_ms) AS duration_max,
quantileState(0.99)(duration_ms) AS p99_duration
FROM executions
GROUP BY tenant_id, application_name, route_id, bucket;
-- ── stats_1m_processor (per-processor-type) ──────────────────────────
CREATE TABLE IF NOT EXISTS stats_1m_processor (
tenant_id LowCardinality(String),
application_name LowCardinality(String),
processor_type LowCardinality(String),
bucket DateTime,
total_count AggregateFunction(count, UInt64),
failed_count AggregateFunction(countIf, UInt64, UInt8),
duration_sum AggregateFunction(sum, Nullable(Int64)),
duration_max AggregateFunction(max, Nullable(Int64)),
p99_duration AggregateFunction(quantile(0.99), Nullable(Int64))
)
ENGINE = AggregatingMergeTree()
PARTITION BY (tenant_id, toYYYYMM(bucket))
ORDER BY (tenant_id, application_name, processor_type, bucket)
TTL bucket + INTERVAL 365 DAY DELETE;
CREATE MATERIALIZED VIEW IF NOT EXISTS stats_1m_processor_mv TO stats_1m_processor AS
SELECT
tenant_id,
application_name,
processor_type,
toStartOfMinute(start_time) AS bucket,
countState() AS total_count,
countIfState(status = 'FAILED') AS failed_count,
sumState(duration_ms) AS duration_sum,
maxState(duration_ms) AS duration_max,
quantileState(0.99)(duration_ms) AS p99_duration
FROM processor_executions
GROUP BY tenant_id, application_name, processor_type, bucket;
-- ── stats_1m_processor_detail (per-processor-id) ─────────────────────
CREATE TABLE IF NOT EXISTS stats_1m_processor_detail (
tenant_id LowCardinality(String),
application_name LowCardinality(String),
route_id LowCardinality(String),
processor_id String,
bucket DateTime,
total_count AggregateFunction(count, UInt64),
failed_count AggregateFunction(countIf, UInt64, UInt8),
duration_sum AggregateFunction(sum, Nullable(Int64)),
duration_max AggregateFunction(max, Nullable(Int64)),
p99_duration AggregateFunction(quantile(0.99), Nullable(Int64))
)
ENGINE = AggregatingMergeTree()
PARTITION BY (tenant_id, toYYYYMM(bucket))
ORDER BY (tenant_id, application_name, route_id, processor_id, bucket)
TTL bucket + INTERVAL 365 DAY DELETE;
CREATE MATERIALIZED VIEW IF NOT EXISTS stats_1m_processor_detail_mv TO stats_1m_processor_detail AS
SELECT
tenant_id,
application_name,
route_id,
processor_id,
toStartOfMinute(start_time) AS bucket,
countState() AS total_count,
countIfState(status = 'FAILED') AS failed_count,
sumState(duration_ms) AS duration_sum,
maxState(duration_ms) AS duration_max,
quantileState(0.99)(duration_ms) AS p99_duration
FROM processor_executions
GROUP BY tenant_id, application_name, route_id, processor_id, bucket;
```
Note: The `ClickHouseSchemaInitializer` runs each `.sql` file as a single statement. ClickHouse supports multiple statements separated by `;` in a single call, BUT the JDBC driver may not. If the initializer fails, each CREATE statement may need to be in its own file. Check during testing.
**IMPORTANT**: The ClickHouseSchemaInitializer needs to handle multi-statement files. Read it first — if it uses `jdbc.execute(sql)` for each file, the semicolons between statements will cause issues. If so, split into separate files (V4a, V4b, etc.) or modify the initializer to split on `;`.
- [ ] **Step 2: Check ClickHouseSchemaInitializer handles multi-statement**
Read `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/ClickHouseSchemaInitializer.java`. If it runs each file as a single `jdbc.execute()`, modify it to split on `;` and run each statement separately. If it already handles this, proceed.
- [ ] **Step 3: Verify DDL loads in Testcontainers**
Write a quick smoke test or manually verify that all 10 objects (5 tables + 5 MVs) are created:
```bash
mvn clean compile -pl cameleer3-server-app -f pom.xml
```
- [ ] **Step 4: Commit**
```bash
git add cameleer3-server-app/src/main/resources/clickhouse/V4__stats_tables_and_mvs.sql
# also add ClickHouseSchemaInitializer if modified
git commit -m "feat(clickhouse): add stats materialized views DDL (5 tables + 5 MVs)"
```
---
### Task 2: ClickHouseStatsStore — Aggregate Queries
**Files:**
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/ClickHouseStatsStoreIT.java`
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseStatsStore.java`
The store implements `StatsStore` using ClickHouse `-Merge` functions. It follows the same pattern as `PostgresStatsStore` but with ClickHouse SQL syntax.
**Key implementation patterns:**
1. **Stats queries** (queryStats): Read from `stats_1m_*` tables using `-Merge` combinators:
```sql
SELECT
countMerge(total_count) AS total_count,
countIfMerge(failed_count) AS failed_count,
CASE WHEN countMerge(total_count) > 0
THEN sumMerge(duration_sum) / countMerge(total_count) ELSE 0 END AS avg_duration,
quantileMerge(0.99)(p99_duration) AS p99_duration,
countIfMerge(running_count) AS active_count
FROM stats_1m_all
WHERE tenant_id = 'default' AND bucket >= ? AND bucket < ?
```
Same pattern for prev-24h and today queries (identical to PostgresStatsStore logic).
2. **Timeseries queries** (queryTimeseries): Group by time period:
```sql
SELECT
toStartOfInterval(bucket, INTERVAL ? SECOND) AS period,
countMerge(total_count) AS total_count,
countIfMerge(failed_count) AS failed_count,
CASE WHEN countMerge(total_count) > 0
THEN sumMerge(duration_sum) / countMerge(total_count) ELSE 0 END AS avg_duration,
quantileMerge(0.99)(p99_duration) AS p99_duration,
countIfMerge(running_count) AS active_count
FROM stats_1m_app
WHERE tenant_id = 'default' AND bucket >= ? AND bucket < ? AND application_name = ?
GROUP BY period ORDER BY period
```
3. **Grouped timeseries**: Same as timeseries but with extra GROUP BY column (application_name or route_id), returned as `Map<String, StatsTimeseries>`.
4. **SLA compliance**: Hit raw `executions FINAL` table:
```sql
SELECT
countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant,
countIf(status != 'RUNNING') AS total
FROM executions FINAL
WHERE tenant_id = 'default' AND start_time >= ? AND start_time < ?
AND application_name = ?
```
5. **SLA counts by app/route**: Same pattern with GROUP BY.
6. **Top errors**: Hit raw `executions FINAL` or `processor_executions` table with CTE for counts + velocity. ClickHouse differences:
- No `FILTER (WHERE ...)` → use `countIf(...)`
- No `LEFT(s, n)` → use `substring(s, 1, n)`
- CTE syntax is identical (`WITH ... AS (...)`)
7. **Active error types**: `SELECT uniq(...)` or `COUNT(DISTINCT ...)` from raw executions.
8. **Punchcard**: ClickHouse day-of-week: `toDayOfWeek(bucket, 1)` returns 1=Mon..7=Sun. PG `EXTRACT(DOW)` returns 0=Sun..6=Sat. Conversion: `toDayOfWeek(bucket, 1) % 7` gives 0=Sun..6=Sat.
**Constructor**: Takes `@Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc`.
**Test approach**: Seed data by inserting directly into `executions` and `processor_executions` tables (the MVs trigger automatically on INSERT). Then query via the StatsStore methods and verify results.
**Test data seeding**: Insert 10 executions across 2 apps, 3 routes, spanning 10 minutes. Include some FAILED, some COMPLETED, varying durations. Then verify:
- `stats()` returns correct totals
- `statsForApp()` filters correctly
- `timeseries()` returns multiple buckets
- `slaCompliance()` returns correct percentage
- `topErrors()` returns ranked errors
- `punchcard()` returns non-empty cells
- [ ] **Step 1: Write the failing integration test**
Create `ClickHouseStatsStoreIT.java` with:
- Load all 4 DDL files (V2 executions, V3 processor_executions, V4 stats MVs)
- Seed 10+ executions and 20+ processor records across 2 apps, 3 routes, 10 minutes
- Test: `stats_returnsCorrectTotals`, `statsForApp_filtersCorrectly`, `timeseries_returnsBuckets`, `timeseriesGroupedByApp_returnsMap`, `slaCompliance_calculatesCorrectly`, `topErrors_returnsRankedErrors`, `activeErrorTypes_countsDistinct`, `punchcard_returnsNonEmpty`, `slaCountsByApp_returnsMap`
- [ ] **Step 2: Run test to verify it fails**
```bash
mvn test -pl cameleer3-server-app -Dtest=ClickHouseStatsStoreIT -Dfailsafe.provider=surefire -DfailIfNoTests=false -f pom.xml
```
- [ ] **Step 3: Implement ClickHouseStatsStore**
Follow the `PostgresStatsStore` structure closely. Same private `Filter` record, same `queryStats`/`queryTimeseries`/`queryGroupedTimeseries` helper methods. Replace PG-specific SQL with CH equivalents per the translation table above.
- [ ] **Step 4: Run test to verify it passes**
```bash
mvn test -pl cameleer3-server-app -Dtest=ClickHouseStatsStoreIT -Dfailsafe.provider=surefire -f pom.xml
```
- [ ] **Step 5: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseStatsStore.java \
cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/ClickHouseStatsStoreIT.java
git commit -m "feat(clickhouse): add ClickHouseStatsStore with -Merge aggregate queries"
```
---
### Task 3: Feature Flag Wiring
**Files:**
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/StorageBeanConfig.java`
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresStatsStore.java`
- Modify: `cameleer3-server-app/src/main/resources/application.yml`
- Modify: `deploy/base/server.yaml`
- [ ] **Step 1: Add ConditionalOnProperty to PostgresStatsStore**
```java
@Repository
@ConditionalOnProperty(name = "cameleer.storage.stats", havingValue = "postgres", matchIfMissing = true)
public class PostgresStatsStore implements StatsStore {
```
- [ ] **Step 2: Add CH StatsStore bean to StorageBeanConfig**
```java
@Bean
@ConditionalOnProperty(name = "cameleer.storage.stats", havingValue = "clickhouse")
public StatsStore clickHouseStatsStore(
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
return new ClickHouseStatsStore(clickHouseJdbc);
}
```
- [ ] **Step 3: Update application.yml**
Add under `cameleer.storage`:
```yaml
stats: ${CAMELEER_STORAGE_STATS:postgres}
```
- [ ] **Step 4: Update deploy/base/server.yaml**
Add env var:
```yaml
- name: CAMELEER_STORAGE_STATS
value: "postgres"
```
- [ ] **Step 5: Compile and verify all tests pass**
```bash
mvn clean verify -DskipITs -f pom.xml
```
- [ ] **Step 6: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/StorageBeanConfig.java \
cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresStatsStore.java \
cameleer3-server-app/src/main/resources/application.yml \
deploy/base/server.yaml
git commit -m "feat(clickhouse): wire ClickHouseStatsStore with cameleer.storage.stats feature flag"
```
---
## Verification Checklist
After all tasks are complete, verify:
1. **MVs trigger**: Insert a row into `executions`, verify `stats_1m_all` has a row
2. **Aggregate correctness**: Insert known data, verify countMerge/sumMerge/quantileMerge produce correct values
3. **Timeseries bucketing**: Verify `toStartOfInterval` groups correctly across time ranges
4. **SLA compliance**: Verify percentage calculation against raw data
5. **Top errors**: Verify ranking and velocity trend detection
6. **Punchcard**: Verify weekday/hour mapping (0=Sun..6=Sat convention)
7. **Feature flag**: `cameleer.storage.stats=postgres` uses PG, `=clickhouse` uses CH
8. **Backward compat**: With default config, everything uses PG
9. **CI**: `mvn clean verify -DskipITs` passes

View File

@@ -0,0 +1,244 @@
# ClickHouse Phase 4: Remaining Tables — 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:** Migrate route diagrams, agent events, and application logs from PostgreSQL/OpenSearch to ClickHouse.
**Architecture:** Three new ClickHouse stores implement existing interfaces. `ClickHouseDiagramStore` uses ReplacingMergeTree for content-hash dedup. `ClickHouseAgentEventRepository` uses MergeTree for append-only events. `ClickHouseLogStore` replaces `OpenSearchLogIndex` with SQL + ngram indexes. Feature flags control each store independently.
**Tech Stack:** ClickHouse 24.12, JdbcTemplate, Testcontainers
**Design Spec:** `docs/superpowers/specs/2026-03-31-clickhouse-migration-design.md`
---
## File Structure
| File | Responsibility |
|------|----------------|
| `cameleer3-server-app/.../resources/clickhouse/V5__route_diagrams.sql` | DDL for `route_diagrams` (ReplacingMergeTree) |
| `cameleer3-server-app/.../resources/clickhouse/V6__agent_events.sql` | DDL for `agent_events` (MergeTree) |
| `cameleer3-server-app/.../resources/clickhouse/V7__logs.sql` | DDL for `logs` (MergeTree with ngram indexes) |
| `cameleer3-server-app/.../storage/ClickHouseDiagramStore.java` | DiagramStore impl for ClickHouse |
| `cameleer3-server-app/.../storage/ClickHouseAgentEventRepository.java` | AgentEventRepository impl for ClickHouse |
| `cameleer3-server-app/.../search/ClickHouseLogStore.java` | Replaces OpenSearchLogIndex |
| `cameleer3-server-app/.../config/StorageBeanConfig.java` | Modified: add CH beans with feature flags |
| `cameleer3-server-app/.../storage/PostgresDiagramStore.java` | Modified: add ConditionalOnProperty |
| `cameleer3-server-app/.../storage/PostgresAgentEventRepository.java` | Modified: add ConditionalOnProperty |
| `cameleer3-server-app/.../search/OpenSearchLogIndex.java` | Modified: add ConditionalOnProperty |
| `cameleer3-server-app/.../resources/application.yml` | Modified: add feature flags |
| `deploy/base/server.yaml` | Modified: add env vars |
---
### Task 1: DDL Scripts
**Files:**
- Create: `cameleer3-server-app/src/main/resources/clickhouse/V5__route_diagrams.sql`
- Create: `cameleer3-server-app/src/main/resources/clickhouse/V6__agent_events.sql`
- Create: `cameleer3-server-app/src/main/resources/clickhouse/V7__logs.sql`
- [ ] **Step 1: Create route_diagrams DDL**
```sql
CREATE TABLE IF NOT EXISTS route_diagrams (
tenant_id LowCardinality(String) DEFAULT 'default',
content_hash String,
route_id LowCardinality(String),
agent_id LowCardinality(String),
application_name LowCardinality(String),
definition String,
created_at DateTime64(3) DEFAULT now64(3)
)
ENGINE = ReplacingMergeTree(created_at)
ORDER BY (tenant_id, content_hash)
SETTINGS index_granularity = 8192
```
- [ ] **Step 2: Create agent_events DDL**
```sql
CREATE TABLE IF NOT EXISTS agent_events (
tenant_id LowCardinality(String) DEFAULT 'default',
timestamp DateTime64(3) DEFAULT now64(3),
agent_id LowCardinality(String),
app_id LowCardinality(String),
event_type LowCardinality(String),
detail String DEFAULT ''
)
ENGINE = MergeTree()
PARTITION BY (tenant_id, toYYYYMM(timestamp))
ORDER BY (tenant_id, app_id, agent_id, timestamp)
TTL toDateTime(timestamp) + INTERVAL 365 DAY DELETE
```
- [ ] **Step 3: Create logs DDL**
```sql
CREATE TABLE IF NOT EXISTS logs (
tenant_id LowCardinality(String) DEFAULT 'default',
timestamp DateTime64(3),
application LowCardinality(String),
agent_id LowCardinality(String),
level LowCardinality(String),
logger_name LowCardinality(String) DEFAULT '',
message String,
thread_name LowCardinality(String) DEFAULT '',
stack_trace String DEFAULT '',
exchange_id String DEFAULT '',
mdc Map(String, String) DEFAULT map(),
INDEX idx_msg message TYPE ngrambf_v1(3, 256, 2, 0) GRANULARITY 4,
INDEX idx_stack stack_trace TYPE ngrambf_v1(3, 256, 2, 0) GRANULARITY 4,
INDEX idx_level level TYPE set(10) GRANULARITY 1
)
ENGINE = MergeTree()
PARTITION BY (tenant_id, toYYYYMM(timestamp))
ORDER BY (tenant_id, application, timestamp)
TTL toDateTime(timestamp) + INTERVAL 365 DAY DELETE
SETTINGS index_granularity = 8192
```
- [ ] **Step 4: Compile and commit**
```bash
mvn clean compile -pl cameleer3-server-app -f pom.xml
git commit -m "feat(clickhouse): add DDL for route_diagrams, agent_events, and logs tables"
```
---
### Task 2: ClickHouseDiagramStore
**Files:**
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseDiagramStore.java`
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/ClickHouseDiagramStoreIT.java`
Implements `DiagramStore` interface (5 methods). Read `PostgresDiagramStore.java` first and translate.
**Key differences from PG:**
- `INSERT INTO ... ON CONFLICT DO NOTHING` → just `INSERT INTO` (ReplacingMergeTree deduplicates by content_hash)
- `?::jsonb` → plain `?` (CH stores definition as String, not JSONB)
- `ORDER BY created_at DESC LIMIT 1``ORDER BY created_at DESC LIMIT 1` (same, but add `FINAL` for ReplacingMergeTree reads)
- `findProcessorRouteMapping`: PG uses `jsonb_array_elements()` — CH has no native JSON array functions. Instead, store the definition as a string and parse in Java, OR query `route_diagrams FINAL` and deserialize definitions application-side. **Recommended:** Fetch all definitions for the application, deserialize in Java, extract processor→route mappings. This is a small result set (one row per route).
- SHA-256 content hash computation stays in Java (same as PG store)
- Add `WHERE tenant_id = 'default'` to all queries
**Tests:**
- `store_insertsNewDiagram`
- `store_duplicateHashIgnored` (ReplacingMergeTree dedup after OPTIMIZE FINAL)
- `findByContentHash_returnsGraph`
- `findContentHashForRoute_returnsMostRecent`
- `findProcessorRouteMapping_extractsMapping`
---
### Task 3: ClickHouseAgentEventRepository
**Files:**
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseAgentEventRepository.java`
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/ClickHouseAgentEventRepositoryIT.java`
Implements `AgentEventRepository` interface (2 methods: `insert` + `query`).
**Key differences from PG:**
- No `BIGSERIAL id` — CH doesn't have auto-increment. `AgentEventRecord` has `long id` but set to 0 for CH rows.
- `INSERT INTO agent_events (tenant_id, agent_id, app_id, event_type, detail) VALUES (?, ?, ?, ?, ?)`
- `query` builds dynamic WHERE (same pattern as PG) with `ORDER BY timestamp DESC LIMIT ?`
- Add `WHERE tenant_id = 'default'`
**Tests:**
- `insert_writesEvent`
- `query_byAppId_filtersCorrectly`
- `query_byTimeRange_filtersCorrectly`
- `query_respectsLimit`
---
### Task 4: ClickHouseLogStore
**Files:**
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseLogStore.java`
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/app/search/ClickHouseLogStoreIT.java`
Replaces `OpenSearchLogIndex`. Must have the same public API:
- `search(application, agentId, level, query, exchangeId, from, to, limit)` → returns `List<LogEntryResponse>`
- `indexBatch(agentId, application, List<LogEntry> entries)` → batch INSERT into `logs`
**Key implementation:**
`indexBatch`: Batch INSERT with `Map(String, String)` for MDC column. Extract `camel.exchangeId` from MDC into top-level `exchange_id` column.
```sql
INSERT INTO logs (tenant_id, timestamp, application, agent_id, level, logger_name,
message, thread_name, stack_trace, exchange_id, mdc)
VALUES ('default', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
```
MCE Map type: pass as `java.util.HashMap` — ClickHouse JDBC 0.9.7 supports native Map type (same as ClickHouseMetricsStore uses for tags).
`search`: Build WHERE clause from params. Use `LIKE '%query%'` for message text search (ngram-accelerated). Return `LogEntryResponse` records.
```sql
SELECT timestamp, level, logger_name, message, thread_name, stack_trace
FROM logs
WHERE tenant_id = 'default' AND application = ?
[AND agent_id = ?]
[AND level = ?]
[AND (exchange_id = ? OR mdc['camel.exchangeId'] = ?)]
[AND message LIKE ?]
[AND timestamp >= ?]
[AND timestamp <= ?]
ORDER BY timestamp DESC
LIMIT ?
```
**Tests:**
- `indexBatch_writesLogs`
- `search_byApplication_returnsLogs`
- `search_byLevel_filtersCorrectly`
- `search_byQuery_usesLikeSearch`
- `search_byExchangeId_matchesTopLevelAndMdc`
- `search_byTimeRange_filtersCorrectly`
- `indexBatch_storesMdc`
---
### Task 5: Feature Flag Wiring
**Files to modify:**
- `PostgresDiagramStore.java` — add `@ConditionalOnProperty(name = "cameleer.storage.diagrams", havingValue = "postgres")`
- `PostgresAgentEventRepository.java` — add `@ConditionalOnProperty(name = "cameleer.storage.events", havingValue = "postgres")`
- `OpenSearchLogIndex.java` — add `@ConditionalOnProperty(name = "cameleer.storage.logs", havingValue = "opensearch")`
- `StorageBeanConfig.java` — add CH diagram, event, and log store beans (all default to clickhouse with `matchIfMissing = true`)
- `application.yml` — add `diagrams`, `events`, `logs` flags under `cameleer.storage`
- `deploy/base/server.yaml` — add env vars
**Feature flags (all default to clickhouse):**
```yaml
cameleer:
storage:
metrics: ${CAMELEER_STORAGE_METRICS:postgres}
search: ${CAMELEER_STORAGE_SEARCH:opensearch}
stats: ${CAMELEER_STORAGE_STATS:clickhouse}
diagrams: ${CAMELEER_STORAGE_DIAGRAMS:clickhouse}
events: ${CAMELEER_STORAGE_EVENTS:clickhouse}
logs: ${CAMELEER_STORAGE_LOGS:clickhouse}
```
**Important for LogStore wiring:** The `OpenSearchLogIndex` is a `@Repository` used directly by controllers (not via an interface). The `ClickHouseLogStore` must be injectable in the same way. Options:
- Extract a `LogIndex` interface with `search()` + `indexBatch()` methods, used by both controllers
- Or make `ClickHouseLogStore` extend/implement the same type
**Recommended:** Create a `LogIndex` interface in the core module with the two methods, have both `OpenSearchLogIndex` and `ClickHouseLogStore` implement it, and update `LogIngestionController` + `LogQueryController` to inject `LogIndex` instead of `OpenSearchLogIndex`.
---
## Verification Checklist
1. **Diagrams**: Store + retrieve RouteGraph via ClickHouse, verify content-hash dedup
2. **Events**: Insert + query events with time range and app/agent filters
3. **Logs**: Batch insert + search with all filter types (level, query, exchangeId, time range)
4. **Feature flags**: Each store independently switchable between PG/OS and CH
5. **Backward compat**: Default config uses ClickHouse for all Phase 4 stores
6. **CI**: `mvn clean verify -DskipITs` passes

View File

@@ -0,0 +1,285 @@
# Admin Page Context Separation 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:** Hide the operational sidebar and TopBar on admin pages, replacing them with a minimal admin header bar containing only a back button and logout.
**Architecture:** Conditionally skip `<Sidebar>`, `<TopBar>`, and `<CommandPalette>` in `LayoutShell` when `isAdminPage` is true. Add a self-contained admin header bar inside `AdminLayout` with back navigation and user/logout. No design system changes needed — `AppShell` already handles `null` sidebar gracefully.
**Tech Stack:** React 19, react-router, lucide-react icons, CSS Modules, `@cameleer/design-system` CSS variables
---
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `ui/src/components/LayoutShell.tsx` | Modify | Conditionally skip Sidebar, TopBar, CommandPalette on admin pages |
| `ui/src/pages/Admin/AdminLayout.tsx` | Modify | Add slim admin header bar with back button and user/logout |
| `ui/src/pages/Admin/AdminLayout.module.css` | Create | Styles for admin header bar |
---
### Task 1: Modify LayoutShell to Skip Sidebar/TopBar on Admin Pages
**Files:**
- Modify: `ui/src/components/LayoutShell.tsx`
- [ ] **Step 1: Update `AppShell` sidebar prop to be conditional**
In `LayoutShell.tsx`, find the `return` statement in `LayoutContent` (around line 296). Change the `<AppShell>` to conditionally pass `null` as sidebar, and conditionally render TopBar and CommandPalette:
```tsx
return (
<AppShell
sidebar={
isAdminPage ? null : <Sidebar apps={sidebarApps} onNavigate={handleSidebarNavigate} />
}
>
{!isAdminPage && (
<TopBar
breadcrumb={breadcrumb}
user={username ? { name: username } : undefined}
onLogout={handleLogout}
/>
)}
{!isAdminPage && (
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onOpen={() => setPaletteOpen(true)}
onSelect={handlePaletteSelect}
onSubmit={handlePaletteSubmit}
onQueryChange={setPaletteQuery}
data={searchData}
/>
)}
{!isAdminPage && (
<ContentTabs active={scope.tab} onChange={setTab} scope={scope} />
)}
<main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0, padding: isAdminPage ? 0 : 0 }}>
<Outlet />
</main>
</AppShell>
);
```
Note: The existing `isAdminPage` guard on `ContentTabs` (line 317) and the padding ternary on `<main>` (line 321) should be updated. Admin padding moves into `AdminLayout` itself, so set `padding: 0` for admin in the main tag.
- [ ] **Step 2: Verify it compiles**
Run: `cd ui && npx tsc --noEmit`
Expected: No type errors.
- [ ] **Step 3: Commit**
```bash
git add ui/src/components/LayoutShell.tsx
git commit -m "feat(#112): hide sidebar, topbar, cmd palette on admin pages"
```
---
### Task 2: Create Admin Header Bar Styles
**Files:**
- Create: `ui/src/pages/Admin/AdminLayout.module.css`
- [ ] **Step 1: Create the CSS module**
```css
.header {
display: flex;
align-items: center;
height: 48px;
padding: 0 16px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
}
.backBtn {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: none;
color: var(--text-secondary);
font-size: 13px;
font-family: var(--font-body);
cursor: pointer;
padding: 6px 10px;
border-radius: var(--radius-sm);
}
.backBtn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.title {
flex: 1;
text-align: center;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--text-muted);
text-transform: uppercase;
}
.userSection {
display: flex;
align-items: center;
gap: 12px;
}
.username {
font-size: 13px;
color: var(--text-secondary);
font-family: var(--font-body);
}
.logoutBtn {
background: none;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 4px 12px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
font-family: var(--font-body);
}
.logoutBtn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.content {
padding: 20px 24px 40px;
}
```
- [ ] **Step 2: Commit**
```bash
git add ui/src/pages/Admin/AdminLayout.module.css
git commit -m "feat(#112): add admin header bar styles"
```
---
### Task 3: Add Admin Header Bar to AdminLayout
**Files:**
- Modify: `ui/src/pages/Admin/AdminLayout.tsx`
- [ ] **Step 1: Rewrite AdminLayout with the header bar**
Replace the full contents of `AdminLayout.tsx`:
```tsx
import { Outlet, useNavigate, useLocation } from 'react-router';
import { Tabs } from '@cameleer/design-system';
import { ArrowLeft, LogOut } from 'lucide-react';
import { useAuthStore } from '../../auth/auth-store';
import styles from './AdminLayout.module.css';
const ADMIN_TABS = [
{ label: 'User Management', value: '/admin/rbac' },
{ label: 'Audit Log', value: '/admin/audit' },
{ label: 'OIDC', value: '/admin/oidc' },
{ label: 'App Config', value: '/admin/appconfig' },
{ label: 'Database', value: '/admin/database' },
{ label: 'ClickHouse', value: '/admin/clickhouse' },
];
export default function AdminLayout() {
const navigate = useNavigate();
const location = useLocation();
const { username, logout } = useAuthStore();
const handleBack = () => navigate('/exchanges');
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<div>
<header className={styles.header}>
<button className={styles.backBtn} onClick={handleBack}>
<ArrowLeft size={16} />
Back
</button>
<span className={styles.title}>Admin</span>
<div className={styles.userSection}>
<span className={styles.username}>{username}</span>
<button className={styles.logoutBtn} onClick={handleLogout}>
<LogOut size={14} />
</button>
</div>
</header>
<Tabs
tabs={ADMIN_TABS}
active={location.pathname}
onChange={(path) => navigate(path)}
/>
<div className={styles.content}>
<Outlet />
</div>
</div>
);
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd ui && npx tsc --noEmit`
Expected: No type errors.
- [ ] **Step 3: Commit**
```bash
git add ui/src/pages/Admin/AdminLayout.tsx
git commit -m "feat(#112): add admin header bar with back button and logout"
```
---
### Task 4: Visual Verification
- [ ] **Step 1: Start the dev server and verify**
Run: `cd ui && npm run dev`
Open `http://localhost:5173/admin/rbac` and verify:
1. No sidebar visible — content spans full viewport width
2. No TopBar (no breadcrumb, no search trigger, no status filters, no time range)
3. Admin header bar visible at top with: `[<- Back]` left, `ADMIN` center, `[username] [logout]` right
4. Admin tabs (User Management, Audit Log, etc.) below the header bar
5. Admin content renders correctly below tabs
- [ ] **Step 2: Verify operational pages are unaffected**
Navigate to `http://localhost:5173/exchanges` and verify:
1. Sidebar renders normally with app/agent/route trees
2. TopBar renders with breadcrumb, search, filters, time range
3. ContentTabs show (Exchanges, Dashboard, Runtime, Logs)
4. Command palette works (Ctrl+K / Cmd+K)
- [ ] **Step 3: Verify back button**
From any admin page, click "Back" — should navigate to `/exchanges`.
- [ ] **Step 4: Verify logout**
Click logout icon in admin header — should navigate to `/login`.
- [ ] **Step 5: Final commit if any fixes were needed**
```bash
git add -A
git commit -m "fix(#112): admin layout adjustments from visual review"
```

View File

@@ -0,0 +1,572 @@
# Composable Sidebar Migration 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:** Migrate the server UI from the old monolithic `<Sidebar apps={[...]}/>` to the new composable compound Sidebar API from `@cameleer/design-system` v0.1.23, adding admin accordion behavior and icon-rail collapse.
**Architecture:** Extract tree-building logic into a local `sidebar-utils.ts`, rewrite sidebar composition in `LayoutShell.tsx` using `Sidebar.Header/Section/Footer` compound components, add admin accordion behavior via route-based section state management, and simplify `AdminLayout.tsx` by removing its tab navigation (sidebar handles it now).
**Tech Stack:** React 19, `@cameleer/design-system` v0.1.23, react-router, lucide-react, CSS Modules
---
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `ui/src/components/sidebar-utils.ts` | Create | Tree-building functions, SidebarApp type, formatCount, admin node builder |
| `ui/src/components/LayoutShell.tsx` | Modify | Rewrite sidebar composition with compound API, add accordion + collapse |
| `ui/src/pages/Admin/AdminLayout.tsx` | Modify | Remove tab navigation (sidebar handles it), keep content wrapper |
---
### Task 1: Create sidebar-utils.ts with Tree-Building Functions
**Files:**
- Create: `ui/src/components/sidebar-utils.ts`
- [ ] **Step 1: Create the utility file**
This file contains the `SidebarApp` type (moved from DS), tree-building functions, and the admin node builder. These were previously inside the DS's monolithic Sidebar component.
```typescript
import type { ReactNode } from 'react';
import type { SidebarTreeNode } from '@cameleer/design-system';
// ── Domain types (moved from DS) ──────────────────────────────────────────
export interface SidebarRoute {
id: string;
name: string;
exchangeCount: number;
}
export interface SidebarAgent {
id: string;
name: string;
status: 'live' | 'stale' | 'dead';
tps: number;
}
export interface SidebarApp {
id: string;
name: string;
health: 'live' | 'stale' | 'dead';
exchangeCount: number;
routes: SidebarRoute[];
agents: SidebarAgent[];
}
// ── Helpers ───────────────────────────────────────────────────────────────
export function formatCount(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
// ── Tree node builders ────────────────────────────────────────────────────
export function buildAppTreeNodes(
apps: SidebarApp[],
statusDot: (health: string) => ReactNode,
chevron: ReactNode,
): SidebarTreeNode[] {
return apps.map((app) => ({
id: app.id,
label: app.name,
icon: statusDot(app.health),
badge: formatCount(app.exchangeCount),
path: `/apps/${app.id}`,
starrable: true,
starKey: `app:${app.id}`,
children: app.routes.map((route) => ({
id: `${app.id}/${route.id}`,
label: route.name,
icon: chevron,
badge: formatCount(route.exchangeCount),
path: `/apps/${app.id}/${route.id}`,
})),
}));
}
export function buildAgentTreeNodes(
apps: SidebarApp[],
statusDot: (health: string) => ReactNode,
): SidebarTreeNode[] {
return apps
.filter((app) => app.agents.length > 0)
.map((app) => {
const liveCount = app.agents.filter((a) => a.status === 'live').length;
return {
id: `agents:${app.id}`,
label: app.name,
icon: statusDot(app.health),
badge: `${liveCount}/${app.agents.length} live`,
path: `/agents/${app.id}`,
starrable: true,
starKey: `agent:${app.id}`,
children: app.agents.map((agent) => ({
id: `agents:${app.id}/${agent.id}`,
label: agent.name,
icon: statusDot(agent.status),
badge: `${agent.tps.toFixed(1)}/s`,
path: `/agents/${app.id}/${agent.id}`,
})),
};
});
}
export function buildRouteTreeNodes(
apps: SidebarApp[],
statusDot: (health: string) => ReactNode,
chevron: ReactNode,
): SidebarTreeNode[] {
return apps
.filter((app) => app.routes.length > 0)
.map((app) => ({
id: `routes:${app.id}`,
label: app.name,
icon: statusDot(app.health),
badge: `${app.routes.length} route${app.routes.length !== 1 ? 's' : ''}`,
path: `/routes/${app.id}`,
starrable: true,
starKey: `routestat:${app.id}`,
children: app.routes.map((route) => ({
id: `routes:${app.id}/${route.id}`,
label: route.name,
icon: chevron,
badge: formatCount(route.exchangeCount),
path: `/routes/${app.id}/${route.id}`,
})),
}));
}
export function buildAdminTreeNodes(): SidebarTreeNode[] {
return [
{ id: 'admin:rbac', label: 'User Management', path: '/admin/rbac' },
{ id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' },
{ id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' },
{ id: 'admin:appconfig', label: 'App Config', path: '/admin/appconfig' },
{ id: 'admin:database', label: 'Database', path: '/admin/database' },
{ id: 'admin:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' },
];
}
// ── localStorage-backed section collapse ──────────────────────────────────
export function readCollapsed(key: string, defaultValue: boolean): boolean {
try {
const raw = localStorage.getItem(key);
if (raw !== null) return raw === 'true';
} catch { /* ignore */ }
return defaultValue;
}
export function writeCollapsed(key: string, value: boolean): void {
try {
localStorage.setItem(key, String(value));
} catch { /* ignore */ }
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd C:/Users/Hendrik/Documents/projects/cameleer3-server/ui && npx tsc --project tsconfig.app.json --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add ui/src/components/sidebar-utils.ts
git commit -m "feat(#112): extract sidebar tree builders and types from DS"
```
---
### Task 2: Rewrite LayoutShell with Composable Sidebar
**Files:**
- Modify: `ui/src/components/LayoutShell.tsx`
This is the main migration task. The `LayoutContent` function gets a significant rewrite of its sidebar composition while preserving all existing TopBar, CommandPalette, ContentTabs, breadcrumb, and scope logic.
- [ ] **Step 1: Update imports**
Replace the old DS imports and add new ones. In `LayoutShell.tsx`, change the first 10 lines to:
```typescript
import { Outlet, useNavigate, useLocation } from 'react-router';
import { AppShell, Sidebar, SidebarTree, useStarred, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, BreadcrumbProvider, useCommandPalette, useGlobalFilters, StatusDot } from '@cameleer/design-system';
import type { SidebarTreeNode, SearchResult } from '@cameleer/design-system';
import { Box, Cpu, GitBranch, Settings, FileText, ChevronRight } from 'lucide-react';
import { useRouteCatalog } from '../api/queries/catalog';
import { useAgents } from '../api/queries/agents';
import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions';
import { useAuthStore } from '../auth/auth-store';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { ContentTabs } from './ContentTabs';
import { useScope } from '../hooks/useScope';
import { buildAppTreeNodes, buildAgentTreeNodes, buildRouteTreeNodes, buildAdminTreeNodes, readCollapsed, writeCollapsed } from './sidebar-utils';
import type { SidebarApp } from './sidebar-utils';
```
- [ ] **Step 2: Remove the old `sidebarApps` builder and `healthToColor` function**
Delete the `healthToColor` function (lines 12-19) and the `sidebarApps` useMemo block (lines 128-154). These are replaced by tree-building functions that produce `SidebarTreeNode[]` directly.
- [ ] **Step 3: Add sidebar state management inside LayoutContent**
Add these state and memo declarations inside `LayoutContent`, after the existing hooks (after `const { scope, setTab } = useScope();` around line 112):
```typescript
// ── Sidebar state ──────────────────────────────────────────────────────
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [filterQuery, setFilterQuery] = useState('');
const { starredIds, isStarred, toggleStar } = useStarred();
// Section collapse states — persisted to localStorage
const [appsOpen, setAppsOpen] = useState(() => !readCollapsed('cameleer:sidebar:apps-collapsed', false));
const [agentsOpen, setAgentsOpen] = useState(() => !readCollapsed('cameleer:sidebar:agents-collapsed', false));
const [routesOpen, setRoutesOpen] = useState(() => !readCollapsed('cameleer:sidebar:routes-collapsed', true));
const [adminOpen, setAdminOpen] = useState(false);
const toggleApps = useCallback(() => {
setAppsOpen((v) => { writeCollapsed('cameleer:sidebar:apps-collapsed', v); return !v; });
}, []);
const toggleAgents = useCallback(() => {
setAgentsOpen((v) => { writeCollapsed('cameleer:sidebar:agents-collapsed', v); return !v; });
}, []);
const toggleRoutes = useCallback(() => {
setRoutesOpen((v) => { writeCollapsed('cameleer:sidebar:routes-collapsed', v); return !v; });
}, []);
const toggleAdmin = useCallback(() => setAdminOpen((v) => !v), []);
// Accordion: entering admin expands admin, collapses operational sections
const prevOpsState = useRef<{ apps: boolean; agents: boolean; routes: boolean } | null>(null);
useEffect(() => {
if (isAdminPage) {
// Save current operational state, then collapse all, expand admin
prevOpsState.current = { apps: appsOpen, agents: agentsOpen, routes: routesOpen };
setAppsOpen(false);
setAgentsOpen(false);
setRoutesOpen(false);
setAdminOpen(true);
} else if (prevOpsState.current) {
// Restore operational state, collapse admin
setAppsOpen(prevOpsState.current.apps);
setAgentsOpen(prevOpsState.current.agents);
setRoutesOpen(prevOpsState.current.routes);
setAdminOpen(false);
prevOpsState.current = null;
}
}, [isAdminPage]); // eslint-disable-line react-hooks/exhaustive-deps
// Build tree nodes from catalog data
const statusDot = useCallback((health: string) => <StatusDot status={health as any} />, []);
const chevronIcon = useMemo(() => <ChevronRight size={12} />, []);
const appNodes = useMemo(
() => buildAppTreeNodes(catalog ? [...catalog].sort((a: any, b: any) => a.appId.localeCompare(b.appId)).map((app: any) => ({
id: app.appId,
name: app.appId,
health: app.health as 'live' | 'stale' | 'dead',
exchangeCount: app.exchangeCount,
routes: [...(app.routes || [])].sort((a: any, b: any) => a.routeId.localeCompare(b.routeId)).map((r: any) => ({
id: r.routeId, name: r.routeId, exchangeCount: r.exchangeCount,
})),
agents: [...(app.agents || [])].sort((a: any, b: any) => a.name.localeCompare(b.name)).map((a: any) => ({
id: a.id, name: a.name, status: a.status as 'live' | 'stale' | 'dead', tps: a.tps ?? 0,
})),
})) : [], statusDot, chevronIcon),
[catalog, statusDot, chevronIcon],
);
const agentNodes = useMemo(
() => buildAgentTreeNodes(catalog ? [...catalog].sort((a: any, b: any) => a.appId.localeCompare(b.appId)).map((app: any) => ({
id: app.appId,
name: app.appId,
health: app.health as 'live' | 'stale' | 'dead',
exchangeCount: app.exchangeCount,
routes: [],
agents: [...(app.agents || [])].sort((a: any, b: any) => a.name.localeCompare(b.name)).map((a: any) => ({
id: a.id, name: a.name, status: a.status as 'live' | 'stale' | 'dead', tps: a.tps ?? 0,
})),
})) : [], statusDot),
[catalog, statusDot],
);
const routeNodes = useMemo(
() => buildRouteTreeNodes(catalog ? [...catalog].sort((a: any, b: any) => a.appId.localeCompare(b.appId)).map((app: any) => ({
id: app.appId,
name: app.appId,
health: app.health as 'live' | 'stale' | 'dead',
exchangeCount: app.exchangeCount,
routes: [...(app.routes || [])].sort((a: any, b: any) => a.routeId.localeCompare(b.routeId)).map((r: any) => ({
id: r.routeId, name: r.routeId, exchangeCount: r.exchangeCount,
})),
agents: [],
})) : [], statusDot, chevronIcon),
[catalog, statusDot, chevronIcon],
);
const adminNodes = useMemo(() => buildAdminTreeNodes(), []);
// Sidebar reveal from Cmd-K navigation
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null;
useEffect(() => {
if (!sidebarRevealPath) return;
if (sidebarRevealPath.startsWith('/apps') && !appsOpen) setAppsOpen(true);
if (sidebarRevealPath.startsWith('/agents') && !agentsOpen) setAgentsOpen(true);
if (sidebarRevealPath.startsWith('/routes') && !routesOpen) setRoutesOpen(true);
if (sidebarRevealPath.startsWith('/admin') && !adminOpen) setAdminOpen(true);
}, [sidebarRevealPath]); // eslint-disable-line react-hooks/exhaustive-deps
const effectiveSelectedPath = sidebarRevealPath ?? location.pathname;
```
- [ ] **Step 4: Replace the return block's sidebar composition**
Replace the `return` statement in `LayoutContent` (starting from `return (` to the closing `);`). The key change is replacing `<Sidebar apps={sidebarApps} onNavigate={handleSidebarNavigate} />` with the compound Sidebar:
```typescript
return (
<AppShell
sidebar={
<Sidebar
collapsed={sidebarCollapsed}
onCollapseToggle={() => setSidebarCollapsed((v) => !v)}
searchValue={filterQuery}
onSearchChange={setFilterQuery}
>
<Sidebar.Header
logo={<img src="/favicon.svg" alt="" style={{ width: 28, height: 24 }} />}
title="cameleer"
version="v3.2.1"
onClick={() => handleSidebarNavigate('/apps')}
/>
{isAdminPage && (
<Sidebar.Section
label="Admin"
icon={<Settings size={14} />}
open={adminOpen}
onToggle={toggleAdmin}
active={location.pathname.startsWith('/admin')}
>
<SidebarTree
nodes={adminNodes}
selectedPath={effectiveSelectedPath}
filterQuery={filterQuery}
onNavigate={handleSidebarNavigate}
/>
</Sidebar.Section>
)}
<Sidebar.Section
label="Applications"
icon={<Box size={14} />}
open={appsOpen}
onToggle={() => { toggleApps(); if (isAdminPage) handleSidebarNavigate('/apps'); }}
active={effectiveSelectedPath.startsWith('/apps') || effectiveSelectedPath.startsWith('/exchanges') || effectiveSelectedPath.startsWith('/dashboard')}
>
<SidebarTree
nodes={appNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="cameleer:expanded:apps"
autoRevealPath={sidebarRevealPath}
onNavigate={handleSidebarNavigate}
/>
</Sidebar.Section>
<Sidebar.Section
label="Agents"
icon={<Cpu size={14} />}
open={agentsOpen}
onToggle={() => { toggleAgents(); if (isAdminPage) handleSidebarNavigate('/agents'); }}
active={effectiveSelectedPath.startsWith('/agents') || effectiveSelectedPath.startsWith('/runtime')}
>
<SidebarTree
nodes={agentNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="cameleer:expanded:agents"
autoRevealPath={sidebarRevealPath}
onNavigate={handleSidebarNavigate}
/>
</Sidebar.Section>
<Sidebar.Section
label="Routes"
icon={<GitBranch size={14} />}
open={routesOpen}
onToggle={() => { toggleRoutes(); if (isAdminPage) handleSidebarNavigate('/routes'); }}
active={effectiveSelectedPath.startsWith('/routes')}
>
<SidebarTree
nodes={routeNodes}
selectedPath={effectiveSelectedPath}
isStarred={isStarred}
onToggleStar={toggleStar}
filterQuery={filterQuery}
persistKey="cameleer:expanded:routes"
autoRevealPath={sidebarRevealPath}
onNavigate={handleSidebarNavigate}
/>
</Sidebar.Section>
{!isAdminPage && (
<Sidebar.Section
label="Admin"
icon={<Settings size={14} />}
open={adminOpen}
onToggle={() => { toggleAdmin(); handleSidebarNavigate('/admin'); }}
active={false}
>
<SidebarTree
nodes={adminNodes}
selectedPath={effectiveSelectedPath}
filterQuery={filterQuery}
onNavigate={handleSidebarNavigate}
/>
</Sidebar.Section>
)}
<Sidebar.Footer>
<Sidebar.FooterLink
icon={<FileText size={14} />}
label="API Docs"
onClick={() => handleSidebarNavigate('/api-docs')}
active={location.pathname === '/api-docs'}
/>
</Sidebar.Footer>
</Sidebar>
}
>
<TopBar
breadcrumb={breadcrumb}
user={username ? { name: username } : undefined}
onLogout={handleLogout}
/>
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onOpen={() => setPaletteOpen(true)}
onSelect={handlePaletteSelect}
onSubmit={handlePaletteSubmit}
onQueryChange={setPaletteQuery}
data={searchData}
/>
{!isAdminPage && (
<ContentTabs active={scope.tab} onChange={setTab} scope={scope} />
)}
<main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0, padding: isAdminPage ? '1.5rem' : 0 }}>
<Outlet />
</main>
</AppShell>
);
```
Note: The Admin section renders in two positions — at the top when `isAdminPage` (accordion mode), at the bottom when not (collapsed section). Only one renders at a time via the conditional.
- [ ] **Step 5: Verify it compiles**
Run: `cd C:/Users/Hendrik/Documents/projects/cameleer3-server/ui && npx tsc --project tsconfig.app.json --noEmit`
Expected: No errors. If `StatusDot` is not exported from the DS, check the exact export name with `grep -r "StatusDot" ui/node_modules/@cameleer/design-system/dist/index.es.d.ts`.
- [ ] **Step 6: Commit**
```bash
git add ui/src/components/LayoutShell.tsx
git commit -m "feat(#112): migrate to composable sidebar with accordion and collapse"
```
---
### Task 3: Simplify AdminLayout
**Files:**
- Modify: `ui/src/pages/Admin/AdminLayout.tsx`
The sidebar now handles admin sub-page navigation, so `AdminLayout` no longer needs its own `<Tabs>`.
- [ ] **Step 1: Rewrite AdminLayout**
Replace the full contents of `AdminLayout.tsx`:
```typescript
import { Outlet } from 'react-router';
export default function AdminLayout() {
return (
<div style={{ padding: '20px 24px 40px' }}>
<Outlet />
</div>
);
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd C:/Users/Hendrik/Documents/projects/cameleer3-server/ui && npx tsc --project tsconfig.app.json --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add ui/src/pages/Admin/AdminLayout.tsx
git commit -m "feat(#112): remove admin tabs, sidebar handles navigation"
```
---
### Task 4: Visual Verification
- [ ] **Step 1: Verify operational mode**
Open `http://localhost:5173/exchanges` and verify:
1. Sidebar shows all 4 sections: Applications, Agents, Routes, Admin
2. Applications and Agents expanded by default, Routes and Admin collapsed
3. Sidebar search filters tree items
4. Clicking an app navigates to the exchanges page for that app
5. TopBar, ContentTabs, CommandPalette all work normally
6. Star/unstar items work
- [ ] **Step 2: Verify sidebar collapse**
Click the `<<` toggle in sidebar header:
1. Sidebar collapses to ~48px icon rail
2. Section icons visible (Box, Cpu, GitBranch, Settings)
3. Footer link icon visible (FileText for API Docs)
4. Click any section icon — sidebar expands and that section opens
- [ ] **Step 3: Verify admin accordion**
Navigate to `/admin/rbac` (click Admin section in sidebar or navigate directly):
1. Admin section appears at top of sidebar, expanded, showing 6 sub-pages
2. Applications, Agents, Routes sections are collapsed to single-line headers
3. Admin sub-page items show active highlighting for current page
4. No admin tabs visible in content area (just content with padding)
5. Clicking between admin sub-pages (e.g., Audit Log, OIDC) works via sidebar
- [ ] **Step 4: Verify leaving admin**
From an admin page, click "Applications" section header:
1. Navigates to `/exchanges` (or last operational tab)
2. Admin section collapses
3. Operational sections restore their previous open/closed states
- [ ] **Step 5: Final commit if any fixes were needed**
```bash
git add -A
git commit -m "fix(#112): sidebar migration adjustments from visual review"
```