Merge pull request 'main' (#1) from main into int
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m33s
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m33s
Reviewed-on: #1
This commit is contained in:
commit
4d364e783e
4 changed files with 106 additions and 93 deletions
|
|
@ -396,7 +396,8 @@
|
||||||
min-width: 0;
|
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 {
|
.nodeActionsHover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|
@ -408,7 +409,6 @@
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodeRow:hover .nodeActionsHover {
|
.nodeRow:hover .nodeActionsHover {
|
||||||
|
|
|
||||||
|
|
@ -382,13 +382,11 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hideRowActionButtons && (
|
{!hideRowActionButtons && (
|
||||||
|
<>
|
||||||
|
<div className={styles.nodeSizeGroup}>
|
||||||
<span className={styles.nodeSize}>
|
<span className={styles.nodeSize}>
|
||||||
{node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''}
|
{node.sizeBytes != null ? _formatSize(node.sizeBytes) : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
|
|
||||||
{!hideRowActionButtons && (
|
|
||||||
<>
|
|
||||||
<div className={styles.nodeActionsHover}>
|
<div className={styles.nodeActionsHover}>
|
||||||
{canCreateChild && onCreateChild && (
|
{canCreateChild && onCreateChild && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -446,6 +444,7 @@ const TreeNodeRow = React.memo(function TreeNodeRow<T>({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.nodeActionsPersistent}>
|
<div className={styles.nodeActionsPersistent}>
|
||||||
{/* Order (left-to-right): extraActions (e.g. settings) -> RAG -> sendToChat -> scope -> neutralize. */}
|
{/* Order (left-to-right): extraActions (e.g. settings) -> RAG -> sendToChat -> scope -> neutralize. */}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ interface FolderData {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
scope?: ScopeValue;
|
scope?: ScopeValue | 'mixed';
|
||||||
neutralize?: boolean | 'mixed';
|
neutralize?: boolean | 'mixed';
|
||||||
contextOrphan?: boolean;
|
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<boolean | string>();
|
||||||
|
const sVals = new Set<string>();
|
||||||
|
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 {
|
export function createFolderFileProvider(options: { includeFiles?: boolean } = {}): TreeNodeProvider {
|
||||||
const includeFiles = options.includeFiles !== false;
|
const includeFiles = options.includeFiles !== false;
|
||||||
const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared');
|
const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared');
|
||||||
|
|
@ -136,12 +156,16 @@ export function createFolderFileProvider(options: { includeFiles?: boolean } = {
|
||||||
rootKey: 'files',
|
rootKey: 'files',
|
||||||
|
|
||||||
async loadChildren(parentId, ownership) {
|
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) {
|
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);
|
const synthRootId = _SYNTH_ROOT_ID(ownership);
|
||||||
|
|
|
||||||
|
|
@ -768,7 +768,6 @@ const MigrationTab: React.FC = () => {
|
||||||
const [loadingDbs, setLoadingDbs] = useState(false);
|
const [loadingDbs, setLoadingDbs] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [exportLog, setExportLog] = useState<ProgressLogEntry[]>([]);
|
const [exportLog, setExportLog] = useState<ProgressLogEntry[]>([]);
|
||||||
const [instanceLabel, setInstanceLabel] = useState('unknown');
|
|
||||||
|
|
||||||
// --- Restore state ---
|
// --- Restore state ---
|
||||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||||
|
|
@ -790,7 +789,6 @@ const MigrationTab: React.FC = () => {
|
||||||
const dbs: MigrationDatabase[] = res.data.databases || [];
|
const dbs: MigrationDatabase[] = res.data.databases || [];
|
||||||
setDatabases(dbs);
|
setDatabases(dbs);
|
||||||
setSelectedDbs(new Set(dbs.map(d => d.name)));
|
setSelectedDbs(new Set(dbs.map(d => d.name)));
|
||||||
if (res.data.instanceLabel) setInstanceLabel(res.data.instanceLabel);
|
|
||||||
} catch {
|
} catch {
|
||||||
setDatabases([]);
|
setDatabases([]);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -836,96 +834,88 @@ const MigrationTab: React.FC = () => {
|
||||||
setExportLog([]);
|
setExportLog([]);
|
||||||
|
|
||||||
const dbList = Array.from(selectedDbs);
|
const dbList = Array.from(selectedDbs);
|
||||||
const isFullExport = allSelected;
|
const dbsParam = allSelected ? 'all' : dbList.join(',');
|
||||||
|
|
||||||
|
_addExportLog(t('Export gestartet: {count} Datenbanken (Streaming)', { count: dbList.length }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseURL = api.defaults.baseURL || '';
|
||||||
|
const url = `${baseURL}/api/admin/database-health/migration/export-stream?databases=${encodeURIComponent(dbsParam)}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, { credentials: 'include' });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Server ${response.status}: ${errText}`);
|
||||||
|
}
|
||||||
|
if (!response.body) throw new Error('ReadableStream not supported');
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
let currentDb = '';
|
||||||
|
let dbCount = 0;
|
||||||
const totalDbs = dbList.length;
|
const totalDbs = dbList.length;
|
||||||
|
let tailBuf = '';
|
||||||
|
|
||||||
_addExportLog(t('Export gestartet: {count} Datenbanken', { count: totalDbs }));
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
await writable.write(value);
|
||||||
|
totalBytes += value.length;
|
||||||
|
|
||||||
let token = '';
|
const text = decoder.decode(value, { stream: true });
|
||||||
try {
|
tailBuf += text;
|
||||||
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;
|
const match = tailBuf.match(/"(poweron_[a-z_]+)":\{"tables"/);
|
||||||
let totalRecords = 0;
|
if (match && match[1] !== currentDb) {
|
||||||
let errors = 0;
|
currentDb = match[1];
|
||||||
let exportedCount = 0;
|
dbCount++;
|
||||||
|
tailBuf = '';
|
||||||
for (let i = 0; i < dbList.length; i++) {
|
const mb = (totalBytes / (1024 * 1024)).toFixed(1);
|
||||||
const dbName = dbList[i];
|
_addExportLog(t('{index}/{total}: {db} — {mb} MB', {
|
||||||
_addExportLog(t('Exportiere {index}/{total}: {db}...', { index: i + 1, total: totalDbs, db: dbName }));
|
index: dbCount, total: totalDbs, db: currentDb, mb,
|
||||||
try {
|
}));
|
||||||
const res = await api.get('/api/admin/database-health/migration/export-single', {
|
lastLogBytes = totalBytes;
|
||||||
params: { token, database: dbName },
|
} else if (totalBytes - lastLogBytes >= 2 * 1024 * 1024) {
|
||||||
});
|
const mb = (totalBytes / (1024 * 1024)).toFixed(1);
|
||||||
totalTables += res.data.tableCount || 0;
|
setExportLog(prev => {
|
||||||
totalRecords += res.data.totalRecords || 0;
|
const updated = [...prev];
|
||||||
exportedCount++;
|
updated[updated.length - 1] = {
|
||||||
_addExportLog(
|
ts: new Date().toLocaleTimeString(),
|
||||||
t('{db}: {tables} Tabellen, {records} Datensaetze', {
|
message: t('{index}/{total}: {db} — {mb} MB', {
|
||||||
db: dbName, tables: res.data.tableCount || 0, records: res.data.totalRecords || 0,
|
index: dbCount, total: totalDbs, db: currentDb || '...', mb,
|
||||||
}),
|
}),
|
||||||
'success',
|
status: 'info',
|
||||||
);
|
};
|
||||||
} catch (err: any) {
|
return updated;
|
||||||
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...'));
|
|
||||||
|
|
||||||
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',
|
|
||||||
});
|
});
|
||||||
|
lastLogBytes = totalBytes;
|
||||||
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);
|
|
||||||
|
|
||||||
_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'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} 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'));
|
toast.showError(t('Export fehlgeschlagen'));
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue