From f27bfd2221c886fd122c9c10e14022b5d74f9937 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 27 May 2026 19:37:37 +0200 Subject: [PATCH] db-export streaming --- src/pages/admin/AdminDatabaseHealthPage.tsx | 161 ++++++++++---------- 1 file changed, 80 insertions(+), 81 deletions(-) diff --git a/src/pages/admin/AdminDatabaseHealthPage.tsx b/src/pages/admin/AdminDatabaseHealthPage.tsx index 4508727..46bd495 100644 --- a/src/pages/admin/AdminDatabaseHealthPage.tsx +++ b/src/pages/admin/AdminDatabaseHealthPage.tsx @@ -768,7 +768,6 @@ const MigrationTab: React.FC = () => { const [loadingDbs, setLoadingDbs] = useState(false); const [exporting, setExporting] = useState(false); const [exportLog, setExportLog] = useState([]); - const [instanceLabel, setInstanceLabel] = useState('unknown'); // --- Restore state --- const [uploadedFile, setUploadedFile] = useState(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 = {}; - - 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);