db-export streaming
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
This commit is contained in:
parent
8f9d233d8c
commit
f27bfd2221
1 changed files with 80 additions and 81 deletions
|
|
@ -768,7 +768,6 @@ const MigrationTab: React.FC = () => {
|
|||
const [loadingDbs, setLoadingDbs] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportLog, setExportLog] = useState<ProgressLogEntry[]>([]);
|
||||
const [instanceLabel, setInstanceLabel] = useState('unknown');
|
||||
|
||||
// --- Restore state ---
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
|
|
@ -790,7 +789,6 @@ const MigrationTab: React.FC = () => {
|
|||
const dbs: MigrationDatabase[] = res.data.databases || [];
|
||||
setDatabases(dbs);
|
||||
setSelectedDbs(new Set(dbs.map(d => d.name)));
|
||||
if (res.data.instanceLabel) setInstanceLabel(res.data.instanceLabel);
|
||||
} catch {
|
||||
setDatabases([]);
|
||||
} finally {
|
||||
|
|
@ -836,99 +834,100 @@ const MigrationTab: React.FC = () => {
|
|||
setExportLog([]);
|
||||
|
||||
const dbList = Array.from(selectedDbs);
|
||||
const isFullExport = allSelected;
|
||||
const totalDbs = dbList.length;
|
||||
const dbsParam = allSelected ? 'all' : dbList.join(',');
|
||||
|
||||
_addExportLog(t('Export gestartet: {count} Datenbanken', { count: totalDbs }));
|
||||
|
||||
let totalTables = 0;
|
||||
let totalRecords = 0;
|
||||
let errors = 0;
|
||||
let exportedCount = 0;
|
||||
const collectedDatabases: Record<string, any> = {};
|
||||
|
||||
for (let i = 0; i < dbList.length; i++) {
|
||||
const dbName = dbList[i];
|
||||
_addExportLog(t('Exportiere {index}/{total}: {db}...', { index: i + 1, total: totalDbs, db: dbName }));
|
||||
try {
|
||||
const res = await api.get('/api/admin/database-health/migration/export-single', {
|
||||
params: { database: dbName },
|
||||
});
|
||||
totalTables += res.data.tableCount || 0;
|
||||
totalRecords += res.data.totalRecords || 0;
|
||||
collectedDatabases[dbName] = res.data.payload;
|
||||
exportedCount++;
|
||||
_addExportLog(
|
||||
t('{db}: {tables} Tabellen, {records} Datensaetze', {
|
||||
db: dbName, tables: res.data.tableCount || 0, records: res.data.totalRecords || 0,
|
||||
}),
|
||||
'success',
|
||||
);
|
||||
} catch (err: any) {
|
||||
errors++;
|
||||
const detail = err?.response?.data?.detail;
|
||||
_addExportLog(
|
||||
t('Fehler bei {db}: {error}', { db: dbName, error: typeof detail === 'string' ? detail : String(err) }),
|
||||
'error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (exportedCount === 0) {
|
||||
_addExportLog(t('Export abgebrochen: keine Daten exportiert'), 'error');
|
||||
toast.showError(t('Export fehlgeschlagen'));
|
||||
setExporting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
_addExportLog(t('Erstelle Exportdatei...'));
|
||||
_addExportLog(t('Export gestartet: {count} Datenbanken (Streaming)', { count: dbList.length }));
|
||||
|
||||
try {
|
||||
const ts = new Date().toISOString().replace(/:/g, '-').slice(0, 19);
|
||||
const scope = isFullExport ? 'full' : 'partial';
|
||||
const filename = `db_backup_${instanceLabel}_${scope}_${ts}.json`;
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
const url = `${baseURL}/api/admin/database-health/migration/export-stream?databases=${encodeURIComponent(dbsParam)}`;
|
||||
|
||||
const meta = JSON.stringify({
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
databaseCount: Object.keys(collectedDatabases).length,
|
||||
totalTables,
|
||||
totalRecords,
|
||||
});
|
||||
const response = await fetch(url, { credentials: 'include' });
|
||||
|
||||
const chunks: string[] = ['{"meta":', meta, ',"databases":{'];
|
||||
const dbEntries = Object.entries(collectedDatabases);
|
||||
for (let i = 0; i < dbEntries.length; i++) {
|
||||
const [dbName, dbData] = dbEntries[i];
|
||||
if (i > 0) chunks.push(',');
|
||||
chunks.push(JSON.stringify(dbName), ':', JSON.stringify(dbData));
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(`Server ${response.status}: ${errText}`);
|
||||
}
|
||||
chunks.push('}}');
|
||||
if (!response.body) throw new Error('ReadableStream not supported');
|
||||
|
||||
const blob = new Blob(chunks, { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
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 reader = response.body.getReader();
|
||||
let totalBytes = 0;
|
||||
let lastLogBytes = 0;
|
||||
|
||||
// Try File System Access API → streams directly to disk, no memory limit
|
||||
if ('showSaveFilePicker' in window) {
|
||||
try {
|
||||
const handle = await (window as any).showSaveFilePicker({
|
||||
suggestedName: filename,
|
||||
types: [{ description: 'JSON', accept: { 'application/json': ['.json'] } }],
|
||||
});
|
||||
const writable = await handle.createWritable();
|
||||
|
||||
_addExportLog(t('Streame Daten direkt auf Disk...'));
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
await writable.write(value);
|
||||
totalBytes += value.length;
|
||||
if (totalBytes - lastLogBytes >= 2 * 1024 * 1024) {
|
||||
_addExportLog(t('{size} geschrieben...', { size: _formatBytes(totalBytes) }));
|
||||
lastLogBytes = totalBytes;
|
||||
}
|
||||
}
|
||||
|
||||
await writable.close();
|
||||
_addExportLog(t('Export abgeschlossen: {size}', { size: _formatBytes(totalBytes) }), 'success');
|
||||
toast.showSuccess(t('Export gespeichert'));
|
||||
setExporting(false);
|
||||
return;
|
||||
} catch (fsErr: any) {
|
||||
if (fsErr.name === 'AbortError') {
|
||||
_addExportLog(t('Export abgebrochen'), 'error');
|
||||
setExporting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: accumulate binary chunks → Blob (no JS string limit)
|
||||
_addExportLog(t('Lade Daten herunter...'));
|
||||
const chunks: Uint8Array[] = [];
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
totalBytes += value.length;
|
||||
if (totalBytes - lastLogBytes >= 5 * 1024 * 1024) {
|
||||
_addExportLog(t('{size} heruntergeladen...', { size: _formatBytes(totalBytes) }));
|
||||
lastLogBytes = totalBytes;
|
||||
}
|
||||
}
|
||||
|
||||
_addExportLog(t('Erstelle Datei ({size})...', { size: _formatBytes(totalBytes) }));
|
||||
|
||||
const blob = new Blob(chunks as BlobPart[], { type: 'application/json' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
|
||||
_addExportLog(
|
||||
t('Export abgeschlossen: {dbs} Datenbanken, {tables} Tabellen, {records} Datensaetze', {
|
||||
dbs: exportedCount, tables: totalTables, records: totalRecords,
|
||||
}),
|
||||
'success',
|
||||
);
|
||||
|
||||
if (errors > 0) {
|
||||
toast.showWarning(t('Export mit {count} Fehlern abgeschlossen', { count: errors }));
|
||||
} else {
|
||||
toast.showSuccess(t('Export erfolgreich'));
|
||||
}
|
||||
_addExportLog(t('Export abgeschlossen: {size}', { size: _formatBytes(totalBytes) }), 'success');
|
||||
toast.showSuccess(t('Export heruntergeladen'));
|
||||
} catch (err: any) {
|
||||
_addExportLog(t('Fehler beim Download der Exportdatei: {error}', { error: String(err) }), 'error');
|
||||
_addExportLog(t('Fehler: {error}', { error: String(err) }), 'error');
|
||||
toast.showError(t('Export fehlgeschlagen'));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
|
|
|
|||
Loading…
Reference in a new issue