1769 lines
62 KiB
TypeScript
1769 lines
62 KiB
TypeScript
/**
|
|
* 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<string, any>;
|
|
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<T extends Record<string, any>>(allData: T[]) {
|
|
const [visibleData, setVisibleData] = useState<T[]>([]);
|
|
const [pagination, setPagination] = useState<PaginationMeta>({
|
|
currentPage: 1, pageSize: 50, totalItems: 0, totalPages: 1,
|
|
});
|
|
|
|
const allDataRef = useRef(allData);
|
|
allDataRef.current = allData;
|
|
|
|
const lastParamsRef = useRef<PaginationParams>({});
|
|
|
|
const fetchFilterValues = useCallback(async (columnKey: string, crossFilters?: Record<string, any>) => {
|
|
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<string>();
|
|
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<TableStat[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [dbFilter, setDbFilter] = useState<string>('');
|
|
|
|
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 (
|
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
|
{/* Controls */}
|
|
<div className={styles.filterSection}>
|
|
<div className={styles.filterGroup}>
|
|
<label className={styles.filterLabel}>{t('Datenbank')}</label>
|
|
<select
|
|
className={styles.filterSelect}
|
|
value={dbFilter}
|
|
onChange={e => setDbFilter(e.target.value)}
|
|
>
|
|
<option value="">{t('Alle')}</option>
|
|
{databases.map(db => <option key={db} value={db}>{db}</option>)}
|
|
</select>
|
|
</div>
|
|
<button className={styles.secondaryButton} onClick={_fetchStats} disabled={loading}>
|
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Summary */}
|
|
<div className={styles.filterSection} style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
|
|
<span className={styles.filterLabel}>{t('{dbs} Datenbanken', { dbs: totals.dbs })}</span>
|
|
<span className={styles.filterLabel}>{t('{tables} Tabellen', { tables: totals.tables })}</span>
|
|
<span className={styles.filterLabel}>{t('{rows} Zeilen (ca.)', { rows: _formatNumber(totals.rows) })}</span>
|
|
<span className={styles.filterLabel}>{t('Total {size}', { size: _formatBytes(totals.size) })}</span>
|
|
<span className={styles.filterLabel}>{t('Index {size}', { size: _formatBytes(totals.idx) })}</span>
|
|
</div>
|
|
|
|
<div className={styles.tableContainer}>
|
|
<FormGeneratorTable
|
|
data={visibleData}
|
|
columns={columns}
|
|
loading={loading}
|
|
sortable={true}
|
|
searchable={true}
|
|
filterable={true}
|
|
pagination={true}
|
|
pageSize={50}
|
|
selectable={false}
|
|
hookData={{
|
|
refetch,
|
|
pagination,
|
|
fetchFilterValues,
|
|
}}
|
|
emptyMessage={t('Keine Tabellen gefunden')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OrphansTab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const OrphansTab: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
const toast = useToast();
|
|
const { confirm, ConfirmDialog } = useConfirm();
|
|
|
|
const [allOrphans, setAllOrphans] = useState<OrphanEntry[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [cleaning, setCleaning] = useState<string | null>(null);
|
|
const [downloading, setDownloading] = useState<string | null>(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<string>('');
|
|
|
|
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<number | 'refused'> => {
|
|
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 (
|
|
<span>
|
|
<code>{row.targetTable}.{row.targetColumn}</code>
|
|
{isCrossDb && (
|
|
<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('cross-db')}
|
|
</span>
|
|
)}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'orphanCount',
|
|
label: t('Orphans'),
|
|
type: 'number',
|
|
sortable: true,
|
|
width: 100,
|
|
formatter: (v: number) => (
|
|
<span style={v > 0 ? { color: 'var(--danger-color, #e53e3e)', fontWeight: 600 } : undefined}>
|
|
{_formatNumber(v)}
|
|
</span>
|
|
),
|
|
},
|
|
], [t, databases]);
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
|
|
<ConfirmDialog />
|
|
|
|
{/* Controls */}
|
|
<div className={styles.filterSection}>
|
|
<div className={styles.filterGroup}>
|
|
<label className={styles.filterLabel}>{t('Datenbank')}</label>
|
|
<select
|
|
className={styles.filterSelect}
|
|
value={dbFilter}
|
|
onChange={e => setDbFilter(e.target.value)}
|
|
>
|
|
<option value="">{t('Alle')}</option>
|
|
{databases.map(db => <option key={db} value={db}>{db}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className={styles.filterGroup}>
|
|
<label className={styles.checkboxLabel}>
|
|
<input type="checkbox" checked={onlyProblems} onChange={e => setOnlyProblems(e.target.checked)} />
|
|
{t('Nur Probleme')}
|
|
</label>
|
|
</div>
|
|
<div className={styles.filterGroup}>
|
|
<label
|
|
className={styles.checkboxLabel}
|
|
title={t('FK-Referenzen auf UserInDB.id ausblenden — diese werden über den User-Purge-Workflow separat behandelt.')}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={excludeUserFks}
|
|
onChange={e => setExcludeUserFks(e.target.checked)}
|
|
/>
|
|
{t('Ohne FK-Referenzen zu UserInDB.id')}
|
|
</label>
|
|
</div>
|
|
<div className={styles.headerActions}>
|
|
<button className={styles.secondaryButton} onClick={_fetchOrphans} disabled={loading}>
|
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
|
|
</button>
|
|
{totalOrphans > 0 && (
|
|
<button className={styles.dangerButton} onClick={() => _cleanAll(false)} disabled={cleaningAll || loading}>
|
|
<FaBroom className={cleaningAll ? 'spinning' : ''} /> {t('Alle bereinigen')} ({_formatNumber(totalOrphans)})
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{totalOrphans > 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} verwaiste Einträge in {relations} Beziehungen gefunden', {
|
|
count: _formatNumber(totalOrphans),
|
|
relations: allOrphans.filter(o => o.orphanCount > 0).length,
|
|
})}
|
|
</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: 'download',
|
|
icon: <FaDownload />,
|
|
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: <FaTrashAlt />,
|
|
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')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<MigrationDatabase[]>([]);
|
|
const [selectedDbs, setSelectedDbs] = useState<Set<string>>(new Set());
|
|
const [loadingDbs, setLoadingDbs] = useState(false);
|
|
const [exporting, setExporting] = useState(false);
|
|
const [exportLog, setExportLog] = useState<ProgressLogEntry[]>([]);
|
|
|
|
// --- Restore state ---
|
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
|
const [validating, setValidating] = useState(false);
|
|
const [validation, setValidation] = useState<ValidationResult | null>(null);
|
|
const [importMode, setImportMode] = useState<'replace' | 'merge'>('merge');
|
|
const [importing, setImporting] = useState(false);
|
|
const [importLog, setImportLog] = useState<ProgressLogEntry[]>([]);
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const exportLogRef = useRef<HTMLDivElement>(null);
|
|
const importLogRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
|
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 (
|
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflow: 'auto', gap: '2rem', padding: '0.5rem 0' }}>
|
|
<ConfirmDialog />
|
|
|
|
{/* ---- BACKUP SECTION ---- */}
|
|
<section>
|
|
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--text-primary)', margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<FaDownload /> {t('Backup')}
|
|
</h2>
|
|
|
|
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', margin: '0 0 0.75rem 0' }}>
|
|
{t('Datenbanken fuer Export auswaehlen')}
|
|
</p>
|
|
|
|
{loadingDbs ? (
|
|
<div className={styles.loadingContainer}>
|
|
<div className={styles.spinner} />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem', marginBottom: '1rem' }}>
|
|
<label className={styles.checkboxLabel} style={{ fontWeight: 600 }}>
|
|
<input type="checkbox" checked={allSelected} onChange={_toggleAll} />
|
|
{t('Alle Datenbanken')}
|
|
</label>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', paddingLeft: '0.5rem' }}>
|
|
{databases.map(db => (
|
|
<label key={db.name} className={styles.checkboxLabel}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedDbs.has(db.name)}
|
|
onChange={() => _toggleDb(db.name)}
|
|
/>
|
|
<span>{db.name}</span>
|
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
|
|
({db.tableCount} {t('Tabellen')}, ~{_formatNumber(db.recordCount)} {t('Zeilen')})
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
className={styles.primaryButton}
|
|
onClick={_startExport}
|
|
disabled={exporting || selectedDbs.size === 0}
|
|
>
|
|
{exporting ? (
|
|
<><FaSync className="spinning" /> {t('Export laeuft...')}</>
|
|
) : (
|
|
<><FaDownload /> {t('Export starten')}</>
|
|
)}
|
|
</button>
|
|
|
|
{exportLog.length > 0 && (
|
|
<div
|
|
ref={exportLogRef}
|
|
style={{
|
|
marginTop: '0.75rem',
|
|
maxHeight: '200px',
|
|
overflow: 'auto',
|
|
background: 'var(--bg-secondary)',
|
|
border: '1px solid var(--border-color)',
|
|
borderRadius: '6px',
|
|
padding: '0.5rem 0.75rem',
|
|
fontFamily: 'monospace',
|
|
fontSize: '0.8125rem',
|
|
lineHeight: '1.6',
|
|
}}
|
|
>
|
|
{exportLog.map((entry, i) => (
|
|
<div key={i} style={{
|
|
color: entry.status === 'error' ? 'var(--danger-color, #e53e3e)'
|
|
: entry.status === 'success' ? '#388e3c' : 'var(--text-secondary)',
|
|
}}>
|
|
<span style={{ color: 'var(--text-tertiary)', marginRight: '0.5rem' }}>{entry.ts}</span>
|
|
{entry.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
{/* ---- DIVIDER ---- */}
|
|
<hr style={{ border: 'none', borderTop: '1px solid var(--border-color)', margin: 0 }} />
|
|
|
|
{/* ---- RESTORE SECTION ---- */}
|
|
<section>
|
|
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--text-primary)', margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<FaUpload /> {t('Restore')}
|
|
</h2>
|
|
|
|
{/* File upload zone */}
|
|
{!uploadedFile ? (
|
|
<div
|
|
onDrop={_onDrop}
|
|
onDragOver={_onDragOver}
|
|
onClick={() => 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',
|
|
}}
|
|
>
|
|
<FaUpload style={{ fontSize: '1.5rem', color: 'var(--text-tertiary)', marginBottom: '0.5rem' }} />
|
|
<p style={{ margin: 0, fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
|
|
{t('Datei hier ablegen oder klicken')}
|
|
</p>
|
|
<p style={{ margin: '0.25rem 0 0', fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
|
|
{t('JSON-Datei hochladen')}
|
|
</p>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".json"
|
|
onChange={_onFileInputChange}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{/* File info */}
|
|
<div className={styles.infoBox} style={{ justifyContent: 'space-between' }}>
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<FaDatabase />
|
|
{uploadedFile.name} ({_formatBytes(uploadedFile.size)})
|
|
</span>
|
|
<button
|
|
className={styles.secondaryButton}
|
|
onClick={_resetUpload}
|
|
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}
|
|
>
|
|
{t('Andere Datei')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Validation */}
|
|
{validating && (
|
|
<div className={styles.loadingContainer} style={{ padding: '1rem' }}>
|
|
<div className={styles.spinner} />
|
|
<span>{uploadProgress || t('Validierung laeuft...')}</span>
|
|
</div>
|
|
)}
|
|
|
|
{validation && !validating && (
|
|
<div style={{ marginTop: '1rem' }}>
|
|
<h3 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 0.75rem 0' }}>
|
|
{t('Pruefung')}
|
|
</h3>
|
|
|
|
{/* Validation warnings */}
|
|
{validation.warnings.length > 0 && (
|
|
<div className={styles.infoBox} style={{
|
|
background: 'var(--warning-bg, #fffbeb)',
|
|
borderColor: 'var(--warning-color, #d69e2e)',
|
|
flexDirection: 'column',
|
|
alignItems: 'flex-start',
|
|
gap: '0.25rem',
|
|
}}>
|
|
{validation.warnings.map((w, i) => (
|
|
<span key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<FaExclamationTriangle style={{ color: 'var(--warning-color, #d69e2e)', flexShrink: 0 }} /> {w}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Summary table */}
|
|
{validation.summary.length > 0 && (
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
|
<thead>
|
|
<tr style={{ borderBottom: '2px solid var(--border-color)' }}>
|
|
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>{t('Datenbank')}</th>
|
|
<th style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>{t('Tabellen')}</th>
|
|
<th style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>{t('Datensaetze')}</th>
|
|
<th style={{ textAlign: 'center', padding: '0.5rem 0.75rem' }}>{t('Status')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{validation.summary.map(s => (
|
|
<tr key={s.database} style={{ borderBottom: '1px solid var(--border-color)' }}>
|
|
<td style={{ padding: '0.5rem 0.75rem' }}>{s.database}</td>
|
|
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>{s.tableCount}</td>
|
|
<td style={{ textAlign: 'right', padding: '0.5rem 0.75rem' }}>{_formatNumber(s.recordCount)}</td>
|
|
<td style={{ textAlign: 'center', padding: '0.5rem 0.75rem' }}>
|
|
{s.registered ? (
|
|
<FaCheckCircle style={{ color: '#388e3c' }} />
|
|
) : (
|
|
<FaExclamationTriangle style={{ color: 'var(--warning-color, #d69e2e)' }} />
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{/* System objects info */}
|
|
{validation.systemObjectsFound.length > 0 && (
|
|
<div className={styles.infoBox} style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '0.25rem' }}>
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontWeight: 500 }}>
|
|
<FaInfoCircle style={{ color: 'var(--primary-color, #f25843)' }} />
|
|
{t('Systemdaten werden beim Import nicht geloescht')}
|
|
</span>
|
|
<span style={{ fontSize: '0.8125rem', color: 'var(--text-secondary)', paddingLeft: '1.5rem' }}>
|
|
{validation.systemObjectsFound.map(o => o.label).join(', ')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Import settings */}
|
|
{validation.valid && (
|
|
<div style={{ marginTop: '1rem' }}>
|
|
<h3 style={{ fontSize: '1rem', fontWeight: 600, margin: '0 0 0.75rem 0' }}>
|
|
{t('Import-Einstellungen')}
|
|
</h3>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1rem' }}>
|
|
<label className={styles.checkboxLabel} style={{ cursor: 'pointer' }}>
|
|
<input
|
|
type="radio"
|
|
name="importMode"
|
|
checked={importMode === 'merge'}
|
|
onChange={() => setImportMode('merge')}
|
|
/>
|
|
<div>
|
|
<div style={{ fontWeight: 500 }}>{t('Zusammenfuehren (bestehende Daten belassen, neue ergaenzen)')}</div>
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
|
|
{t('Bestehende Datensaetze bleiben erhalten, nur fehlende werden eingefuegt')}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
<label className={styles.checkboxLabel} style={{ cursor: 'pointer' }}>
|
|
<input
|
|
type="radio"
|
|
name="importMode"
|
|
checked={importMode === 'replace'}
|
|
onChange={() => setImportMode('replace')}
|
|
/>
|
|
<div>
|
|
<div style={{ fontWeight: 500 }}>{t('Neu (Datenbank leeren und importieren)')}</div>
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>
|
|
{t('Bestehende Daten werden geloescht und durch importierte Daten ersetzt')}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
{importMode === 'replace' && (
|
|
<div className={styles.infoBox} style={{
|
|
background: 'var(--danger-bg, #fff5f5)',
|
|
borderColor: 'var(--danger-color, #e53e3e)',
|
|
}}>
|
|
<FaExclamationTriangle style={{ marginRight: 8, color: 'var(--danger-color, #e53e3e)', flexShrink: 0 }} />
|
|
{t('Achtung: Bestehende Daten werden unwiderruflich geloescht. Erstellen Sie zuerst ein Backup.')}
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
className={importMode === 'replace' ? styles.dangerButton : styles.primaryButton}
|
|
onClick={_startImport}
|
|
disabled={importing}
|
|
style={{ marginTop: '0.5rem' }}
|
|
>
|
|
{importing ? (
|
|
<><FaSync className="spinning" /> {t('Import laeuft...')}</>
|
|
) : (
|
|
<><FaUpload /> {t('Import starten')}</>
|
|
)}
|
|
</button>
|
|
|
|
{importLog.length > 0 && (
|
|
<div
|
|
ref={importLogRef}
|
|
style={{
|
|
marginTop: '0.75rem',
|
|
maxHeight: '200px',
|
|
overflow: 'auto',
|
|
background: 'var(--bg-secondary)',
|
|
border: '1px solid var(--border-color)',
|
|
borderRadius: '6px',
|
|
padding: '0.5rem 0.75rem',
|
|
fontFamily: 'monospace',
|
|
fontSize: '0.8125rem',
|
|
lineHeight: '1.6',
|
|
}}
|
|
>
|
|
{importLog.map((entry, i) => (
|
|
<div key={i} style={{
|
|
color: entry.status === 'error' ? 'var(--danger-color, #e53e3e)'
|
|
: entry.status === 'success' ? '#388e3c' : 'var(--text-secondary)',
|
|
}}>
|
|
<span style={{ color: 'var(--text-tertiary)', marginRight: '0.5rem' }}>{entry.ts}</span>
|
|
{entry.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const AdminDatabaseHealthPage: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
|
|
const tabs = useMemo(() => [
|
|
{
|
|
id: 'stats',
|
|
label: t('Statistiken'),
|
|
content: <StatsTab />,
|
|
},
|
|
{
|
|
id: 'orphans',
|
|
label: t('Orphan Cleanup'),
|
|
content: <OrphansTab />,
|
|
},
|
|
{
|
|
id: 'legacy',
|
|
label: t('Legacy Cleanup'),
|
|
content: <LegacyCleanupTab />,
|
|
},
|
|
{
|
|
id: 'migration',
|
|
label: t('Migration'),
|
|
content: <MigrationTab />,
|
|
},
|
|
], [t]);
|
|
|
|
return (
|
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
|
<div className={styles.pageHeader}>
|
|
<div>
|
|
<h1 className={styles.pageTitle}>{t('Datenbank-Gesundheit')}</h1>
|
|
<p className={styles.pageSubtitle}>{t('Tabellenstatistiken, verwaiste Datensaetze und Migration')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs tabs={tabs} defaultTabId="stats" />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminDatabaseHealthPage;
|