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:
hsiegeln
2026-04-11 23:18:59 +02:00
parent 95a92ae9e5
commit 92503a1061
4 changed files with 492 additions and 0 deletions

View 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>
);
}