Pydantic FK als Single Source of Truth
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
This commit is contained in:
parent
50c05e91d7
commit
12868fdd17
1 changed files with 257 additions and 0 deletions
|
|
@ -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
|
// Page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -1419,6 +1671,11 @@ export const AdminDatabaseHealthPage: React.FC = () => {
|
||||||
label: t('Orphan Cleanup'),
|
label: t('Orphan Cleanup'),
|
||||||
content: <OrphansTab />,
|
content: <OrphansTab />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'legacy',
|
||||||
|
label: t('Legacy Cleanup'),
|
||||||
|
content: <LegacyCleanupTab />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'migration',
|
id: 'migration',
|
||||||
label: t('Migration'),
|
label: t('Migration'),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue