diff --git a/docs/superpowers/plans/2026-04-11-infrastructure-endpoint-visibility.md b/docs/superpowers/plans/2026-04-11-infrastructure-endpoint-visibility.md new file mode 100644 index 00000000..75901197 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-infrastructure-endpoint-visibility.md @@ -0,0 +1,1163 @@ +# Infrastructure Endpoint Visibility — 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 DB/ClickHouse admin endpoints from tenant admins in SaaS-managed servers, and build a vendor-facing infrastructure dashboard in the SaaS platform with per-tenant visibility. + +**Architecture:** Server uses `@ConditionalOnProperty` to remove infrastructure controller beans when a flag is set. SaaS provisioner sets the flag on tenant servers. SaaS platform queries shared PostgreSQL and ClickHouse directly via raw JDBC for vendor infrastructure monitoring. + +**Tech Stack:** Spring Boot 3.4, Spring Boot Actuator, React 19, React Query, raw JDBC, ClickHouse JDBC, `@cameleer/design-system` + +**Spec:** `docs/superpowers/specs/2026-04-11-infrastructure-endpoint-visibility-design.md` + +**Cross-repo:** Tasks 1-6 in `cameleer3-server`, Tasks 7-13 in `cameleer-saas`. + +--- + +## Part 1: Server — Disable Infrastructure Endpoints + +### Task 1: Add property to application.yml + +**Files:** +- Modify: `cameleer3-server-app/src/main/resources/application.yml` +- Modify: `cameleer3-server-app/src/test/resources/application-test.yml` + +- [ ] **Step 1: Add the property to application.yml** + +In `application.yml`, add `infrastructureendpoints` under the existing `cameleer.server.security` block (after `corsallowedorigins`, around line 73): + +```yaml + infrastructureendpoints: ${CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS:true} +``` + +- [ ] **Step 2: Add the property to application-test.yml** + +In `application-test.yml`, add under `cameleer.server.security` (after `bootstraptokenprevious`): + +```yaml + infrastructureendpoints: true +``` + +- [ ] **Step 3: Commit** + +```bash +git add cameleer3-server-app/src/main/resources/application.yml cameleer3-server-app/src/test/resources/application-test.yml +git commit -m "feat: add infrastructureendpoints property (default true)" +``` + +--- + +### Task 2: Add @ConditionalOnProperty to DatabaseAdminController + +**Files:** +- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DatabaseAdminController.java` + +- [ ] **Step 1: Add the annotation** + +Add `@ConditionalOnProperty` import and annotation to `DatabaseAdminController`. The annotation goes alongside the existing class-level annotations (`@RestController`, `@RequestMapping`, `@PreAuthorize`, `@Tag`): + +```java +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +``` + +Add before `@RestController`: + +```java +@ConditionalOnProperty( + name = "cameleer.server.security.infrastructureendpoints", + havingValue = "true", + matchIfMissing = true +) +``` + +- [ ] **Step 2: Verify compile** + +```bash +mvn compile -pl cameleer3-server-app -q +``` + +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DatabaseAdminController.java +git commit -m "feat: make DatabaseAdminController conditional on infrastructureendpoints" +``` + +--- + +### Task 3: Add @ConditionalOnProperty to ClickHouseAdminController + +**Files:** +- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClickHouseAdminController.java` + +- [ ] **Step 1: Add the annotation** + +Same pattern as Task 2. Add import: + +```java +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +``` + +Add before `@RestController`: + +```java +@ConditionalOnProperty( + name = "cameleer.server.security.infrastructureendpoints", + havingValue = "true", + matchIfMissing = true +) +``` + +- [ ] **Step 2: Verify compile** + +```bash +mvn compile -pl cameleer3-server-app -q +``` + +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClickHouseAdminController.java +git commit -m "feat: make ClickHouseAdminController conditional on infrastructureendpoints" +``` + +--- + +### Task 4: Expose flag in health endpoint + +The server uses Spring Boot Actuator for `/api/v1/health`. Add a custom `HealthIndicator` that contributes the `infrastructureEndpoints` flag to the health response details. + +**Files:** +- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/ServerCapabilitiesHealthIndicator.java` + +- [ ] **Step 1: Create the health indicator** + +```java +package com.cameleer3.server.app.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +@Component +public class ServerCapabilitiesHealthIndicator implements HealthIndicator { + + @Value("${cameleer.server.security.infrastructureendpoints:true}") + private boolean infrastructureEndpoints; + + @Override + public Health health() { + return Health.up() + .withDetail("infrastructureEndpoints", infrastructureEndpoints) + .build(); + } +} +``` + +- [ ] **Step 2: Verify compile** + +```bash +mvn compile -pl cameleer3-server-app -q +``` + +Expected: BUILD SUCCESS + +- [ ] **Step 3: Verify health response includes the flag** + +Start the server locally or in a test and check: + +```bash +curl -s http://localhost:8081/api/v1/health | jq '.components.serverCapabilities.details.infrastructureEndpoints' +``` + +Expected: `true` + +- [ ] **Step 4: Commit** + +```bash +git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/ServerCapabilitiesHealthIndicator.java +git commit -m "feat: expose infrastructureEndpoints flag in health endpoint" +``` + +--- + +### Task 5: UI — Filter admin sidebar nodes based on flag + +**Files:** +- Modify: `ui/src/components/sidebar-utils.ts` + +- [ ] **Step 1: Update buildAdminTreeNodes to accept a filter parameter** + +Change the function signature and filter out infrastructure nodes when the flag is false. Current code at lines 102-111: + +```typescript +export function buildAdminTreeNodes(): SidebarTreeNode[] { + return [ + { id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' }, + { id: 'admin:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' }, + { id: 'admin:database', label: 'Database', path: '/admin/database' }, + { id: 'admin:environments', label: 'Environments', path: '/admin/environments' }, + { id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' }, + { id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' }, + ]; +} +``` + +Replace with: + +```typescript +export function buildAdminTreeNodes(opts?: { infrastructureEndpoints?: boolean }): SidebarTreeNode[] { + const showInfra = opts?.infrastructureEndpoints !== false; + const nodes: SidebarTreeNode[] = [ + { id: 'admin:audit', label: 'Audit Log', path: '/admin/audit' }, + ...(showInfra ? [{ id: 'admin:clickhouse', label: 'ClickHouse', path: '/admin/clickhouse' }] : []), + ...(showInfra ? [{ id: 'admin:database', label: 'Database', path: '/admin/database' }] : []), + { id: 'admin:environments', label: 'Environments', path: '/admin/environments' }, + { id: 'admin:oidc', label: 'OIDC', path: '/admin/oidc' }, + { id: 'admin:rbac', label: 'Users & Roles', path: '/admin/rbac' }, + ]; + return nodes; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/components/sidebar-utils.ts +git commit -m "feat: filter infrastructure nodes from admin sidebar" +``` + +--- + +### Task 6: UI — Fetch capabilities flag and wire into sidebar + +**Files:** +- Create: `ui/src/api/queries/capabilities.ts` +- Modify: `ui/src/components/LayoutShell.tsx` + +- [ ] **Step 1: Create capabilities hook** + +Create `ui/src/api/queries/capabilities.ts`: + +```typescript +import { useQuery } from '@tanstack/react-query'; + +interface HealthResponse { + status: string; + components?: { + serverCapabilities?: { + details?: { + infrastructureEndpoints?: boolean; + }; + }; + }; +} + +export function useServerCapabilities() { + return useQuery<{ infrastructureEndpoints: boolean }>({ + queryKey: ['server-capabilities'], + queryFn: async () => { + const res = await fetch('/api/v1/health'); + if (!res.ok) return { infrastructureEndpoints: true }; + const data: HealthResponse = await res.json(); + return { + infrastructureEndpoints: + data.components?.serverCapabilities?.details?.infrastructureEndpoints ?? true, + }; + }, + staleTime: Infinity, + }); +} +``` + +Note: this fetches `/api/v1/health` directly (no auth needed — it's public). `staleTime: Infinity` means it's fetched once per session. + +- [ ] **Step 2: Wire into LayoutShell** + +In `LayoutShell.tsx`, import the hook and pass the flag to `buildAdminTreeNodes`. + +Add import near the top (alongside other query imports): + +```typescript +import { useServerCapabilities } from '../api/queries/capabilities'; +``` + +In the component body (near line 294 where `isAdmin` is defined), add: + +```typescript +const { data: capabilities } = useServerCapabilities(); +``` + +Find the `useMemo` that computes `adminTreeNodes` (around lines 434-437). It currently calls `buildAdminTreeNodes()` with no arguments. Update the call: + +```typescript +const adminTreeNodes = useMemo( + () => buildAdminTreeNodes({ infrastructureEndpoints: capabilities?.infrastructureEndpoints }), + [capabilities?.infrastructureEndpoints] +); +``` + +Update the import of `buildAdminTreeNodes` if needed (it's from `./sidebar-utils`). + +- [ ] **Step 3: Verify** + +Start the dev server (`cd ui && npm run dev`). Log in as admin. Verify: +- With default config (`infrastructureEndpoints: true`): Database and ClickHouse tabs visible in admin sidebar. +- No console errors. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/api/queries/capabilities.ts ui/src/components/LayoutShell.tsx +git commit -m "feat: fetch server capabilities and hide infra tabs when disabled" +``` + +--- + +## Part 2: SaaS — Provisioner Flag + Vendor Infrastructure Dashboard + +### Task 7: Add env var to DockerTenantProvisioner + +**Files:** +- Modify: `src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java` + +- [ ] **Step 1: Add the env var** + +In `createServerContainer()`, after the existing env var list (around line 215, after `CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME`), add: + +```java +"CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS=false" +``` + +This goes inside the `List.of(...)` block alongside the other `CAMELEER_SERVER_*` env vars. + +- [ ] **Step 2: Verify compile** + +```bash +mvn compile -q +``` + +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/provisioning/DockerTenantProvisioner.java +git commit -m "feat: set INFRASTRUCTUREENDPOINTS=false on tenant server containers" +``` + +--- + +### Task 8: Create InfrastructureService — PostgreSQL queries + +**Files:** +- Create: `src/main/java/net/siegeln/cameleer/saas/vendor/InfrastructureService.java` + +- [ ] **Step 1: Create the service with PostgreSQL methods** + +```java +package net.siegeln.cameleer.saas.vendor; + +import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.sql.*; +import java.util.*; + +@Service +public class InfrastructureService { + + private static final Logger log = LoggerFactory.getLogger(InfrastructureService.class); + + private final ProvisioningProperties props; + + public InfrastructureService(ProvisioningProperties props) { + this.props = props; + } + + // --- Response records --- + + public record PostgresOverview(String version, long databaseSizeBytes, int activeConnections) {} + + public record TenantPgStats(String slug, long schemaSizeBytes, int tableCount, long totalRows) {} + + public record TableStats(String tableName, long rowCount, long dataSizeBytes, long indexSizeBytes) {} + + // --- PostgreSQL methods --- + + public PostgresOverview getPostgresOverview() { + try (Connection conn = pgConnection(); + Statement stmt = conn.createStatement()) { + + String version = ""; + try (ResultSet rs = stmt.executeQuery("SELECT version()")) { + if (rs.next()) version = rs.getString(1); + } + + long dbSize = 0; + try (ResultSet rs = stmt.executeQuery( + "SELECT pg_database_size(current_database())")) { + if (rs.next()) dbSize = rs.getLong(1); + } + + int activeConns = 0; + try (ResultSet rs = stmt.executeQuery( + "SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()")) { + if (rs.next()) activeConns = rs.getInt(1); + } + + return new PostgresOverview(version, dbSize, activeConns); + } catch (Exception e) { + log.error("Failed to get PostgreSQL overview: {}", e.getMessage()); + throw new RuntimeException("PostgreSQL overview failed", e); + } + } + + public List getPostgresTenantStats() { + String sql = """ + SELECT + s.schema_name, + coalesce(sum(pg_total_relation_size(quote_ident(s.schema_name) || '.' || quote_ident(t.table_name))), 0) AS schema_size, + count(t.table_name) AS table_count, + coalesce(sum(st.n_live_tup), 0) AS total_rows + FROM information_schema.schemata s + LEFT JOIN information_schema.tables t + ON t.table_schema = s.schema_name AND t.table_type = 'BASE TABLE' + LEFT JOIN pg_stat_user_tables st + ON st.schemaname = s.schema_name AND st.relname = t.table_name + WHERE s.schema_name LIKE 'tenant_%' + GROUP BY s.schema_name + ORDER BY schema_size DESC + """; + + List result = new ArrayList<>(); + try (Connection conn = pgConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + String schema = rs.getString("schema_name"); + String slug = schema.startsWith("tenant_") ? schema.substring(7) : schema; + result.add(new TenantPgStats( + slug, + rs.getLong("schema_size"), + rs.getInt("table_count"), + rs.getLong("total_rows"))); + } + } catch (Exception e) { + log.error("Failed to get tenant PG stats: {}", e.getMessage()); + throw new RuntimeException("Tenant PG stats failed", e); + } + return result; + } + + public List getPostgresTenantDetail(String slug) { + String schema = "tenant_" + slug; + String sql = """ + SELECT + st.relname AS table_name, + st.n_live_tup AS row_count, + pg_table_size(quote_ident(st.schemaname) || '.' || quote_ident(st.relname)) AS data_size, + pg_indexes_size(quote_ident(st.schemaname) || '.' || quote_ident(st.relname)) AS index_size + FROM pg_stat_user_tables st + WHERE st.schemaname = ? + ORDER BY data_size DESC + """; + + List result = new ArrayList<>(); + try (Connection conn = pgConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, schema); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + result.add(new TableStats( + rs.getString("table_name"), + rs.getLong("row_count"), + rs.getLong("data_size"), + rs.getLong("index_size"))); + } + } + } catch (Exception e) { + log.error("Failed to get PG detail for tenant '{}': {}", slug, e.getMessage()); + throw new RuntimeException("Tenant PG detail failed", e); + } + return result; + } + + private Connection pgConnection() throws SQLException { + return DriverManager.getConnection(props.datasourceUrl(), "cameleer", "cameleer_dev"); + } +} +``` + +- [ ] **Step 2: Verify compile** + +```bash +mvn compile -q +``` + +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/vendor/InfrastructureService.java +git commit -m "feat: add InfrastructureService with PostgreSQL queries" +``` + +--- + +### Task 9: Add ClickHouse queries to InfrastructureService + +**Files:** +- Modify: `src/main/java/net/siegeln/cameleer/saas/vendor/InfrastructureService.java` + +- [ ] **Step 1: Add ClickHouse response records** + +Add these records inside `InfrastructureService`, after the PostgreSQL records: + +```java +public record ClickHouseOverview( + String version, long uptimeSeconds, + long totalDiskBytes, long totalUncompressedBytes, + double compressionRatio, long totalRows, int activeMerges) {} + +public record TenantChStats(String tenantId, long totalRows, Map rowsByTable) {} + +public record ChTableStats(String tableName, long rowCount) {} +``` + +- [ ] **Step 2: Add ClickHouse methods** + +Add these methods after `getPostgresTenantDetail`: + +```java +// --- ClickHouse methods --- + +public ClickHouseOverview getClickHouseOverview() { + try (Connection conn = chConnection(); + Statement stmt = conn.createStatement()) { + + String version = ""; + long uptime = 0; + try (ResultSet rs = stmt.executeQuery("SELECT version(), uptime()")) { + if (rs.next()) { + version = rs.getString(1); + uptime = rs.getLong(2); + } + } + + long diskBytes = 0, uncompressedBytes = 0, totalRows = 0; + try (ResultSet rs = stmt.executeQuery(""" + SELECT sum(bytes_on_disk), sum(data_uncompressed_bytes), sum(rows) + FROM system.parts + WHERE database = currentDatabase() AND active + """)) { + if (rs.next()) { + diskBytes = rs.getLong(1); + uncompressedBytes = rs.getLong(2); + totalRows = rs.getLong(3); + } + } + + double ratio = uncompressedBytes > 0 + ? (double) uncompressedBytes / diskBytes : 0; + + int activeMerges = 0; + try (ResultSet rs = stmt.executeQuery( + "SELECT count() FROM system.merges WHERE database = currentDatabase()")) { + if (rs.next()) activeMerges = rs.getInt(1); + } + + return new ClickHouseOverview(version, uptime, diskBytes, + uncompressedBytes, Math.round(ratio * 100.0) / 100.0, + totalRows, activeMerges); + } catch (Exception e) { + log.error("Failed to get ClickHouse overview: {}", e.getMessage()); + throw new RuntimeException("ClickHouse overview failed", e); + } +} + +public List getClickHouseTenantStats() { + // Query the main data tables that have tenant_id column + String[] tables = {"executions", "processor_executions", "logs", "agent_events", "usage_events"}; + Map> tenantTableRows = new LinkedHashMap<>(); + + try (Connection conn = chConnection(); + Statement stmt = conn.createStatement()) { + for (String table : tables) { + try (ResultSet rs = stmt.executeQuery( + "SELECT tenant_id, count() AS cnt FROM " + table + " GROUP BY tenant_id")) { + while (rs.next()) { + String tid = rs.getString("tenant_id"); + long cnt = rs.getLong("cnt"); + tenantTableRows.computeIfAbsent(tid, k -> new LinkedHashMap<>()) + .put(table, cnt); + } + } + } + } catch (Exception e) { + log.error("Failed to get ClickHouse tenant stats: {}", e.getMessage()); + throw new RuntimeException("ClickHouse tenant stats failed", e); + } + + return tenantTableRows.entrySet().stream() + .map(e -> { + long total = e.getValue().values().stream().mapToLong(Long::longValue).sum(); + return new TenantChStats(e.getKey(), total, e.getValue()); + }) + .sorted(Comparator.comparingLong(TenantChStats::totalRows).reversed()) + .toList(); +} + +public List getClickHouseTenantDetail(String tenantId) { + String[] tables = {"executions", "processor_executions", "logs", "agent_events", "usage_events"}; + List result = new ArrayList<>(); + + try (Connection conn = chConnection()) { + for (String table : tables) { + try (PreparedStatement pstmt = conn.prepareStatement( + "SELECT count() AS cnt FROM " + table + " WHERE tenant_id = ?")) { + pstmt.setString(1, tenantId); + try (ResultSet rs = pstmt.executeQuery()) { + if (rs.next()) { + result.add(new ChTableStats(table, rs.getLong("cnt"))); + } + } + } + } + } catch (Exception e) { + log.error("Failed to get CH detail for tenant '{}': {}", tenantId, e.getMessage()); + throw new RuntimeException("ClickHouse tenant detail failed", e); + } + return result; +} + +private Connection chConnection() throws SQLException { + return DriverManager.getConnection(props.clickhouseUrl()); +} +``` + +- [ ] **Step 3: Verify compile** + +```bash +mvn compile -q +``` + +Expected: BUILD SUCCESS + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/vendor/InfrastructureService.java +git commit -m "feat: add ClickHouse queries to InfrastructureService" +``` + +--- + +### Task 10: Create InfrastructureController + +**Files:** +- Create: `src/main/java/net/siegeln/cameleer/saas/vendor/InfrastructureController.java` + +- [ ] **Step 1: Create the controller** + +```java +package net.siegeln.cameleer.saas.vendor; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/vendor/infrastructure") +@PreAuthorize("hasAuthority('SCOPE_platform:admin')") +public class InfrastructureController { + + private final InfrastructureService infraService; + + public InfrastructureController(InfrastructureService infraService) { + this.infraService = infraService; + } + + public record InfraOverviewResponse( + InfrastructureService.PostgresOverview postgres, + InfrastructureService.ClickHouseOverview clickhouse) {} + + @GetMapping + public ResponseEntity overview() { + return ResponseEntity.ok(new InfraOverviewResponse( + infraService.getPostgresOverview(), + infraService.getClickHouseOverview())); + } + + @GetMapping("/postgres") + public ResponseEntity> postgres() { + return ResponseEntity.ok(Map.of( + "overview", infraService.getPostgresOverview(), + "tenants", infraService.getPostgresTenantStats())); + } + + @GetMapping("/postgres/{slug}") + public ResponseEntity> postgresDetail( + @PathVariable String slug) { + return ResponseEntity.ok(infraService.getPostgresTenantDetail(slug)); + } + + @GetMapping("/clickhouse") + public ResponseEntity> clickhouse() { + return ResponseEntity.ok(Map.of( + "overview", infraService.getClickHouseOverview(), + "tenants", infraService.getClickHouseTenantStats())); + } + + @GetMapping("/clickhouse/{tenantId}") + public ResponseEntity> clickhouseDetail( + @PathVariable String tenantId) { + return ResponseEntity.ok(infraService.getClickHouseTenantDetail(tenantId)); + } +} +``` + +- [ ] **Step 2: Verify compile** + +```bash +mvn compile -q +``` + +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/vendor/InfrastructureController.java +git commit -m "feat: add vendor InfrastructureController for platform:admin" +``` + +--- + +### Task 11: UI — Add infrastructure API hooks + +**Files:** +- Modify: `ui/src/api/vendor-hooks.ts` + +- [ ] **Step 1: Add types and hooks** + +Add these types and hooks to the bottom of `vendor-hooks.ts`: + +```typescript +// --- Infrastructure --- + +export interface PostgresOverview { + version: string; + databaseSizeBytes: number; + activeConnections: number; +} + +export interface TenantPgStats { + slug: string; + schemaSizeBytes: number; + tableCount: number; + totalRows: number; +} + +export interface TableStats { + tableName: string; + rowCount: number; + dataSizeBytes: number; + indexSizeBytes: number; +} + +export interface ClickHouseOverview { + version: string; + uptimeSeconds: number; + totalDiskBytes: number; + totalUncompressedBytes: number; + compressionRatio: number; + totalRows: number; + activeMerges: number; +} + +export interface TenantChStats { + tenantId: string; + totalRows: number; + rowsByTable: Record; +} + +export interface ChTableStats { + tableName: string; + rowCount: number; +} + +export interface InfraOverview { + postgres: PostgresOverview; + clickhouse: ClickHouseOverview; +} + +export function useInfraOverview() { + return useQuery({ + queryKey: ['vendor', 'infrastructure'], + queryFn: () => api.get('/vendor/infrastructure'), + }); +} + +export function useInfraPostgres() { + return useQuery<{ overview: PostgresOverview; tenants: TenantPgStats[] }>({ + queryKey: ['vendor', 'infrastructure', 'postgres'], + queryFn: () => api.get('/vendor/infrastructure/postgres'), + }); +} + +export function useInfraClickHouse() { + return useQuery<{ overview: ClickHouseOverview; tenants: TenantChStats[] }>({ + queryKey: ['vendor', 'infrastructure', 'clickhouse'], + queryFn: () => api.get('/vendor/infrastructure/clickhouse'), + }); +} + +export function useInfraPgDetail(slug: string) { + return useQuery({ + queryKey: ['vendor', 'infrastructure', 'postgres', slug], + queryFn: () => api.get(`/vendor/infrastructure/postgres/${slug}`), + enabled: !!slug, + }); +} + +export function useInfraChDetail(tenantId: string) { + return useQuery({ + queryKey: ['vendor', 'infrastructure', 'clickhouse', tenantId], + queryFn: () => api.get(`/vendor/infrastructure/clickhouse/${tenantId}`), + enabled: !!tenantId, + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/api/vendor-hooks.ts +git commit -m "feat: add vendor infrastructure API hooks" +``` + +--- + +### Task 12: UI — Create InfrastructurePage + +**Files:** +- Create: `ui/src/pages/vendor/InfrastructurePage.tsx` + +- [ ] **Step 1: Create the page** + +```tsx +import { useState } from 'react'; +import { + useInfraOverview, + useInfraPostgres, + useInfraClickHouse, + useInfraPgDetail, + useInfraChDetail, + type TenantPgStats, + type TenantChStats, +} from '../../api/vendor-hooks'; + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i]; +} + +function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + if (days > 0) return `${days}d ${hours}h`; + const mins = Math.floor((seconds % 3600) / 60); + return `${hours}h ${mins}m`; +} + +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +export default function InfrastructurePage() { + const { data: overview, isLoading } = useInfraOverview(); + const { data: pgData } = useInfraPostgres(); + const { data: chData } = useInfraClickHouse(); + const [pgDetailSlug, setPgDetailSlug] = useState(null); + const [chDetailId, setChDetailId] = useState(null); + const { data: pgDetail } = useInfraPgDetail(pgDetailSlug ?? ''); + const { data: chDetail } = useInfraChDetail(chDetailId ?? ''); + + if (isLoading) return
Loading...
; + + return ( +
+

Infrastructure

+ + {/* PostgreSQL */} +
+

PostgreSQL

+ + {overview?.postgres && ( +
+
Version
{overview.postgres.version.split(' ').slice(0, 2).join(' ')}
+
Database Size
{formatBytes(overview.postgres.databaseSizeBytes)}
+
Active Connections
{overview.postgres.activeConnections}
+
+ )} + + {pgData?.tenants && pgData.tenants.length > 0 && ( + <> +

Per-Tenant Breakdown

+ + + + + + + + + + + {pgData.tenants.map((t: TenantPgStats) => ( + setPgDetailSlug(pgDetailSlug === t.slug ? null : t.slug)} + > + + + + + + ))} + +
TenantSchema SizeTablesRows
{t.slug}{formatBytes(t.schemaSizeBytes)}{t.tableCount}{formatNumber(t.totalRows)}
+ + )} + + {pgDetailSlug && pgDetail && ( +
+ Tables in tenant_{pgDetailSlug}: + + + + + + + + + + + {pgDetail.map(t => ( + + + + + + + ))} + +
TableRowsDataIndex
{t.tableName}{formatNumber(t.rowCount)}{formatBytes(t.dataSizeBytes)}{formatBytes(t.indexSizeBytes)}
+
+ )} +
+ + {/* ClickHouse */} +
+

ClickHouse

+ + {overview?.clickhouse && ( +
+
Version
{overview.clickhouse.version}
+
Uptime
{formatUptime(overview.clickhouse.uptimeSeconds)}
+
Disk Usage
{formatBytes(overview.clickhouse.totalDiskBytes)}
+
Total Rows
{formatNumber(overview.clickhouse.totalRows)}
+
Compression
{overview.clickhouse.compressionRatio}x
+
Active Merges
{overview.clickhouse.activeMerges}
+
+ )} + + {chData?.tenants && chData.tenants.length > 0 && ( + <> +

Per-Tenant Breakdown

+ + + + + + + + + + {chData.tenants.map((t: TenantChStats) => ( + setChDetailId(chDetailId === t.tenantId ? null : t.tenantId)} + > + + + + + ))} + +
TenantTotal RowsTables
{t.tenantId}{formatNumber(t.totalRows)} + {Object.entries(t.rowsByTable).map(([k, v]) => `${k}: ${formatNumber(v)}`).join(', ')} +
+ + )} + + {chDetailId && chDetail && ( +
+ Tables for tenant {chDetailId}: + + + + + + + + + {chDetail.map(t => ( + + + + + ))} + +
TableRows
{t.tableName}{formatNumber(t.rowCount)}
+
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/pages/vendor/InfrastructurePage.tsx +git commit -m "feat: add vendor InfrastructurePage with PG/CH per-tenant view" +``` + +--- + +### Task 13: UI — Add to sidebar and router + +**Files:** +- Modify: `ui/src/components/Layout.tsx` +- Modify: `ui/src/router.tsx` + +- [ ] **Step 1: Add sidebar entry** + +In `Layout.tsx`, add a new sidebar item in the vendor section. After the "Certificates" div (around line 95) and before the "Identity (Logto)" div, add: + +```tsx +
navigate('/vendor/infrastructure')} + > + Infrastructure +
+``` + +- [ ] **Step 2: Add route** + +In `router.tsx`, add a new route in the vendor routes section (after the `/vendor/certificates` route, around line 83). First add the lazy import near the top of the file with the other lazy imports: + +```tsx +const InfrastructurePage = lazy(() => import('./pages/vendor/InfrastructurePage')); +``` + +Then add the route: + +```tsx +}> + Loading...}> + + + +} /> +``` + +- [ ] **Step 3: Verify** + +Start the dev server. Log in as vendor admin. Verify: +- "Infrastructure" appears in vendor sidebar between "Certificates" and "Identity (Logto)" +- Clicking it navigates to `/vendor/infrastructure` +- Page loads with PostgreSQL and ClickHouse sections +- Per-tenant breakdown tables are populated +- Clicking a tenant row expands the detail view + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/components/Layout.tsx ui/src/router.tsx +git commit -m "feat: add Infrastructure to vendor sidebar and router" +``` + +--- + +## Documentation Update + +### Task 14: Update documentation + +**Files:** +- Modify: `cameleer3-server/CLAUDE.md` — mention `CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS` in the Security section and Docker Orchestration section +- Modify: `cameleer3-server/HOWTO.md` — add to Configuration table under Security +- Modify: `cameleer-saas/CLAUDE.md` — mention Infrastructure page in vendor section, add to "Per-tenant server env vars" table + +- [ ] **Step 1: Update server CLAUDE.md** + +In the Security bullet point, after the CORS line, add: +``` +Infrastructure access: `CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS=false` disables Database and ClickHouse admin endpoints (set by SaaS provisioner on tenant servers). Health endpoint exposes the flag for UI tab visibility. +``` + +- [ ] **Step 2: Update server HOWTO.md** + +Add a row to the Security configuration table: + +``` +| `cameleer.server.security.infrastructureendpoints` | `true` | `CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS` | Show DB/ClickHouse admin endpoints. Set `false` in SaaS-managed mode | +``` + +- [ ] **Step 3: Update SaaS CLAUDE.md** + +In the "Per-tenant server env vars" table, add: + +``` +| `CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS` | `false` | Hides Database/ClickHouse admin from tenant admins | +``` + +In the "Key Classes" vendor section, add `InfrastructureService.java` and `InfrastructureController.java` descriptions. + +In the Layout.tsx description, add "Infrastructure" to the vendor sidebar list. + +- [ ] **Step 4: Commit both repos** + +```bash +# Server +cd cameleer3-server +git add CLAUDE.md HOWTO.md +git commit -m "docs: document infrastructureendpoints flag" + +# SaaS +cd cameleer-saas +git add CLAUDE.md +git commit -m "docs: document vendor Infrastructure page and env var" +```