docs: add historical implementation plans
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
285
docs/superpowers/plans/2026-04-02-admin-context-separation.md
Normal file
285
docs/superpowers/plans/2026-04-02-admin-context-separation.md
Normal 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"
|
||||
```
|
||||
@@ -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"
|
||||
```
|
||||
Reference in New Issue
Block a user