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>
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— mentionCAMELEER_SERVER_SECURITY_INFRASTRUCTUREENDPOINTSin 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"