Files
cameleer-server/docs/superpowers/plans/2026-04-11-infrastructure-endpoint-visibility.md

1164 lines
39 KiB
Markdown
Raw Normal View History

# 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<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**
```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<String, Long> 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<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**
```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<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**
```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<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**
```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<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**
```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
<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:
```tsx
const InfrastructurePage = lazy(() => import('./pages/vendor/InfrastructurePage'));
```
Then add the route:
```tsx
<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**
```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"
```