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..8cb4279 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,96 +834,88 @@ 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 })); + _addExportLog(t('Export gestartet: {count} Datenbanken (Streaming)', { count: dbList.length })); - 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; - } + const baseURL = api.defaults.baseURL || ''; + const url = `${baseURL}/api/admin/database-health/migration/export-stream?databases=${encodeURIComponent(dbsParam)}`; - let totalTables = 0; - let totalRecords = 0; - let errors = 0; - let exportedCount = 0; + const response = await fetch(url, { credentials: 'include' }); - 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 }, - }); - totalTables += res.data.tableCount || 0; - totalRecords += res.data.totalRecords || 0; - 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 (!response.ok) { + const errText = await response.text(); + throw new Error(`Server ${response.status}: ${errText}`); } - } + if (!response.body) throw new Error('ReadableStream not supported'); - if (exportedCount === 0) { - _addExportLog(t('Export abgebrochen: keine Daten exportiert'), 'error'); - toast.showError(t('Export fehlgeschlagen')); - setExporting(false); - return; - } + 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]; + } - _addExportLog(t('Erstelle Exportdatei...')); - - 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 res = await api.get('/api/admin/database-health/migration/export-download', { - params: { token, filename }, - responseType: 'blob', + const handle = await (window as any).showSaveFilePicker({ + suggestedName: filename, + types: [{ description: 'JSON', accept: { 'application/json': ['.json'] } }], }); + const writable = await handle.createWritable(); - const blob = new Blob([res.data], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + 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 = ''; - _addExportLog( - t('Export abgeschlossen: {dbs} Datenbanken, {tables} Tabellen, {records} Datensaetze', { - dbs: exportedCount, tables: totalTables, records: totalRecords, - }), - 'success', - ); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + await writable.write(value); + totalBytes += value.length; - if (errors > 0) { - toast.showWarning(t('Export mit {count} Fehlern abgeschlossen', { count: errors })); - } else { - toast.showSuccess(t('Export erfolgreich')); + 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 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);