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;
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;
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);
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'));