/** * 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; 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>(allData: T[]) { const [visibleData, setVisibleData] = useState([]); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 50, totalItems: 0, totalPages: 1, }); const allDataRef = useRef(allData); allDataRef.current = allData; const lastParamsRef = useRef({}); const fetchFilterValues = useCallback(async (columnKey: string, crossFilters?: Record) => { 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(); 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([]); const [loading, setLoading] = useState(false); const [dbFilter, setDbFilter] = useState(''); 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 (
{/* Controls */}
{/* Summary */}
{t('{dbs} Datenbanken', { dbs: totals.dbs })} {t('{tables} Tabellen', { tables: totals.tables })} {t('{rows} Zeilen (ca.)', { rows: _formatNumber(totals.rows) })} {t('Total {size}', { size: _formatBytes(totals.size) })} {t('Index {size}', { size: _formatBytes(totals.idx) })}
); }; // --------------------------------------------------------------------------- // OrphansTab // --------------------------------------------------------------------------- const OrphansTab: React.FC = () => { const { t } = useLanguage(); const toast = useToast(); const { confirm, ConfirmDialog } = useConfirm(); const [allOrphans, setAllOrphans] = useState([]); const [loading, setLoading] = useState(false); const [cleaning, setCleaning] = useState(null); const [downloading, setDownloading] = useState(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(''); 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 => { 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 ( {row.targetTable}.{row.targetColumn} {isCrossDb && ( {t('cross-db')} )} ); }, }, { key: 'orphanCount', label: t('Orphans'), type: 'number', sortable: true, width: 100, formatter: (v: number) => ( 0 ? { color: 'var(--danger-color, #e53e3e)', fontWeight: 600 } : undefined}> {_formatNumber(v)} ), }, ], [t, databases]); return (
{/* Controls */}
{totalOrphans > 0 && ( )}
{totalOrphans > 0 && (
{t('{count} verwaiste Einträge in {relations} Beziehungen gefunden', { count: _formatNumber(totalOrphans), relations: allOrphans.filter(o => o.orphanCount > 0).length, })}
)}
, 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: , 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')} />
); }; // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- export const AdminDatabaseHealthPage: React.FC = () => { const { t } = useLanguage(); const tabs = useMemo(() => [ { id: 'stats', label: t('Statistiken'), content: , }, { id: 'orphans', label: t('Orphan Cleanup'), content: , }, ], [t]); return (

{t('Datenbank-Gesundheit')}

{t('Tabellenstatistiken und verwaiste Datensätze')}

); }; export default AdminDatabaseHealthPage;