163 lines
10 KiB
TypeScript
163 lines
10 KiB
TypeScript
import { useCallback, useEffect, useId, useState } from 'react';
|
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
|
import type { DbStructureResponse, DbStructureTable } from '../../domain/db-structure';
|
|
import { TriangleIcon } from '../../components/TriangleIcon';
|
|
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
|
|
|
|
const assessorApi = new AssessorApiClient();
|
|
|
|
type BackendStatus = 'connected' | 'checking' | 'unavailable';
|
|
interface DbStructurePanelProps {
|
|
backendStatus: BackendStatus;
|
|
}
|
|
|
|
const LAYER_ORDER = ['import', 'mapping', 'canonical', 'result', 'governance', 'core', 'other'] as const;
|
|
type LayerId = (typeof LAYER_ORDER)[number];
|
|
const LAYER_INFO: Record<LayerId, { kicker: string; label: string; description: string }> = {
|
|
import: { kicker: 'Layer 1', label: 'Import & ingestion', description: 'Source files, ingestion jobs and immutable import evidence.' },
|
|
mapping: { kicker: 'Layer 2', label: 'Mapping & normalization', description: 'Recognition, mapping and normalization contracts between source and domain data.' },
|
|
canonical: { kicker: 'Layer 3', label: 'Canonical model', description: 'Stable assessments, findings and environment context independent of source tools.' },
|
|
result: { kicker: 'Layer 4', label: 'Result layer', description: 'Read models and result projections used by Work, filters and exports.' },
|
|
governance: { kicker: 'Supporting layer', label: 'Governance & quality', description: 'Reviews, validation, processing compatibility and quality controls.' },
|
|
core: { kicker: 'Foundation', label: 'Core management', description: 'Projects, scan runs and shared lifecycle state.' },
|
|
other: { kicker: 'Supporting layer', label: 'Other', description: 'Additional relational structures outside the primary processing layers.' }
|
|
};
|
|
|
|
function groupByLayer(tables: DbStructureTable[]): Array<{ layer: LayerId; tables: DbStructureTable[] }> {
|
|
return LAYER_ORDER
|
|
.map(layer => ({ layer, tables: tables.filter(table => table.layer === layer) }))
|
|
.filter(group => group.tables.length > 0);
|
|
}
|
|
|
|
export function summarizeDbStructure(tables: DbStructureTable[]) {
|
|
const countedTables = tables.filter(table => table.approximateRowCount !== null);
|
|
return {
|
|
tables: tables.length,
|
|
columns: tables.reduce((total, table) => total + table.columns.length, 0),
|
|
relationships: tables.reduce((total, table) => total + table.foreignKeys.length, 0),
|
|
approximateRows: countedTables.reduce((total, table) => total + (table.approximateRowCount ?? 0), 0),
|
|
countedTables: countedTables.length
|
|
};
|
|
}
|
|
|
|
function DbStructureTableCard({ table, isOpen, onToggle }: { table: DbStructureTable; isOpen: boolean; onToggle: () => void }) {
|
|
const detailsId = useId();
|
|
return <article className={`db-structure-card${isOpen ? ' is-expanded' : ''}`}>
|
|
<button className="db-structure-card-header" type="button" onClick={onToggle} aria-expanded={isOpen} aria-controls={detailsId}>
|
|
<span className="db-structure-card-copy">
|
|
<span className="db-structure-table-kind">Table</span>
|
|
<strong>{table.name}</strong>
|
|
<span className="db-structure-card-facts">
|
|
<span>{table.columns.length.toLocaleString('en-US')} columns</span>
|
|
<span>{table.foreignKeys.length.toLocaleString('en-US')} relations</span>
|
|
{table.approximateRowCount !== null && <span>~{table.approximateRowCount.toLocaleString('en-US')} rows</span>}
|
|
</span>
|
|
</span>
|
|
<span className="db-structure-card-toggle" aria-hidden="true"><TriangleIcon direction={isOpen ? 'down' : 'right'} /></span>
|
|
</button>
|
|
{isOpen && <div className="db-structure-card-body" id={detailsId}>
|
|
<section className="db-structure-card-section" aria-label={`Columns in ${table.name}`}>
|
|
<div className="db-structure-detail-heading"><h4>Columns</h4><span>{table.columns.length.toLocaleString('en-US')}</span></div>
|
|
<div className="db-structure-column-head" aria-hidden="true"><span>Column</span><span>Type</span><span>Constraint</span></div>
|
|
<ul className="db-structure-columns">
|
|
{table.columns.map(column => <li key={column.name}>
|
|
<strong>{column.name}</strong>
|
|
<span>{column.dataType}</span>
|
|
<small>{column.nullable ? 'Nullable' : 'Required'}</small>
|
|
</li>)}
|
|
</ul>
|
|
</section>
|
|
<section className="db-structure-card-section db-structure-relations" aria-label={`Relationships from ${table.name}`}>
|
|
<div className="db-structure-detail-heading"><h4>Relationships</h4><span>{table.foreignKeys.length.toLocaleString('en-US')}</span></div>
|
|
{table.foreignKeys.length > 0 ? <ul className="db-structure-fks">
|
|
{table.foreignKeys.map(fk => <li key={`${fk.column}-${fk.referencedTable}-${fk.referencedColumn}`}>
|
|
<strong>{fk.column}</strong><span aria-hidden="true">→</span><span>{fk.referencedTable}.{fk.referencedColumn}</span>
|
|
</li>)}
|
|
</ul> : <p>No outgoing relationships.</p>}
|
|
</section>
|
|
</div>}
|
|
</article>;
|
|
}
|
|
|
|
export function DbStructurePanel({ backendStatus }: DbStructurePanelProps) {
|
|
const [structure, setStructure] = useState<DbStructureResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
|
|
const load = useCallback(async () => {
|
|
if (backendStatus !== 'connected') {
|
|
setStructure(null);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
setStructure(await assessorApi.getDatabaseStructure());
|
|
} catch (reason) {
|
|
setError(reason instanceof Error ? reason.message : 'Database structure could not be loaded.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [backendStatus]);
|
|
|
|
useEffect(() => { void load(); }, [load]);
|
|
|
|
if (backendStatus !== 'connected') {
|
|
return <div className="datahub-page">
|
|
<section className="datahub-offline" role="status"><div className="datahub-offline-icon" aria-hidden="true">!</div><div><h3>{backendStatus === 'checking' ? 'Checking server connection' : 'Backend is unreachable'}</h3><p>The database structure will be available again once the local API is connected.</p></div></section>
|
|
</div>;
|
|
}
|
|
|
|
const groups = structure ? groupByLayer(structure.tables) : [];
|
|
const metrics = structure ? summarizeDbStructure(structure.tables) : undefined;
|
|
const allExpanded = Boolean(structure?.tables.length) && structure!.tables.every(table => expanded[table.name]);
|
|
const setAllExpanded = (open: boolean) => {
|
|
if (!structure) return;
|
|
setExpanded(Object.fromEntries(structure.tables.map(table => [table.name, open])));
|
|
};
|
|
|
|
return <div className="datahub-page db-structure-page">
|
|
{error && <div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => void load()}>Reload</button></div>}
|
|
{loading && !structure ? <PanelLoadingState message="Loading database structure …"/> : structure && <>
|
|
<header className="db-structure-intro">
|
|
<div className="db-structure-intro-copy">
|
|
<span className="db-structure-eyebrow">Database dictionary</span>
|
|
<h2>Relational schema</h2>
|
|
<p>Explore how imported evidence moves through normalization into canonical and result data. Open any table to inspect its columns, constraints and relationships.</p>
|
|
</div>
|
|
<div className="db-structure-intro-actions">
|
|
<span className={`db-structure-persistence is-${structure.persistence}`}><i aria-hidden="true" />{structure.persistence === 'postgresql' ? 'PostgreSQL live schema' : 'Memory mode'}</span>
|
|
<button className="btn primary" type="button" disabled={loading} onClick={() => void load()}>{loading ? 'Loading …' : 'Refresh schema'}</button>
|
|
</div>
|
|
</header>
|
|
{metrics && <section className="db-structure-summary" aria-label="Database structure summary">
|
|
<article><span>Tables</span><strong>{metrics.tables.toLocaleString('en-US')}</strong><small>Across {groups.length.toLocaleString('en-US')} active layers</small></article>
|
|
<article><span>Columns</span><strong>{metrics.columns.toLocaleString('en-US')}</strong><small>Typed schema fields</small></article>
|
|
<article><span>Relationships</span><strong>{metrics.relationships.toLocaleString('en-US')}</strong><small>Declared foreign keys</small></article>
|
|
<article><span>Approximate rows</span><strong>{metrics.countedTables > 0 ? metrics.approximateRows.toLocaleString('en-US') : '—'}</strong><small>{metrics.countedTables > 0 ? `Reported by ${metrics.countedTables.toLocaleString('en-US')} tables` : 'No estimates reported'}</small></article>
|
|
</section>}
|
|
<div className="db-structure-toolbar">
|
|
<p className="db-structure-meta">Schema snapshot <time dateTime={structure.generatedAt}>{new Date(structure.generatedAt).toLocaleString('en-US')}</time></p>
|
|
{structure.tables.length > 0 && <button className="btn" type="button" onClick={() => setAllExpanded(!allExpanded)}>{allExpanded ? 'Collapse all' : 'Expand all'}</button>}
|
|
</div>
|
|
{structure.tables.length === 0 ? <div className="datahub-empty"><h3>No tables found</h3><p>The current persistence mode has no relational schema available to introspect.</p></div> : <div className="db-structure-layers">
|
|
{groups.map(group => <section className="db-structure-layer" key={group.layer}>
|
|
<header className="db-structure-layer-header">
|
|
<div><span>{LAYER_INFO[group.layer].kicker}</span><h3>{LAYER_INFO[group.layer].label}</h3><p>{LAYER_INFO[group.layer].description}</p></div>
|
|
<strong>{group.tables.length.toLocaleString('en-US')} {group.tables.length === 1 ? 'table' : 'tables'}</strong>
|
|
</header>
|
|
<div className="db-structure-grid">
|
|
{group.tables.map(table => {
|
|
const key = table.name;
|
|
const isOpen = expanded[key] ?? false;
|
|
return <DbStructureTableCard key={key} table={table} isOpen={isOpen} onToggle={() => setExpanded(current => ({ ...current, [key]: !isOpen }))} />;
|
|
})}
|
|
</div>
|
|
</section>)}
|
|
</div>}
|
|
</>}
|
|
</div>;
|
|
}
|