From 5a5d24bbe26b3cdcba7a10266c5b45dd1c27dbe7 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 27 May 2026 17:43:53 +0200 Subject: [PATCH 1/4] fix db sync --- .../FormGeneratorTree.module.css | 4 +-- .../FormGeneratorTree/FormGeneratorTree.tsx | 13 ++++--- .../providers/FolderFileProvider.tsx | 36 +++++++++++++++---- src/pages/admin/AdminDatabaseHealthPage.tsx | 32 ++++++++--------- 4 files changed, 53 insertions(+), 32 deletions(-) diff --git a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css index bb2cdb1..088de25 100644 --- a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css +++ b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.module.css @@ -396,7 +396,8 @@ min-width: 0; } -/* Hover action icons -- overlay on top of file size to save width */ +/* Hover action icons -- overlay on top of file size to save width. + Positioned inside .nodeSizeGroup so they never cover .nodeActionsPersistent. */ .nodeActionsHover { position: absolute; right: 0; @@ -408,7 +409,6 @@ gap: 2px; opacity: 0; transition: opacity 0.15s ease; - z-index: 1; } .nodeRow:hover .nodeActionsHover { diff --git a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx index 1b9aca7..cee9833 100644 --- a/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/FormGeneratorTree.tsx @@ -381,15 +381,13 @@ const TreeNodeRow = React.memo(function TreeNodeRow({ )} - {!hideRowActionButtons && ( - - {node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''} - - )} - {!hideRowActionButtons && ( <> -
+
+ + {node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''} + +
{canCreateChild && onCreateChild && ( )} +
diff --git a/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx b/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx index f983a84..cb3bb2d 100644 --- a/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx +++ b/src/components/FormGenerator/FormGeneratorTree/providers/FolderFileProvider.tsx @@ -7,7 +7,7 @@ interface FolderData { id: string; name: string; parentId?: string | null; - scope?: ScopeValue; + scope?: ScopeValue | 'mixed'; neutralize?: boolean | 'mixed'; contextOrphan?: boolean; } @@ -83,6 +83,26 @@ function _makeSyntheticRoot(ownership: Ownership): TreeNode { }; } +function _applyRootAggregate(root: TreeNode, allFolders: FolderData[]): void { + const topFolders = allFolders.filter((f) => (f.parentId ?? null) === null); + if (topFolders.length === 0) return; + const nVals = new Set(); + const sVals = new Set(); + for (const f of topFolders) { + if (f.neutralize === 'mixed') { nVals.add(true); nVals.add(false); } + else nVals.add(Boolean(f.neutralize)); + const sv = f.scope ?? 'personal'; + if (sv === 'mixed') { sVals.add('__a'); sVals.add('__b'); } + else sVals.add(sv); + } + root.neutralize = nVals.size > 1 + ? 'mixed' + : ((nVals.values().next().value as boolean) ?? false); + root.scope = (sVals.size > 1 + ? 'mixed' + : (sVals.values().next().value ?? 'personal')) as ScopeValue; +} + export function createFolderFileProvider(options: { includeFiles?: boolean } = {}): TreeNodeProvider { const includeFiles = options.includeFiles !== false; const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared'); @@ -136,12 +156,16 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = { rootKey: 'files', async loadChildren(parentId, ownership) { - // Synthetic root: when the tree asks for top-level (parentId=null), - // we return ONE container ("/") instead of the real items. The real - // top-level items are then loaded as children of that container the - // next time the tree resolves it (auto-expanded via defaultExpanded). if (parentId === null) { - return [_makeSyntheticRoot(ownership)]; + const root = _makeSyntheticRoot(ownership); + try { + const res = await api.get('/api/files/folders/tree', { + params: { owner: ownerParam(ownership) }, + }); + const allFolders: FolderData[] = res.data ?? []; + _applyRootAggregate(root, allFolders); + } catch { /* keep defaults */ } + return [root]; } const synthRootId = _SYNTH_ROOT_ID(ownership); diff --git a/src/pages/admin/AdminDatabaseHealthPage.tsx b/src/pages/admin/AdminDatabaseHealthPage.tsx index ef98bb2..f34ca63 100644 --- a/src/pages/admin/AdminDatabaseHealthPage.tsx +++ b/src/pages/admin/AdminDatabaseHealthPage.tsx @@ -841,31 +841,22 @@ const MigrationTab: React.FC = () => { _addExportLog(t('Export gestartet: {count} Datenbanken', { count: totalDbs })); - let token = ''; - try { - const startRes = await api.post('/api/admin/database-health/migration/export-start'); - token = startRes.data.token; - } catch (err: any) { - _addExportLog(t('Fehler beim Starten des Exports: {error}', { error: String(err) }), 'error'); - toast.showError(t('Export fehlgeschlagen')); - setExporting(false); - return; - } - 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: { token, database: dbName }, + 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', { @@ -897,12 +888,19 @@ const MigrationTab: React.FC = () => { const scope = isFullExport ? 'full' : 'partial'; const filename = `db_backup_${instanceLabel}_${scope}_${ts}.json`; - const res = await api.get('/api/admin/database-health/migration/export-download', { - params: { token, filename }, - responseType: 'blob', - }); + const exportData = { + meta: { + exportedAt: new Date().toISOString(), + version: '1.0', + databaseCount: Object.keys(collectedDatabases).length, + totalTables, + totalRecords, + }, + databases: collectedDatabases, + }; - const blob = new Blob([res.data], { type: 'application/json' }); + const content = JSON.stringify(exportData, null, 0); + const blob = new Blob([content], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; -- 2.45.2 From 8f9d233d8c2c957562ce764d21c0ea1e9f5d805e Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 27 May 2026 18:07:04 +0200 Subject: [PATCH 2/4] fixed db export --- src/pages/admin/AdminDatabaseHealthPage.tsx | 29 ++++++++++++--------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/pages/admin/AdminDatabaseHealthPage.tsx b/src/pages/admin/AdminDatabaseHealthPage.tsx index f34ca63..4508727 100644 --- a/src/pages/admin/AdminDatabaseHealthPage.tsx +++ b/src/pages/admin/AdminDatabaseHealthPage.tsx @@ -888,19 +888,24 @@ const MigrationTab: React.FC = () => { const scope = isFullExport ? 'full' : 'partial'; const filename = `db_backup_${instanceLabel}_${scope}_${ts}.json`; - const exportData = { - meta: { - exportedAt: new Date().toISOString(), - version: '1.0', - databaseCount: Object.keys(collectedDatabases).length, - totalTables, - totalRecords, - }, - databases: collectedDatabases, - }; + const meta = JSON.stringify({ + exportedAt: new Date().toISOString(), + version: '1.0', + databaseCount: Object.keys(collectedDatabases).length, + totalTables, + totalRecords, + }); - const content = JSON.stringify(exportData, null, 0); - const blob = new Blob([content], { type: 'application/json' }); + 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)); + } + chunks.push('}}'); + + const blob = new Blob(chunks, { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; -- 2.45.2 From f27bfd2221c886fd122c9c10e14022b5d74f9937 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 27 May 2026 19:37:37 +0200 Subject: [PATCH 3/4] 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); -- 2.45.2 From 57319507bb826d9b744406e84eafebded1eff413 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 27 May 2026 23:05:00 +0200 Subject: [PATCH 4/4] streaming export with log --- src/pages/admin/AdminDatabaseHealthPage.tsx | 102 +++++++++----------- 1 file changed, 45 insertions(+), 57 deletions(-) diff --git a/src/pages/admin/AdminDatabaseHealthPage.tsx b/src/pages/admin/AdminDatabaseHealthPage.tsx index 46bd495..8cb4279 100644 --- a/src/pages/admin/AdminDatabaseHealthPage.tsx +++ b/src/pages/admin/AdminDatabaseHealthPage.tsx @@ -857,75 +857,63 @@ const MigrationTab: React.FC = () => { 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; - - // 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[] = []; + let currentDb = ''; + let dbCount = 0; + const totalDbs = dbList.length; + let tailBuf = ''; for (;;) { const { done, value } = await reader.read(); if (done) break; - chunks.push(value); + await writable.write(value); totalBytes += value.length; - if (totalBytes - lastLogBytes >= 5 * 1024 * 1024) { - _addExportLog(t('{size} heruntergeladen...', { size: _formatBytes(totalBytes) })); + + 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); } - _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 = blobUrl; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(blobUrl); - - _addExportLog(t('Export abgeschlossen: {size}', { size: _formatBytes(totalBytes) }), 'success'); - toast.showSuccess(t('Export heruntergeladen')); + 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')); -- 2.45.2