Files
cameleer-server/docs/superpowers/plans/2026-04-11-infrastructure-endpoint-visibility.md
hsiegeln cb3ebfea7c
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
chore: rename cameleer3 to cameleer
Rename Java packages from com.cameleer3 to com.cameleer, module
directories from cameleer3-* to cameleer-*, and all references
throughout workflows, Dockerfiles, docs, migrations, and pom.xml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:28:42 +02:00

39 KiB

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

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

      infrastructureendpoints: true
  • Step 3: Commit
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):

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;

Add before @RestController:

@ConditionalOnProperty(
    name = "cameleer.server.security.infrastructureendpoints",
    havingValue = "true",
    matchIfMissing = true
)
  • Step 2: Verify compile
mvn compile -pl cameleer-server-app -q

Expected: BUILD SUCCESS

  • Step 3: Commit
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:

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;

Add before @RestController:

@ConditionalOnProperty(
    name = "cameleer.server.security.infrastructureendpoints",
    havingValue = "true",
    matchIfMissing = true
)
  • Step 2: Verify compile
mvn compile -pl cameleer-server-app -q

Expected: BUILD SUCCESS

  • Step 3: Commit
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

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

curl -s http://localhost:8081/api/v1/health | jq '.components.serverCapabilities.details.infrastructureEndpoints'

Expected: true

  • Step 4: Commit
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:

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:

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

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

import { useServerCapabilities } from '../api/queries/capabilities';

In the component body (near line 294 where isAdmin is defined), add:

const { data: capabilities } = useServerCapabilities();

Find the useMemo that computes adminTreeNodes (around lines 434-437). It currently calls buildAdminTreeNodes() with no arguments. Update the call:

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

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:

"CAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTS=false"

This goes inside the List.of(...) block alongside the other CAMELEER_SERVER_* env vars.

  • Step 2: Verify compile
mvn compile -q

Expected: BUILD SUCCESS

  • Step 3: Commit
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

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<TenantPgStats> 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<TenantPgStats> 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<TableStats> 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<TableStats> 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
mvn compile -q

Expected: BUILD SUCCESS

  • Step 3: Commit
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:

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<String, Long> rowsByTable) {}

public record ChTableStats(String tableName, long rowCount) {}
  • Step 2: Add ClickHouse methods

Add these methods after getPostgresTenantDetail:

// --- 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<TenantChStats> getClickHouseTenantStats() {
    // Query the main data tables that have tenant_id column
    String[] tables = {"executions", "processor_executions", "logs", "agent_events", "usage_events"};
    Map<String, Map<String, Long>> 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<ChTableStats> getClickHouseTenantDetail(String tenantId) {
    String[] tables = {"executions", "processor_executions", "logs", "agent_events", "usage_events"};
    List<ChTableStats> 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
mvn compile -q

Expected: BUILD SUCCESS

  • Step 4: Commit
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

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<InfraOverviewResponse> overview() {
        return ResponseEntity.ok(new InfraOverviewResponse(
                infraService.getPostgresOverview(),
                infraService.getClickHouseOverview()));
    }

    @GetMapping("/postgres")
    public ResponseEntity<Map<String, Object>> postgres() {
        return ResponseEntity.ok(Map.of(
                "overview", infraService.getPostgresOverview(),
                "tenants", infraService.getPostgresTenantStats()));
    }

    @GetMapping("/postgres/{slug}")
    public ResponseEntity<List<InfrastructureService.TableStats>> postgresDetail(
            @PathVariable String slug) {
        return ResponseEntity.ok(infraService.getPostgresTenantDetail(slug));
    }

    @GetMapping("/clickhouse")
    public ResponseEntity<Map<String, Object>> clickhouse() {
        return ResponseEntity.ok(Map.of(
                "overview", infraService.getClickHouseOverview(),
                "tenants", infraService.getClickHouseTenantStats()));
    }

    @GetMapping("/clickhouse/{tenantId}")
    public ResponseEntity<List<InfrastructureService.ChTableStats>> clickhouseDetail(
            @PathVariable String tenantId) {
        return ResponseEntity.ok(infraService.getClickHouseTenantDetail(tenantId));
    }
}
  • Step 2: Verify compile
mvn compile -q

Expected: BUILD SUCCESS

  • Step 3: Commit
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:

// --- 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<string, number>;
}

export interface ChTableStats {
  tableName: string;
  rowCount: number;
}

export interface InfraOverview {
  postgres: PostgresOverview;
  clickhouse: ClickHouseOverview;
}

export function useInfraOverview() {
  return useQuery<InfraOverview>({
    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<TableStats[]>({
    queryKey: ['vendor', 'infrastructure', 'postgres', slug],
    queryFn: () => api.get(`/vendor/infrastructure/postgres/${slug}`),
    enabled: !!slug,
  });
}

export function useInfraChDetail(tenantId: string) {
  return useQuery<ChTableStats[]>({
    queryKey: ['vendor', 'infrastructure', 'clickhouse', tenantId],
    queryFn: () => api.get(`/vendor/infrastructure/clickhouse/${tenantId}`),
    enabled: !!tenantId,
  });
}
  • Step 2: Commit
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

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<string | null>(null);
  const [chDetailId, setChDetailId] = useState<string | null>(null);
  const { data: pgDetail } = useInfraPgDetail(pgDetailSlug ?? '');
  const { data: chDetail } = useInfraChDetail(chDetailId ?? '');

  if (isLoading) return <div style={{ padding: 24 }}>Loading...</div>;

  return (
    <div style={{ padding: 24, maxWidth: 1200 }}>
      <h2 style={{ margin: '0 0 24px', fontSize: 20, fontWeight: 600 }}>Infrastructure</h2>

      {/* PostgreSQL */}
      <div style={{ background: 'var(--surface-1)', borderRadius: 8, padding: 20, marginBottom: 20, border: '1px solid var(--border)' }}>
        <h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600 }}>PostgreSQL</h3>

        {overview?.postgres && (
          <div style={{ display: 'flex', gap: 32, marginBottom: 16 }}>
            <div><span style={{ color: 'var(--text-muted)', fontSize: 12 }}>Version</span><br />{overview.postgres.version.split(' ').slice(0, 2).join(' ')}</div>
            <div><span style={{ color: 'var(--text-muted)', fontSize: 12 }}>Database Size</span><br />{formatBytes(overview.postgres.databaseSizeBytes)}</div>
            <div><span style={{ color: 'var(--text-muted)', fontSize: 12 }}>Active Connections</span><br />{overview.postgres.activeConnections}</div>
          </div>
        )}

        {pgData?.tenants && pgData.tenants.length > 0 && (
          <>
            <h4 style={{ margin: '16px 0 8px', fontSize: 13, fontWeight: 600, color: 'var(--text-muted)' }}>Per-Tenant Breakdown</h4>
            <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
              <thead>
                <tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
                  <th style={{ padding: '6px 8px' }}>Tenant</th>
                  <th style={{ padding: '6px 8px' }}>Schema Size</th>
                  <th style={{ padding: '6px 8px' }}>Tables</th>
                  <th style={{ padding: '6px 8px' }}>Rows</th>
                </tr>
              </thead>
              <tbody>
                {pgData.tenants.map((t: TenantPgStats) => (
                  <tr
                    key={t.slug}
                    style={{ borderBottom: '1px solid var(--border)', cursor: 'pointer' }}
                    onClick={() => setPgDetailSlug(pgDetailSlug === t.slug ? null : t.slug)}
                  >
                    <td style={{ padding: '6px 8px', fontWeight: 500 }}>{t.slug}</td>
                    <td style={{ padding: '6px 8px' }}>{formatBytes(t.schemaSizeBytes)}</td>
                    <td style={{ padding: '6px 8px' }}>{t.tableCount}</td>
                    <td style={{ padding: '6px 8px' }}>{formatNumber(t.totalRows)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </>
        )}

        {pgDetailSlug && pgDetail && (
          <div style={{ marginTop: 12, padding: 12, background: 'var(--surface-2)', borderRadius: 6, fontSize: 12 }}>
            <strong>Tables in tenant_{pgDetailSlug}:</strong>
            <table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 8 }}>
              <thead>
                <tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
                  <th style={{ padding: '4px 8px' }}>Table</th>
                  <th style={{ padding: '4px 8px' }}>Rows</th>
                  <th style={{ padding: '4px 8px' }}>Data</th>
                  <th style={{ padding: '4px 8px' }}>Index</th>
                </tr>
              </thead>
              <tbody>
                {pgDetail.map(t => (
                  <tr key={t.tableName} style={{ borderBottom: '1px solid var(--border)' }}>
                    <td style={{ padding: '4px 8px' }}>{t.tableName}</td>
                    <td style={{ padding: '4px 8px' }}>{formatNumber(t.rowCount)}</td>
                    <td style={{ padding: '4px 8px' }}>{formatBytes(t.dataSizeBytes)}</td>
                    <td style={{ padding: '4px 8px' }}>{formatBytes(t.indexSizeBytes)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </div>

      {/* ClickHouse */}
      <div style={{ background: 'var(--surface-1)', borderRadius: 8, padding: 20, border: '1px solid var(--border)' }}>
        <h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600 }}>ClickHouse</h3>

        {overview?.clickhouse && (
          <div style={{ display: 'flex', gap: 32, marginBottom: 16 }}>
            <div><span style={{ color: 'var(--text-muted)', fontSize: 12 }}>Version</span><br />{overview.clickhouse.version}</div>
            <div><span style={{ color: 'var(--text-muted)', fontSize: 12 }}>Uptime</span><br />{formatUptime(overview.clickhouse.uptimeSeconds)}</div>
            <div><span style={{ color: 'var(--text-muted)', fontSize: 12 }}>Disk Usage</span><br />{formatBytes(overview.clickhouse.totalDiskBytes)}</div>
            <div><span style={{ color: 'var(--text-muted)', fontSize: 12 }}>Total Rows</span><br />{formatNumber(overview.clickhouse.totalRows)}</div>
            <div><span style={{ color: 'var(--text-muted)', fontSize: 12 }}>Compression</span><br />{overview.clickhouse.compressionRatio}x</div>
            <div><span style={{ color: 'var(--text-muted)', fontSize: 12 }}>Active Merges</span><br />{overview.clickhouse.activeMerges}</div>
          </div>
        )}

        {chData?.tenants && chData.tenants.length > 0 && (
          <>
            <h4 style={{ margin: '16px 0 8px', fontSize: 13, fontWeight: 600, color: 'var(--text-muted)' }}>Per-Tenant Breakdown</h4>
            <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
              <thead>
                <tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
                  <th style={{ padding: '6px 8px' }}>Tenant</th>
                  <th style={{ padding: '6px 8px' }}>Total Rows</th>
                  <th style={{ padding: '6px 8px' }}>Tables</th>
                </tr>
              </thead>
              <tbody>
                {chData.tenants.map((t: TenantChStats) => (
                  <tr
                    key={t.tenantId}
                    style={{ borderBottom: '1px solid var(--border)', cursor: 'pointer' }}
                    onClick={() => setChDetailId(chDetailId === t.tenantId ? null : t.tenantId)}
                  >
                    <td style={{ padding: '6px 8px', fontWeight: 500 }}>{t.tenantId}</td>
                    <td style={{ padding: '6px 8px' }}>{formatNumber(t.totalRows)}</td>
                    <td style={{ padding: '6px 8px', color: 'var(--text-muted)' }}>
                      {Object.entries(t.rowsByTable).map(([k, v]) => `${k}: ${formatNumber(v)}`).join(', ')}
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </>
        )}

        {chDetailId && chDetail && (
          <div style={{ marginTop: 12, padding: 12, background: 'var(--surface-2)', borderRadius: 6, fontSize: 12 }}>
            <strong>Tables for tenant {chDetailId}:</strong>
            <table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 8 }}>
              <thead>
                <tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
                  <th style={{ padding: '4px 8px' }}>Table</th>
                  <th style={{ padding: '4px 8px' }}>Rows</th>
                </tr>
              </thead>
              <tbody>
                {chDetail.map(t => (
                  <tr key={t.tableName} style={{ borderBottom: '1px solid var(--border)' }}>
                    <td style={{ padding: '4px 8px' }}>{t.tableName}</td>
                    <td style={{ padding: '4px 8px' }}>{formatNumber(t.rowCount)}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </div>
    </div>
  );
}
  • Step 2: Commit
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:

    <div
      style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer',
        fontWeight: isActive(location, '/vendor/infrastructure') ? 600 : 400,
        color: isActive(location, '/vendor/infrastructure') ? 'var(--amber)' : 'var(--text-muted)' }}
      onClick={() => navigate('/vendor/infrastructure')}
    >
      Infrastructure
    </div>
  • 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:

const InfrastructurePage = lazy(() => import('./pages/vendor/InfrastructurePage'));

Then add the route:

<Route path="/vendor/infrastructure" element={
  <RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
    <Suspense fallback={<div>Loading...</div>}>
      <InfrastructurePage />
    </Suspense>
  </RequireScope>
} />
  • 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

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
# 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"