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>
1164 lines
39 KiB
Markdown
1164 lines
39 KiB
Markdown
# 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"
|
|
```
|