feat: add vendor infrastructure page with PG/CH per-tenant view
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -94,3 +94,88 @@ export function useVendorAuditLog(filters: AuditLogFilters) {
|
||||
queryFn: () => api.get(`/vendor/audit?${params.toString()}`),
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,6 +91,14 @@ export function Layout() {
|
||||
>
|
||||
Certificates
|
||||
</div>
|
||||
<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>
|
||||
<div
|
||||
style={{ padding: '6px 12px 6px 36px', fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}
|
||||
onClick={() => window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')}
|
||||
|
||||
393
ui/src/pages/vendor/InfrastructurePage.tsx
vendored
Normal file
393
ui/src/pages/vendor/InfrastructurePage.tsx
vendored
Normal file
@@ -0,0 +1,393 @@
|
||||
import { useState } from 'react';
|
||||
import { Spinner } from '@cameleer/design-system';
|
||||
import {
|
||||
useInfraPostgres,
|
||||
useInfraClickHouse,
|
||||
useInfraPgDetail,
|
||||
useInfraChDetail,
|
||||
} from '../../api/vendor-hooks';
|
||||
import type { TenantPgStats, TenantChStats } from '../../api/vendor-hooks';
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (n < 1024 * 1024 * 1024 * 1024) return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
return `${(n / 1024 / 1024 / 1024 / 1024).toFixed(2)} TB`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
const kpiStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
minWidth: 120,
|
||||
};
|
||||
|
||||
const kpiLabelStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
};
|
||||
|
||||
const kpiValueStyle: React.CSSProperties = {
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
color: 'var(--amber)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
};
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
textAlign: 'left',
|
||||
padding: '6px 12px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
};
|
||||
|
||||
const monoStyle: React.CSSProperties = {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
// --- PG Detail Row ---
|
||||
|
||||
function PgDetailRow({ slug }: { slug: string }) {
|
||||
const { data, isLoading } = useInfraPgDetail(slug);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ ...tdStyle, textAlign: 'center' }}>
|
||||
<Spinner size="sm" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ ...tdStyle, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
No tables found
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={5} style={{ padding: 0 }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: 'var(--surface-2)' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...thStyle, paddingLeft: 48 }}>Table</th>
|
||||
<th style={thStyle}>Rows</th>
|
||||
<th style={thStyle}>Data Size</th>
|
||||
<th style={thStyle}>Index Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((t) => (
|
||||
<tr key={t.tableName}>
|
||||
<td style={{ ...tdStyle, ...monoStyle, paddingLeft: 48 }}>{t.tableName}</td>
|
||||
<td style={tdStyle}>{formatNumber(t.rowCount)}</td>
|
||||
<td style={tdStyle}>{formatBytes(t.dataSizeBytes)}</td>
|
||||
<td style={tdStyle}>{formatBytes(t.indexSizeBytes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- CH Detail Row ---
|
||||
|
||||
function ChDetailRow({ tenantId }: { tenantId: string }) {
|
||||
const { data, isLoading } = useInfraChDetail(tenantId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={3} style={{ ...tdStyle, textAlign: 'center' }}>
|
||||
<Spinner size="sm" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={3} style={{ ...tdStyle, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
No tables found
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={3} style={{ padding: 0 }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: 'var(--surface-2)' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...thStyle, paddingLeft: 48 }}>Table</th>
|
||||
<th style={thStyle}>Rows</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((t) => (
|
||||
<tr key={t.tableName}>
|
||||
<td style={{ ...tdStyle, ...monoStyle, paddingLeft: 48 }}>{t.tableName}</td>
|
||||
<td style={tdStyle}>{formatNumber(t.rowCount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// --- PostgreSQL Tenant Row ---
|
||||
|
||||
function PgTenantRow({ tenant }: { tenant: TenantPgStats }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
>
|
||||
<td style={{ ...tdStyle, ...monoStyle }}>{tenant.slug}</td>
|
||||
<td style={tdStyle}>{formatBytes(tenant.schemaSizeBytes)}</td>
|
||||
<td style={tdStyle}>{formatNumber(tenant.tableCount)}</td>
|
||||
<td style={tdStyle}>{formatNumber(tenant.totalRows)}</td>
|
||||
<td style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: 11 }}>
|
||||
{expanded ? '▲ hide' : '▼ detail'}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && <PgDetailRow slug={tenant.slug} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- ClickHouse Tenant Row ---
|
||||
|
||||
function ChTenantRow({ tenant }: { tenant: TenantChStats }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const tablePreview = Object.entries(tenant.rowsByTable)
|
||||
.slice(0, 3)
|
||||
.map(([tbl, rows]) => `${tbl}: ${formatNumber(rows)}`)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
>
|
||||
<td style={{ ...tdStyle, ...monoStyle }}>{tenant.tenantId}</td>
|
||||
<td style={tdStyle}>{formatNumber(tenant.totalRows)}</td>
|
||||
<td style={{ ...tdStyle, color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
{tablePreview || '—'}
|
||||
{Object.keys(tenant.rowsByTable).length > 3 ? ' …' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && <ChDetailRow tenantId={tenant.tenantId} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Page ---
|
||||
|
||||
export function InfrastructurePage() {
|
||||
const pg = useInfraPostgres();
|
||||
const ch = useInfraClickHouse();
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: 'var(--surface-1)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const cardHeaderStyle: React.CSSProperties = {
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const cardTitleStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const kpiRowStyle: React.CSSProperties = {
|
||||
padding: '16px 20px',
|
||||
display: 'flex',
|
||||
gap: 32,
|
||||
flexWrap: 'wrap',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Infrastructure</h1>
|
||||
|
||||
{/* PostgreSQL Card */}
|
||||
<div style={cardStyle}>
|
||||
<div style={cardHeaderStyle}>
|
||||
<h2 style={cardTitleStyle}>PostgreSQL</h2>
|
||||
{pg.isLoading && <Spinner size="sm" />}
|
||||
{pg.isError && (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>Failed to load</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pg.data && (
|
||||
<>
|
||||
<div style={kpiRowStyle}>
|
||||
<div style={kpiStyle}>
|
||||
<span style={kpiLabelStyle}>Version</span>
|
||||
<span style={{ ...kpiValueStyle, fontSize: 14, fontFamily: 'monospace' }}>
|
||||
{pg.data.overview.version.split(' ').slice(0, 2).join(' ')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={kpiStyle}>
|
||||
<span style={kpiLabelStyle}>Database Size</span>
|
||||
<span style={kpiValueStyle}>{formatBytes(pg.data.overview.databaseSizeBytes)}</span>
|
||||
</div>
|
||||
<div style={kpiStyle}>
|
||||
<span style={kpiLabelStyle}>Active Connections</span>
|
||||
<span style={kpiValueStyle}>{formatNumber(pg.data.overview.activeConnections)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={thStyle}>Tenant Slug</th>
|
||||
<th style={thStyle}>Schema Size</th>
|
||||
<th style={thStyle}>Tables</th>
|
||||
<th style={thStyle}>Total Rows</th>
|
||||
<th style={thStyle}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pg.data.tenants.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ ...tdStyle, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
No tenant schemas found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pg.data.tenants.map((t) => (
|
||||
<PgTenantRow key={t.slug} tenant={t} />
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ClickHouse Card */}
|
||||
<div style={cardStyle}>
|
||||
<div style={cardHeaderStyle}>
|
||||
<h2 style={cardTitleStyle}>ClickHouse</h2>
|
||||
{ch.isLoading && <Spinner size="sm" />}
|
||||
{ch.isError && (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>Failed to load</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ch.data && (
|
||||
<>
|
||||
<div style={kpiRowStyle}>
|
||||
<div style={kpiStyle}>
|
||||
<span style={kpiLabelStyle}>Version</span>
|
||||
<span style={{ ...kpiValueStyle, fontSize: 14, fontFamily: 'monospace' }}>
|
||||
{ch.data.overview.version}
|
||||
</span>
|
||||
</div>
|
||||
<div style={kpiStyle}>
|
||||
<span style={kpiLabelStyle}>Uptime</span>
|
||||
<span style={kpiValueStyle}>{formatUptime(ch.data.overview.uptimeSeconds)}</span>
|
||||
</div>
|
||||
<div style={kpiStyle}>
|
||||
<span style={kpiLabelStyle}>Disk Usage</span>
|
||||
<span style={kpiValueStyle}>{formatBytes(ch.data.overview.totalDiskBytes)}</span>
|
||||
</div>
|
||||
<div style={kpiStyle}>
|
||||
<span style={kpiLabelStyle}>Total Rows</span>
|
||||
<span style={kpiValueStyle}>{formatNumber(ch.data.overview.totalRows)}</span>
|
||||
</div>
|
||||
<div style={kpiStyle}>
|
||||
<span style={kpiLabelStyle}>Compression</span>
|
||||
<span style={kpiValueStyle}>{ch.data.overview.compressionRatio.toFixed(1)}x</span>
|
||||
</div>
|
||||
<div style={kpiStyle}>
|
||||
<span style={kpiLabelStyle}>Active Merges</span>
|
||||
<span style={kpiValueStyle}>{formatNumber(ch.data.overview.activeMerges)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={thStyle}>Tenant ID</th>
|
||||
<th style={thStyle}>Total Rows</th>
|
||||
<th style={thStyle}>By Table (preview)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ch.data.tenants.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} style={{ ...tdStyle, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
No tenant data found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
ch.data.tenants.map((t) => (
|
||||
<ChTenantRow key={t.tenantId} tenant={t} />
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { CreateTenantPage } from './pages/vendor/CreateTenantPage';
|
||||
import { TenantDetailPage } from './pages/vendor/TenantDetailPage';
|
||||
import { VendorAuditPage } from './pages/vendor/VendorAuditPage';
|
||||
import { CertificatesPage } from './pages/vendor/CertificatesPage';
|
||||
import { InfrastructurePage } from './pages/vendor/InfrastructurePage';
|
||||
import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage';
|
||||
import { TenantLicensePage } from './pages/tenant/TenantLicensePage';
|
||||
import { SsoPage } from './pages/tenant/SsoPage';
|
||||
@@ -81,6 +82,11 @@ export function AppRouter() {
|
||||
<CertificatesPage />
|
||||
</RequireScope>
|
||||
} />
|
||||
<Route path="/vendor/infrastructure" element={
|
||||
<RequireScope scope="platform:admin" fallback={<Navigate to="/tenant" replace />}>
|
||||
<InfrastructurePage />
|
||||
</RequireScope>
|
||||
} />
|
||||
|
||||
{/* Tenant portal */}
|
||||
<Route path="/tenant" element={<TenantDashboardPage />} />
|
||||
|
||||
Reference in New Issue
Block a user