# 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 `cameleer-server`, Tasks 7-13 in `cameleer-saas`. --- ## Part 1: Server — Disable Infrastructure Endpoints ### Task 1: Add property to application.yml **Files:** - Modify: `cameleer-server-app/src/main/resources/application.yml` - Modify: `cameleer-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 cameleer-server-app/src/main/resources/application.yml cameleer-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: `cameleer-server-app/src/main/java/com/cameleer/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 cameleer-server-app -q ``` Expected: BUILD SUCCESS - [ ] **Step 3: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DatabaseAdminController.java git commit -m "feat: make DatabaseAdminController conditional on infrastructureendpoints" ``` --- ### Task 3: Add @ConditionalOnProperty to ClickHouseAdminController **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/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 cameleer-server-app -q ``` Expected: BUILD SUCCESS - [ ] **Step 3: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/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: `cameleer-server-app/src/main/java/com/cameleer/server/app/config/ServerCapabilitiesHealthIndicator.java` - [ ] **Step 1: Create the health indicator** ```java package com.cameleer.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 cameleer-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 cameleer-server-app/src/main/java/com/cameleer/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)} > ))}
Tenant Schema Size Tables Rows
{t.slug} {formatBytes(t.schemaSizeBytes)} {t.tableCount} {formatNumber(t.totalRows)}
)} {pgDetailSlug && pgDetail && (
Tables in tenant_{pgDetailSlug}: {pgDetail.map(t => ( ))}
Table Rows Data Index
{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)} > ))}
Tenant Total Rows Tables
{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 => ( ))}
Table Rows
{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: `cameleer-server/CLAUDE.md` — mention `CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS` in the Security section and Docker Orchestration section - Modify: `cameleer-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 cameleer-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" ```