ui-nyla/src/pages/admin/AdminDatabaseHealthPage.tsx
2026-04-26 22:53:39 +02:00

758 lines
25 KiB
TypeScript

/**
* AdminDatabaseHealthPage
*
* SysAdmin-only page with two tabs:
* 1. Table Statistics — pg_stat data for every table across all databases
* 2. Orphan Cleanup — FK orphan detection with per-relation + batch cleanup
*
* Both tabs use FormGeneratorTable with a client-side pagination/sort/filter
* adapter (the backend returns all rows at once; the dataset is small enough).
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle, FaDownload } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { useToast } from '../../contexts/ToastContext';
import { useConfirm } from '../../hooks/useConfirm';
import { Tabs } from '../../components/UiComponents/Tabs/Tabs';
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface TableStat {
id: string;
db: string;
table: string;
estimatedRows: number;
totalSizeBytes: number;
indexSizeBytes: number;
lastVacuum: string | null;
lastAnalyze: string | null;
}
interface OrphanEntry {
id: string;
sourceDb: string;
sourceTable: string;
sourceColumn: string;
targetDb: string;
targetTable: string;
targetColumn: string;
orphanCount: number;
sourceRowCount?: number;
targetRowCount?: number;
targetEmpty?: boolean;
wouldDeleteAll?: boolean;
}
interface CleanResult {
db: string;
table: string;
column: string;
deleted: number;
error?: string;
skipped?: string;
}
interface PaginationParams {
page?: number;
pageSize?: number;
search?: string;
filters?: Record<string, any>;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
}
interface PaginationMeta {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function _formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function _formatNumber(n: number): string {
return n.toLocaleString('de-CH');
}
// ---------------------------------------------------------------------------
// useClientPagination — adapts a static array to FormGeneratorTable's
// hookData.refetch / hookData.pagination contract.
// ---------------------------------------------------------------------------
function _useClientPagination<T extends Record<string, any>>(allData: T[]) {
const [visibleData, setVisibleData] = useState<T[]>([]);
const [pagination, setPagination] = useState<PaginationMeta>({
currentPage: 1, pageSize: 50, totalItems: 0, totalPages: 1,
});
const allDataRef = useRef(allData);
allDataRef.current = allData;
const lastParamsRef = useRef<PaginationParams>({});
const fetchFilterValues = useCallback(async (columnKey: string, crossFilters?: Record<string, any>) => {
let source = allDataRef.current;
if (crossFilters && Object.keys(crossFilters).length > 0) {
source = source.filter(row => {
for (const [key, val] of Object.entries(crossFilters)) {
if (val === undefined || val === null || val === '') continue;
const cell = String(row[key] ?? '');
if (Array.isArray(val)) {
if (val.length > 0 && !val.includes(cell)) return false;
} else {
if (cell !== String(val)) return false;
}
}
return true;
});
}
const seen = new Set<string>();
for (const row of source) {
const v = row[columnKey];
if (v !== undefined && v !== null && String(v).trim()) {
seen.add(String(v));
}
}
return Array.from(seen).sort();
}, []);
const refetch = useCallback(async (params?: PaginationParams) => {
const p = params || lastParamsRef.current;
lastParamsRef.current = p;
const source = allDataRef.current;
const page = p.page || 1;
const pageSize = p.pageSize || 50;
const search = (p.search || '').toLowerCase();
const filters = p.filters || {};
const sorts = p.sort || [];
// 1) Filter
let filtered = source.filter(row => {
for (const [key, val] of Object.entries(filters)) {
if (val === undefined || val === null || val === '') continue;
const cell = String(row[key] ?? '');
if (Array.isArray(val)) {
if (val.length > 0 && !val.includes(cell)) return false;
} else {
if (cell !== String(val)) return false;
}
}
return true;
});
// 2) Search
if (search) {
filtered = filtered.filter(row =>
Object.values(row).some(v => String(v ?? '').toLowerCase().includes(search)),
);
}
// 3) Sort
if (sorts.length > 0) {
filtered.sort((a, b) => {
for (const s of sorts) {
const aVal = a[s.field];
const bVal = b[s.field];
let cmp = 0;
if (typeof aVal === 'number' && typeof bVal === 'number') {
cmp = aVal - bVal;
} else {
cmp = String(aVal ?? '').localeCompare(String(bVal ?? ''));
}
if (cmp !== 0) return s.direction === 'desc' ? -cmp : cmp;
}
return 0;
});
}
// 4) Paginate
const totalItems = filtered.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
const paged = filtered.slice(start, start + pageSize);
setVisibleData(paged);
setPagination({ currentPage: safePage, pageSize, totalItems, totalPages });
}, []);
// Re-apply whenever allData changes
useEffect(() => {
refetch(lastParamsRef.current);
}, [allData, refetch]);
return { visibleData, pagination, refetch, fetchFilterValues };
}
// ---------------------------------------------------------------------------
// StatsTab
// ---------------------------------------------------------------------------
const StatsTab: React.FC = () => {
const { t } = useLanguage();
const [allStats, setAllStats] = useState<TableStat[]>([]);
const [loading, setLoading] = useState(false);
const [dbFilter, setDbFilter] = useState<string>('');
const _fetchStats = useCallback(async () => {
try {
setLoading(true);
const params = dbFilter ? `?db=${encodeURIComponent(dbFilter)}` : '';
const res = await api.get(`/api/admin/database-health/stats${params}`);
const rows = (res.data.stats || []).map((s: any, i: number) => ({
...s,
id: `${s.db}-${s.table}-${i}`,
}));
setAllStats(rows);
} catch {
setAllStats([]);
} finally {
setLoading(false);
}
}, [dbFilter]);
useEffect(() => { _fetchStats(); }, [_fetchStats]);
const { visibleData, pagination, refetch, fetchFilterValues } = _useClientPagination(allStats);
const databases = useMemo(
() => Array.from(new Set(allStats.map(s => s.db))).sort(),
[allStats],
);
const totals = useMemo(() => {
let rows = 0, size = 0, idx = 0;
for (const s of allStats) {
rows += s.estimatedRows;
size += s.totalSizeBytes;
idx += s.indexSizeBytes;
}
return { rows, size, idx, tables: allStats.length, dbs: databases.length };
}, [allStats, databases]);
const columns: ColumnConfig[] = useMemo(() => [
{
key: 'db',
label: t('Datenbank'),
sortable: true,
filterable: true,
searchable: true,
width: 200,
filterOptions: databases,
},
{
key: 'table',
label: t('Tabelle'),
sortable: true,
searchable: true,
width: 200,
},
{
key: 'estimatedRows',
label: t('Zeilen (ca.)'),
type: 'number',
sortable: true,
width: 120,
formatter: (v: number) => _formatNumber(v),
},
{
key: 'totalSizeBytes',
label: t('Total Size'),
type: 'number',
sortable: true,
width: 120,
formatter: (v: number) => _formatBytes(v),
},
{
key: 'indexSizeBytes',
label: t('Index Size'),
type: 'number',
sortable: true,
width: 120,
formatter: (v: number) => _formatBytes(v),
},
{
key: 'lastVacuum',
label: t('Last Vacuum'),
sortable: true,
width: 170,
formatter: (v: string | null) => v ?? '—',
},
{
key: 'lastAnalyze',
label: t('Last Analyze'),
sortable: true,
width: 170,
formatter: (v: string | null) => v ?? '—',
},
], [t, databases]);
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
{/* Controls */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Datenbank')}</label>
<select
className={styles.filterSelect}
value={dbFilter}
onChange={e => setDbFilter(e.target.value)}
>
<option value="">{t('Alle')}</option>
{databases.map(db => <option key={db} value={db}>{db}</option>)}
</select>
</div>
<button className={styles.secondaryButton} onClick={_fetchStats} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
{/* Summary */}
<div className={styles.filterSection} style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
<span className={styles.filterLabel}>{t('{dbs} Datenbanken', { dbs: totals.dbs })}</span>
<span className={styles.filterLabel}>{t('{tables} Tabellen', { tables: totals.tables })}</span>
<span className={styles.filterLabel}>{t('{rows} Zeilen (ca.)', { rows: _formatNumber(totals.rows) })}</span>
<span className={styles.filterLabel}>{t('Total {size}', { size: _formatBytes(totals.size) })}</span>
<span className={styles.filterLabel}>{t('Index {size}', { size: _formatBytes(totals.idx) })}</span>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable
data={visibleData}
columns={columns}
loading={loading}
sortable={true}
searchable={true}
filterable={true}
pagination={true}
pageSize={50}
selectable={false}
hookData={{
refetch,
pagination,
fetchFilterValues,
}}
emptyMessage={t('Keine Tabellen gefunden')}
/>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// OrphansTab
// ---------------------------------------------------------------------------
const OrphansTab: React.FC = () => {
const { t } = useLanguage();
const toast = useToast();
const { confirm, ConfirmDialog } = useConfirm();
const [allOrphans, setAllOrphans] = useState<OrphanEntry[]>([]);
const [loading, setLoading] = useState(false);
const [cleaning, setCleaning] = useState<string | null>(null);
const [downloading, setDownloading] = useState<string | null>(null);
const [cleaningAll, setCleaningAll] = useState(false);
const [onlyProblems, setOnlyProblems] = useState(true);
// Default ON: deleted-user remnants belong to a dedicated purge workflow,
// not to generic FK cleanup. Hiding them by default prevents confusion
// (and accidental "Alle bereinigen" runs) when the SysAdmin scans for
// genuine FK drift.
const [excludeUserFks, setExcludeUserFks] = useState(true);
const [dbFilter, setDbFilter] = useState<string>('');
const _fetchOrphans = useCallback(async () => {
try {
setLoading(true);
const qs = new URLSearchParams();
if (dbFilter) qs.set('db', dbFilter);
if (excludeUserFks) qs.set('excludeUserFks', 'true');
const params = qs.toString() ? `?${qs.toString()}` : '';
const res = await api.get(`/api/admin/database-health/orphans${params}`);
const rows = (res.data.orphans || []).map((o: any, i: number) => ({
...o,
id: `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}-${i}`,
}));
setAllOrphans(rows);
} catch {
setAllOrphans([]);
} finally {
setLoading(false);
}
}, [dbFilter, excludeUserFks]);
useEffect(() => { _fetchOrphans(); }, [_fetchOrphans]);
const displayed = useMemo(
() => onlyProblems ? allOrphans.filter(o => o.orphanCount > 0) : allOrphans,
[allOrphans, onlyProblems],
);
const { visibleData, pagination, refetch, fetchFilterValues } = _useClientPagination(displayed);
const databases = useMemo(
() => Array.from(new Set(allOrphans.map(o => o.sourceDb))).sort(),
[allOrphans],
);
const totalOrphans = useMemo(() => allOrphans.reduce((s, o) => s + o.orphanCount, 0), [allOrphans]);
const _postCleanOne = async (o: OrphanEntry, force: boolean): Promise<number | 'refused'> => {
try {
const res = await api.post('/api/admin/database-health/orphans/clean', {
db: o.sourceDb,
table: o.sourceTable,
column: o.sourceColumn,
force,
});
return res.data.deleted as number;
} catch (err: any) {
const status = err?.response?.status;
const detail = err?.response?.data?.detail;
if (status === 409 && detail?.refused) {
return 'refused';
}
const reason = typeof detail === 'string' ? detail : (detail?.reason || t('Fehler beim Bereinigen'));
throw new Error(reason);
}
};
const _downloadOne = async (o: OrphanEntry) => {
const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`;
setDownloading(key);
try {
const res = await api.get('/api/admin/database-health/orphans/list', {
params: {
db: o.sourceDb,
table: o.sourceTable,
column: o.sourceColumn,
limit: 5000,
},
});
const payload = {
sourceDb: o.sourceDb,
sourceTable: o.sourceTable,
sourceColumn: o.sourceColumn,
targetDb: o.targetDb,
targetTable: o.targetTable,
targetColumn: o.targetColumn,
scannedOrphanCount: o.orphanCount,
downloadedAt: new Date().toISOString(),
...res.data,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const ts = new Date().toISOString().replace(/[:.]/g, '-');
a.href = url;
a.download = `orphans_${o.sourceDb}_${o.sourceTable}_${o.sourceColumn}_${ts}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.showSuccess(t('{count} verwaiste Datensätze heruntergeladen', { count: res.data?.count ?? 0 }));
} catch (err: any) {
const detail = err?.response?.data?.detail;
const reason = typeof detail === 'string' ? detail : (detail?.reason || t('Fehler beim Download'));
toast.showError(reason);
} finally {
setDownloading(null);
}
};
const _cleanOne = async (o: OrphanEntry) => {
const baseMsg = t('{count} verwaiste Einträge in {table}.{column} löschen?', { count: o.orphanCount, table: o.sourceTable, column: o.sourceColumn });
const warning = (o.targetEmpty || o.wouldDeleteAll)
? '\n\n' + t('WARNUNG: Target-Tabelle {target} ist leer oder die Bereinigung würde alle Source-Zeilen löschen. Das ist meist eine Fehlkonfiguration!', { target: `${o.targetDb}.${o.targetTable}` })
: '';
const ok = await confirm(baseMsg + warning, { title: t('Orphans bereinigen'), variant: 'danger' });
if (!ok) return;
const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`;
setCleaning(key);
try {
let result = await _postCleanOne(o, false);
if (result === 'refused') {
const forceOk = await confirm(
t('Sicherheits-Check ausgelöst (leere Target-Tabelle oder >50% der Source würden gelöscht). Trotzdem mit force=true bereinigen?'),
{ title: t('Bereinigung erzwingen?'), variant: 'danger' },
);
if (!forceOk) {
toast.showInfo(t('Bereinigung abgebrochen'));
return;
}
result = await _postCleanOne(o, true);
}
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: result as number }));
_fetchOrphans();
} catch (err: any) {
toast.showError(err?.message || t('Fehler beim Bereinigen'));
} finally {
setCleaning(null);
}
};
const _cleanAll = async (force: boolean = false) => {
const ok = await confirm(
t('{count} verwaiste Einträge in {relations} Beziehungen löschen?', {
count: totalOrphans,
relations: allOrphans.filter(o => o.orphanCount > 0).length,
}) + (force ? '\n\n' + t('FORCE-Modus: Sicherheits-Checks werden ignoriert!') : ''),
{ title: t('Alle Orphans bereinigen'), variant: 'danger' },
);
if (!ok) return;
setCleaningAll(true);
try {
const res = await api.post('/api/admin/database-health/orphans/clean-all', { force, excludeUserFks });
const results: CleanResult[] = res.data.results || [];
const totalDeleted = results.reduce((s, r) => s + r.deleted, 0);
const errors = results.filter(r => r.error);
const skipped = results.filter(r => r.skipped);
if (skipped.length > 0 && !force) {
const retryOk = await confirm(
t('{deleted} gelöscht. {skipped} Bereinigungen wurden vom Sicherheits-Check abgelehnt (leere Target-Tabelle oder >50% Löschung). Mit force=true erneut versuchen?', { deleted: totalDeleted, skipped: skipped.length }),
{ title: t('Force benötigt'), variant: 'danger' },
);
if (retryOk) {
setCleaningAll(false);
await _cleanAll(true);
return;
}
}
if (errors.length > 0) {
toast.showWarning(t('{deleted} gelöscht, {errors} Fehler, {skipped} übersprungen', { deleted: totalDeleted, errors: errors.length, skipped: skipped.length }));
} else if (skipped.length > 0) {
toast.showWarning(t('{deleted} gelöscht, {skipped} übersprungen', { deleted: totalDeleted, skipped: skipped.length }));
} else {
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: totalDeleted }));
}
_fetchOrphans();
} catch (err: any) {
toast.showError(err.response?.data?.detail || t('Fehler beim Bereinigen'));
} finally {
setCleaningAll(false);
}
};
const columns: ColumnConfig[] = useMemo(() => [
{
key: 'sourceDb',
label: t('Source DB'),
sortable: true,
filterable: true,
searchable: true,
width: 180,
filterOptions: databases,
},
{
key: 'sourceTable',
label: t('Tabelle'),
sortable: true,
searchable: true,
width: 180,
},
{
key: 'sourceColumn',
label: t('FK-Spalte'),
sortable: true,
searchable: true,
width: 150,
},
{
key: 'targetTable',
label: t('Referenz'),
sortable: true,
width: 220,
formatter: (_val: string, row: OrphanEntry) => {
const isCrossDb = row.sourceDb !== row.targetDb;
return (
<span>
<code>{row.targetTable}.{row.targetColumn}</code>
{isCrossDb && (
<span style={{
marginLeft: '0.4rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
fontSize: '0.625rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.03em',
background: 'var(--primary-dark-bg, rgba(242, 88, 67, 0.12))',
color: 'var(--primary-color, #f25843)',
}}>
{t('cross-db')}
</span>
)}
</span>
);
},
},
{
key: 'orphanCount',
label: t('Orphans'),
type: 'number',
sortable: true,
width: 100,
formatter: (v: number) => (
<span style={v > 0 ? { color: 'var(--danger-color, #e53e3e)', fontWeight: 600 } : undefined}>
{_formatNumber(v)}
</span>
),
},
], [t, databases]);
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<ConfirmDialog />
{/* Controls */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Datenbank')}</label>
<select
className={styles.filterSelect}
value={dbFilter}
onChange={e => setDbFilter(e.target.value)}
>
<option value="">{t('Alle')}</option>
{databases.map(db => <option key={db} value={db}>{db}</option>)}
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.checkboxLabel}>
<input type="checkbox" checked={onlyProblems} onChange={e => setOnlyProblems(e.target.checked)} />
{t('Nur Probleme')}
</label>
</div>
<div className={styles.filterGroup}>
<label
className={styles.checkboxLabel}
title={t('FK-Referenzen auf UserInDB.id ausblenden — diese werden über den User-Purge-Workflow separat behandelt.')}
>
<input
type="checkbox"
checked={excludeUserFks}
onChange={e => setExcludeUserFks(e.target.checked)}
/>
{t('Ohne FK-Referenzen zu UserInDB.id')}
</label>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={_fetchOrphans} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
</button>
{totalOrphans > 0 && (
<button className={styles.dangerButton} onClick={() => _cleanAll(false)} disabled={cleaningAll || loading}>
<FaBroom className={cleaningAll ? 'spinning' : ''} /> {t('Alle bereinigen')} ({_formatNumber(totalOrphans)})
</button>
)}
</div>
</div>
{totalOrphans > 0 && (
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
{t('{count} verwaiste Einträge in {relations} Beziehungen gefunden', {
count: _formatNumber(totalOrphans),
relations: allOrphans.filter(o => o.orphanCount > 0).length,
})}
</div>
)}
<div className={styles.tableContainer}>
<FormGeneratorTable
data={visibleData}
columns={columns}
loading={loading}
sortable={true}
searchable={true}
filterable={true}
pagination={true}
pageSize={50}
selectable={false}
customActions={[
{
id: 'download',
icon: <FaDownload />,
onClick: (row: OrphanEntry) => _downloadOne(row),
visible: (row: OrphanEntry) => row.orphanCount > 0,
loading: (row: OrphanEntry) => downloading === `${row.sourceDb}.${row.sourceTable}.${row.sourceColumn}`,
title: t('Orphan-Liste herunterladen (JSON)'),
},
{
id: 'clean',
icon: <FaTrashAlt />,
onClick: (row: OrphanEntry) => _cleanOne(row),
visible: (row: OrphanEntry) => row.orphanCount > 0,
loading: (row: OrphanEntry) => cleaning === `${row.sourceDb}.${row.sourceTable}.${row.sourceColumn}` || cleaningAll,
title: t('Orphans löschen'),
},
]}
hookData={{
refetch,
pagination,
fetchFilterValues,
}}
emptyMessage={onlyProblems ? t('Keine Orphans gefunden') : t('Keine FK-Beziehungen gefunden')}
/>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export const AdminDatabaseHealthPage: React.FC = () => {
const { t } = useLanguage();
const tabs = useMemo(() => [
{
id: 'stats',
label: t('Statistiken'),
content: <StatsTab />,
},
{
id: 'orphans',
label: t('Orphan Cleanup'),
content: <OrphansTab />,
},
], [t]);
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1>
<p className={styles.pageSubtitle}>{t('Tabellenstatistiken und verwaiste Datensätze')}</p>
</div>
</div>
<Tabs tabs={tabs} defaultTabId="stats" />
</div>
);
};
export default AdminDatabaseHealthPage;