diff --git a/ui/src/api/vendor-hooks.ts b/ui/src/api/vendor-hooks.ts index 9c8d1a9..7bcacdd 100644 --- a/ui/src/api/vendor-hooks.ts +++ b/ui/src/api/vendor-hooks.ts @@ -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; +} + +export interface ChTableStats { + tableName: string; + rowCount: number; +} + +export interface InfraOverview { + postgres: PostgresOverview; + clickhouse: ClickHouseOverview; +} + +export function useInfraOverview() { + return useQuery({ + 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({ + queryKey: ['vendor', 'infrastructure', 'postgres', slug], + queryFn: () => api.get(`/vendor/infrastructure/postgres/${slug}`), + enabled: !!slug, + }); +} + +export function useInfraChDetail(tenantId: string) { + return useQuery({ + queryKey: ['vendor', 'infrastructure', 'clickhouse', tenantId], + queryFn: () => api.get(`/vendor/infrastructure/clickhouse/${tenantId}`), + enabled: !!tenantId, + }); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index bf586b1..329795e 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -91,6 +91,14 @@ export function Layout() { > Certificates +
navigate('/vendor/infrastructure')} + > + Infrastructure +
window.open(`${window.location.protocol}//${window.location.hostname}:3002`, '_blank', 'noopener')} diff --git a/ui/src/pages/vendor/InfrastructurePage.tsx b/ui/src/pages/vendor/InfrastructurePage.tsx new file mode 100644 index 0000000..957cf6d --- /dev/null +++ b/ui/src/pages/vendor/InfrastructurePage.tsx @@ -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 ( + + + + + + ); + } + if (!data || data.length === 0) { + return ( + + + No tables found + + + ); + } + + return ( + <> + + + + + + + + + + + + + {data.map((t) => ( + + + + + + + ))} + +
TableRowsData SizeIndex Size
{t.tableName}{formatNumber(t.rowCount)}{formatBytes(t.dataSizeBytes)}{formatBytes(t.indexSizeBytes)}
+ + + + ); +} + +// --- CH Detail Row --- + +function ChDetailRow({ tenantId }: { tenantId: string }) { + const { data, isLoading } = useInfraChDetail(tenantId); + + if (isLoading) { + return ( + + + + + + ); + } + if (!data || data.length === 0) { + return ( + + + No tables found + + + ); + } + + return ( + + + + + + + + + + + {data.map((t) => ( + + + + + ))} + +
TableRows
{t.tableName}{formatNumber(t.rowCount)}
+ + + ); +} + +// --- PostgreSQL Tenant Row --- + +function PgTenantRow({ tenant }: { tenant: TenantPgStats }) { + const [expanded, setExpanded] = useState(false); + + return ( + <> + setExpanded((e) => !e)} + > + {tenant.slug} + {formatBytes(tenant.schemaSizeBytes)} + {formatNumber(tenant.tableCount)} + {formatNumber(tenant.totalRows)} + + {expanded ? '▲ hide' : '▼ detail'} + + + {expanded && } + + ); +} + +// --- 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 ( + <> + setExpanded((e) => !e)} + > + {tenant.tenantId} + {formatNumber(tenant.totalRows)} + + {tablePreview || '—'} + {Object.keys(tenant.rowsByTable).length > 3 ? ' …' : ''} + + + {expanded && } + + ); +} + +// --- 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 ( +
+

Infrastructure

+ + {/* PostgreSQL Card */} +
+
+

PostgreSQL

+ {pg.isLoading && } + {pg.isError && ( + Failed to load + )} +
+ + {pg.data && ( + <> +
+
+ Version + + {pg.data.overview.version.split(' ').slice(0, 2).join(' ')} + +
+
+ Database Size + {formatBytes(pg.data.overview.databaseSizeBytes)} +
+
+ Active Connections + {formatNumber(pg.data.overview.activeConnections)} +
+
+ + + + + + + + + + + + + {pg.data.tenants.length === 0 ? ( + + + + ) : ( + pg.data.tenants.map((t) => ( + + )) + )} + +
Tenant SlugSchema SizeTablesTotal Rows
+ No tenant schemas found +
+ + )} +
+ + {/* ClickHouse Card */} +
+
+

ClickHouse

+ {ch.isLoading && } + {ch.isError && ( + Failed to load + )} +
+ + {ch.data && ( + <> +
+
+ Version + + {ch.data.overview.version} + +
+
+ Uptime + {formatUptime(ch.data.overview.uptimeSeconds)} +
+
+ Disk Usage + {formatBytes(ch.data.overview.totalDiskBytes)} +
+
+ Total Rows + {formatNumber(ch.data.overview.totalRows)} +
+
+ Compression + {ch.data.overview.compressionRatio.toFixed(1)}x +
+
+ Active Merges + {formatNumber(ch.data.overview.activeMerges)} +
+
+ + + + + + + + + + + {ch.data.tenants.length === 0 ? ( + + + + ) : ( + ch.data.tenants.map((t) => ( + + )) + )} + +
Tenant IDTotal RowsBy Table (preview)
+ No tenant data found +
+ + )} +
+
+ ); +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 3c9f930..cf3fcd1 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -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() { } /> + }> + + + } /> {/* Tenant portal */} } />