/** * AdminDatabaseHealthPage * * SysAdmin-only page with three tabs: * 1. Table Statistics — pg_stat data for every table across all databases * 2. Orphan Cleanup — FK orphan detection with per-relation + batch cleanup * 3. Migration — Database backup (export) and restore (import) * * Both Stats/Orphan 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, FaUpload, FaDatabase, FaInfoCircle, FaCheckCircle } 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')} />
); }; // --------------------------------------------------------------------------- // Types (Migration) // --------------------------------------------------------------------------- interface MigrationDatabase { name: string; tableCount: number; recordCount: number; } interface ValidationSummaryItem { database: string; tableCount: number; recordCount: number; registered: boolean; } interface ValidationResult { valid: boolean; summary: ValidationSummaryItem[]; warnings: string[]; systemObjectsFound: Array<{ type: string; label: string; payloadId: string }>; } interface ProgressLogEntry { ts: string; message: string; status: 'info' | 'success' | 'error'; } // --------------------------------------------------------------------------- // MigrationTab // --------------------------------------------------------------------------- const MigrationTab: React.FC = () => { const { t } = useLanguage(); const toast = useToast(); const { confirm, ConfirmDialog } = useConfirm(); // --- Backup state --- const [databases, setDatabases] = useState([]); const [selectedDbs, setSelectedDbs] = useState>(new Set()); const [loadingDbs, setLoadingDbs] = useState(false); const [exporting, setExporting] = useState(false); const [exportLog, setExportLog] = useState([]); // --- Restore state --- const [uploadedFile, setUploadedFile] = useState(null); const [validating, setValidating] = useState(false); const [validation, setValidation] = useState(null); const [importMode, setImportMode] = useState<'replace' | 'merge'>('merge'); const [importing, setImporting] = useState(false); const [importLog, setImportLog] = useState([]); const fileInputRef = useRef(null); const exportLogRef = useRef(null); const importLogRef = useRef(null); // --- Fetch databases --- const _fetchDatabases = useCallback(async () => { try { setLoadingDbs(true); const res = await api.get('/api/admin/database-health/migration/databases'); const dbs: MigrationDatabase[] = res.data.databases || []; setDatabases(dbs); setSelectedDbs(new Set(dbs.map(d => d.name))); } catch { setDatabases([]); } finally { setLoadingDbs(false); } }, []); useEffect(() => { _fetchDatabases(); }, [_fetchDatabases]); // --- Backup: DB selection --- const allSelected = databases.length > 0 && selectedDbs.size === databases.length; const _toggleAll = () => { if (allSelected) { setSelectedDbs(new Set()); } else { setSelectedDbs(new Set(databases.map(d => d.name))); } }; const _toggleDb = (name: string) => { setSelectedDbs(prev => { const next = new Set(prev); if (next.has(name)) { next.delete(name); } else { next.add(name); } return next; }); }; // --- Backup: Export (per-DB with progress) --- const _addExportLog = useCallback((message: string, logStatus: ProgressLogEntry['status'] = 'info') => { const ts = new Date().toLocaleTimeString(); setExportLog(prev => [...prev, { ts, message, status: logStatus }]); setTimeout(() => exportLogRef.current?.scrollTo({ top: exportLogRef.current.scrollHeight }), 50); }, []); const _startExport = async () => { if (selectedDbs.size === 0) return; setExporting(true); setExportLog([]); const dbList = Array.from(selectedDbs); const dbsParam = allSelected ? 'all' : dbList.join(','); _addExportLog(t('Export gestartet: {count} Datenbanken (Streaming)', { count: dbList.length })); try { const baseURL = api.defaults.baseURL || ''; const url = `${baseURL}/api/admin/database-health/migration/export-stream?databases=${encodeURIComponent(dbsParam)}`; const response = await fetch(url, { credentials: 'include' }); if (!response.ok) { const errText = await response.text(); throw new Error(`Server ${response.status}: ${errText}`); } if (!response.body) throw new Error('ReadableStream not supported'); const cd = response.headers.get('Content-Disposition'); let filename = `db_export_${new Date().toISOString().replace(/[:.]/g, '-')}.json`; if (cd) { const m = cd.match(/filename="?([^"]+)"?/); if (m) filename = m[1]; } const handle = await (window as any).showSaveFilePicker({ suggestedName: filename, types: [{ description: 'JSON', accept: { 'application/json': ['.json'] } }], }); const writable = await handle.createWritable(); const reader = response.body.getReader(); const decoder = new TextDecoder(); let totalBytes = 0; let lastLogBytes = 0; let currentDb = ''; let dbCount = 0; const totalDbs = dbList.length; let tailBuf = ''; for (;;) { const { done, value } = await reader.read(); if (done) break; await writable.write(value); totalBytes += value.length; const text = decoder.decode(value, { stream: true }); tailBuf += text; const match = tailBuf.match(/"(poweron_[a-z_]+)":\{"tables"/); if (match && match[1] !== currentDb) { currentDb = match[1]; dbCount++; tailBuf = ''; const mb = (totalBytes / (1024 * 1024)).toFixed(1); _addExportLog(t('{index}/{total}: {db} — {mb} MB', { index: dbCount, total: totalDbs, db: currentDb, mb, })); lastLogBytes = totalBytes; } else if (totalBytes - lastLogBytes >= 2 * 1024 * 1024) { const mb = (totalBytes / (1024 * 1024)).toFixed(1); setExportLog(prev => { const updated = [...prev]; updated[updated.length - 1] = { ts: new Date().toLocaleTimeString(), message: t('{index}/{total}: {db} — {mb} MB', { index: dbCount, total: totalDbs, db: currentDb || '...', mb, }), status: 'info', }; return updated; }); lastLogBytes = totalBytes; } if (tailBuf.length > 500) tailBuf = tailBuf.slice(-200); } await writable.close(); const mb = (totalBytes / (1024 * 1024)).toFixed(1); _addExportLog(t('Export abgeschlossen: {count} Datenbanken, {mb} MB', { count: dbCount, mb }), 'success'); toast.showSuccess(t('Export gespeichert')); } catch (err: any) { _addExportLog(t('Fehler: {error}', { error: String(err) }), 'error'); toast.showError(t('Export fehlgeschlagen')); } finally { setExporting(false); } }; // --- Restore: File upload --- const _handleFileSelect = (file: File) => { setUploadedFile(file); setValidation(null); _validateFile(file); }; const _onFileInputChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) _handleFileSelect(file); if (e.target) e.target.value = ''; }; const _onDrop = (e: React.DragEvent) => { e.preventDefault(); const file = e.dataTransfer.files?.[0]; if (file && file.name.endsWith('.json')) { _handleFileSelect(file); } }; const _onDragOver = (e: React.DragEvent) => { e.preventDefault(); }; // --- Restore: Validate (uploads file to server, streams to disk) --- const importTokenRef = useRef(''); const [uploadProgress, setUploadProgress] = useState(''); const _validateFile = async (file: File) => { setValidating(true); setValidation(null); setUploadProgress(''); importTokenRef.current = ''; const fileMb = (file.size / (1024 * 1024)).toFixed(1); try { // Phase 1: Upload file to disk const formData = new FormData(); formData.append('file', file); const uploadRes = await api.post('/api/admin/database-health/migration/upload-import', formData, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0, onUploadProgress: (e) => { if (e.total) { const pct = Math.round((e.loaded / e.total) * 100); const loadedMb = (e.loaded / (1024 * 1024)).toFixed(1); setUploadProgress( pct < 100 ? t('Upload: {loaded} / {total} MB ({pct}%)', { loaded: loadedMb, total: fileMb, pct }) : t('Verarbeitung wird gestartet...'), ); } }, }); const token = uploadRes.data.token; if (!token) throw new Error('No token returned from upload'); // Phase 2: Stream validation + split with progress const baseURL = api.defaults.baseURL || ''; const streamUrl = `${baseURL}/api/admin/database-health/migration/process-import-stream?token=${encodeURIComponent(token)}`; const streamRes = await fetch(streamUrl, { credentials: 'include' }); if (!streamRes.ok) { const errText = await streamRes.text(); throw new Error(`Server ${streamRes.status}: ${errText}`); } if (!streamRes.body) throw new Error('ReadableStream not supported'); const reader = streamRes.body.getReader(); const decoder = new TextDecoder(); let buf = ''; let finalResult: any = null; for (;;) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop() || ''; for (const line of lines) { if (!line.trim()) continue; try { const evt = JSON.parse(line); if (evt.phase === 'validate') { setUploadProgress( t('Pass 1 Validierung: {db}.{table} ({rows} Datensaetze)', { db: evt.db, table: evt.table, rows: evt.rows, }), ); } else if (evt.phase === 'split') { setUploadProgress( t('Pass 2 Split: {db}.{table} ({rows} Datensaetze)', { db: evt.db, table: evt.table, rows: evt.rows, }), ); } else if (evt.phase === 'done') { finalResult = evt.result; } else if (evt.phase === 'error') { throw new Error(evt.detail || 'Processing failed'); } } catch (parseErr) { if ((parseErr as Error).message?.startsWith('Processing')) throw parseErr; } } } setUploadProgress(''); if (!finalResult) throw new Error('No result received from processing stream'); importTokenRef.current = finalResult.token || token; setValidation({ valid: finalResult.valid, summary: (finalResult.databases || []).map((d: any) => ({ database: d.database, tableCount: d.tableCount, recordCount: d.recordCount, registered: true, })), warnings: finalResult.warnings || [], systemObjectsFound: finalResult.systemObjectsFound || [], }); } catch (err: any) { setUploadProgress(''); const detail = err?.response?.data?.detail || err?.message; setValidation({ valid: false, summary: [], systemObjectsFound: [], warnings: [typeof detail === 'string' ? detail : t('Upload oder Validierung fehlgeschlagen')], }); } finally { setValidating(false); } }; // --- Restore: Import (per-DB with progress) --- const _addImportLog = useCallback((message: string, logStatus: ProgressLogEntry['status'] = 'info') => { const ts = new Date().toLocaleTimeString(); setImportLog(prev => [...prev, { ts, message, status: logStatus }]); setTimeout(() => importLogRef.current?.scrollTo({ top: importLogRef.current.scrollHeight }), 50); }, []); const _startImport = async () => { if (!importTokenRef.current || !validation?.valid || importing) return; setImporting(true); const modeLabel = importMode === 'replace' ? t('Neu (Datenbank leeren und importieren)') : t('Zusammenfuehren (bestehende Daten belassen, neue ergaenzen)'); const ok = await confirm( t('Import mit Modus "{mode}" starten? Dieser Vorgang kann nicht rueckgaengig gemacht werden.', { mode: modeLabel }), { title: t('Import starten'), variant: importMode === 'replace' ? 'danger' : 'primary' }, ); if (!ok) { setImporting(false); return; } setImportLog([]); const token = importTokenRef.current; const dbList = validation.summary.filter(s => s.registered); const totalDbs = dbList.length; let totalRecords = 0; let errors = 0; _addImportLog(t('Import gestartet: {count} Datenbanken', { count: totalDbs })); for (let i = 0; i < dbList.length; i++) { const dbInfo = dbList[i]; _addImportLog( t('Importiere {index}/{total}: {db} ({records} Datensaetze)...', { index: i + 1, total: totalDbs, db: dbInfo.database, records: dbInfo.recordCount, }), ); try { const res = await api.post('/api/admin/database-health/migration/import-single', { token, database: dbInfo.database, mode: importMode, }); const result = res.data; totalRecords += result.recordCount || 0; const dbWarnings: string[] = result.warnings || []; for (const w of dbWarnings) { _addImportLog(t('Warnung: {msg}', { msg: w }), 'error'); } _addImportLog( t('{db}: {count} Datensaetze importiert', { db: dbInfo.database, count: result.recordCount || 0 }), dbWarnings.length > 0 ? 'error' : 'success', ); } catch (err: any) { errors++; const detail = err?.response?.data?.detail; _addImportLog( t('Fehler bei {db}: {error}', { db: dbInfo.database, error: typeof detail === 'string' ? detail : String(err), }), 'error', ); } } try { await api.post('/api/admin/database-health/migration/import-done', { token }); } catch { /* ignore */ } _addImportLog( t('Import abgeschlossen: {records} Datensaetze in {dbs} Datenbanken', { records: totalRecords, dbs: totalDbs, }), 'success', ); if (errors > 0) { toast.showWarning(t('{count} Datensaetze importiert, {errors} Fehler', { count: totalRecords, errors })); } else { toast.showSuccess(t('{count} Datensaetze erfolgreich importiert', { count: totalRecords })); } importTokenRef.current = ''; setImporting(false); }; const _resetUpload = () => { setUploadedFile(null); setValidation(null); importTokenRef.current = ''; }; return (
{/* ---- BACKUP SECTION ---- */}

{t('Backup')}

{t('Datenbanken fuer Export auswaehlen')}

{loadingDbs ? (
) : ( <>
{databases.map(db => ( ))}
{exportLog.length > 0 && (
{exportLog.map((entry, i) => (
{entry.ts} {entry.message}
))}
)} )}
{/* ---- DIVIDER ---- */}
{/* ---- RESTORE SECTION ---- */}

{t('Restore')}

{/* File upload zone */} {!uploadedFile ? (
fileInputRef.current?.click()} style={{ border: '2px dashed var(--border-color)', borderRadius: '8px', padding: '2rem', textAlign: 'center', cursor: 'pointer', background: 'var(--bg-secondary)', transition: 'border-color 0.2s', }} >

{t('Datei hier ablegen oder klicken')}

{t('JSON-Datei hochladen')}

) : (
{/* File info */}
{uploadedFile.name} ({_formatBytes(uploadedFile.size)})
{/* Validation */} {validating && (
{uploadProgress || t('Validierung laeuft...')}
)} {validation && !validating && (

{t('Pruefung')}

{/* Validation warnings */} {validation.warnings.length > 0 && (
{validation.warnings.map((w, i) => ( {w} ))}
)} {/* Summary table */} {validation.summary.length > 0 && ( {validation.summary.map(s => ( ))}
{t('Datenbank')} {t('Tabellen')} {t('Datensaetze')} {t('Status')}
{s.database} {s.tableCount} {_formatNumber(s.recordCount)} {s.registered ? ( ) : ( )}
)} {/* System objects info */} {validation.systemObjectsFound.length > 0 && (
{t('Systemdaten werden beim Import nicht geloescht')} {validation.systemObjectsFound.map(o => o.label).join(', ')}
)} {/* Import settings */} {validation.valid && (

{t('Import-Einstellungen')}

{importMode === 'replace' && (
{t('Achtung: Bestehende Daten werden unwiderruflich geloescht. Erstellen Sie zuerst ein Backup.')}
)} {importLog.length > 0 && (
{importLog.map((entry, i) => (
{entry.ts} {entry.message}
))}
)}
)}
)}
)}
); }; // --------------------------------------------------------------------------- // LegacyCleanupTab // --------------------------------------------------------------------------- interface LegacyTable { id: string; db: string; table: string; rowCount: number; sizeBytes: number; } const LegacyCleanupTab: React.FC = () => { const { t } = useLanguage(); const toast = useToast(); const { confirm, ConfirmDialog } = useConfirm(); const [allLegacy, setAllLegacy] = useState([]); const [loading, setLoading] = useState(false); const [dropping, setDropping] = useState(null); const [selected, setSelected] = useState>(new Set()); const [droppingBatch, setDroppingBatch] = useState(false); const _fetchLegacy = useCallback(async () => { try { setLoading(true); const res = await api.get('/api/admin/database-health/legacy-tables'); const rows: LegacyTable[] = (res.data.legacyTables || []).map((t: any) => ({ ...t, id: `${t.db}.${t.table}`, })); setAllLegacy(rows); setSelected(new Set()); } catch { setAllLegacy([]); } finally { setLoading(false); } }, []); useEffect(() => { _fetchLegacy(); }, [_fetchLegacy]); const { visibleData, pagination, refetch, fetchFilterValues } = _useClientPagination(allLegacy); const databases = useMemo( () => Array.from(new Set(allLegacy.map(l => l.db))).sort(), [allLegacy], ); const totals = useMemo(() => { let rows = 0, size = 0; for (const l of allLegacy) { rows += l.rowCount; size += l.sizeBytes; } return { count: allLegacy.length, rows, size, dbs: databases.length }; }, [allLegacy, databases]); const _dropOne = async (entry: LegacyTable) => { const ok = await confirm( t('Legacy-Tabelle {db}.{table} ({rows} Zeilen, {size}) unwiderruflich löschen?', { db: entry.db, table: entry.table, rows: _formatNumber(entry.rowCount), size: _formatBytes(entry.sizeBytes), }), { title: t('Legacy-Tabelle löschen'), variant: 'danger' }, ); if (!ok) return; setDropping(entry.id); try { await api.post('/api/admin/database-health/legacy-tables/drop', { db: entry.db, table: entry.table }); toast.showSuccess(t('{db}.{table} gelöscht', { db: entry.db, table: entry.table })); _fetchLegacy(); } catch (err: any) { const detail = err?.response?.data?.detail; toast.showError(typeof detail === 'string' ? detail : t('Fehler beim Löschen')); } finally { setDropping(null); } }; const _dropSelected = async () => { if (selected.size === 0) return; const selectedEntries = allLegacy.filter(l => selected.has(l.id)); const totalRows = selectedEntries.reduce((s, l) => s + l.rowCount, 0); const ok = await confirm( t('{count} Legacy-Tabellen mit insgesamt {rows} Zeilen unwiderruflich löschen?', { count: selected.size, rows: _formatNumber(totalRows), }), { title: t('Ausgewählte löschen'), variant: 'danger' }, ); if (!ok) return; setDroppingBatch(true); let deleted = 0; let errors = 0; for (const entry of selectedEntries) { try { await api.post('/api/admin/database-health/legacy-tables/drop', { db: entry.db, table: entry.table }); deleted++; } catch { errors++; } } if (errors > 0) { toast.showWarning(t('{deleted} gelöscht, {errors} Fehler', { deleted, errors })); } else { toast.showSuccess(t('{deleted} Legacy-Tabellen gelöscht', { deleted })); } setDroppingBatch(false); _fetchLegacy(); }; const _toggleSelect = (id: string) => { setSelected(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const _toggleAll = () => { if (selected.size === allLegacy.length) { setSelected(new Set()); } else { setSelected(new Set(allLegacy.map(l => l.id))); } }; const columns: ColumnConfig[] = useMemo(() => [ { key: '_select', label: '', width: 40, formatter: (_val: any, row: LegacyTable) => ( _toggleSelect(row.id)} /> ), }, { 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: 250, formatter: (v: string) => ( {v} {t('kein Modell')} ), }, { key: 'rowCount', label: t('Zeilen (ca.)'), type: 'number' as const, sortable: true, width: 120, formatter: (v: number) => _formatNumber(v), }, { key: 'sizeBytes', label: t('Grösse'), type: 'number' as const, sortable: true, width: 120, formatter: (v: number) => _formatBytes(v), }, ], [t, databases, selected]); return (
{selected.size > 0 && ( )}
{allLegacy.length > 0 && (
{t('{count} Legacy-Tabellen in {dbs} Datenbanken ({rows} Zeilen, {size})', { count: totals.count, dbs: totals.dbs, rows: _formatNumber(totals.rows), size: _formatBytes(totals.size), })}
)}
, onClick: (row: LegacyTable) => _dropOne(row), loading: (row: LegacyTable) => dropping === row.id || droppingBatch, title: t('Tabelle löschen'), }, ]} hookData={{ refetch, pagination, fetchFilterValues, }} emptyMessage={t('Keine Legacy-Tabellen 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: , }, { id: 'legacy', label: t('Legacy Cleanup'), content: , }, { id: 'migration', label: t('Migration'), content: , }, ], [t]); return (

{t('Datenbank-Gesundheit')}

{t('Tabellenstatistiken, verwaiste Datensaetze und Migration')}

); }; export default AdminDatabaseHealthPage;