diff --git a/src/pages/admin/AdminDatabaseHealthPage.tsx b/src/pages/admin/AdminDatabaseHealthPage.tsx index f83e103..34cdc21 100644 --- a/src/pages/admin/AdminDatabaseHealthPage.tsx +++ b/src/pages/admin/AdminDatabaseHealthPage.tsx @@ -1401,6 +1401,258 @@ const MigrationTab: React.FC = () => { }; +// --------------------------------------------------------------------------- +// 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 // --------------------------------------------------------------------------- @@ -1419,6 +1671,11 @@ export const AdminDatabaseHealthPage: React.FC = () => { label: t('Orphan Cleanup'), content: , }, + { + id: 'legacy', + label: t('Legacy Cleanup'), + content: , + }, { id: 'migration', label: t('Migration'),