Assessor_2.0/src/features/db-structure/DbStructurePanel.tsx

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