Pydantic FK als Single Source of Truth
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s

This commit is contained in:
ValueOn AG 2026-05-25 15:14:09 +02:00
parent 50c05e91d7
commit 12868fdd17

View file

@ -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<LegacyTable[]>([]);
const [loading, setLoading] = useState(false);
const [dropping, setDropping] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(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) => (
<input
type="checkbox"
checked={selected.has(row.id)}
onChange={() => _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) => (
<span style={{ color: 'var(--danger-color, #e53e3e)' }}>
<code>{v}</code>
<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('kein Modell')}
</span>
</span>
),
},
{
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 (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<ConfirmDialog />
<div className={styles.filterSection}>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={_fetchLegacy} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
</button>
<label className={styles.checkboxLabel} style={{ fontWeight: 600 }}>
<input type="checkbox" checked={selected.size === allLegacy.length && allLegacy.length > 0} onChange={_toggleAll} />
{t('Alle')}
</label>
{selected.size > 0 && (
<button className={styles.dangerButton} onClick={_dropSelected} disabled={droppingBatch || loading}>
<FaTrashAlt className={droppingBatch ? 'spinning' : ''} />
{t('Ausgewählte löschen')} ({selected.size})
</button>
)}
</div>
</div>
{allLegacy.length > 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} Legacy-Tabellen in {dbs} Datenbanken ({rows} Zeilen, {size})', {
count: totals.count, dbs: totals.dbs, rows: _formatNumber(totals.rows), size: _formatBytes(totals.size),
})}
</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: 'drop',
icon: <FaTrashAlt />,
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')}
/>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
@ -1419,6 +1671,11 @@ export const AdminDatabaseHealthPage: React.FC = () => {
label: t('Orphan Cleanup'),
content: <OrphansTab />,
},
{
id: 'legacy',
label: t('Legacy Cleanup'),
content: <LegacyCleanupTab />,
},
{
id: 'migration',
label: t('Migration'),