From 65170d9e4c882f2f8b2b5dfb289e31b293188327 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 18 May 2026 07:57:02 +0200 Subject: [PATCH] fixed toggle icons udb --- src/api/connectionApi.ts | 66 ++- src/api/trusteeApi.ts | 9 +- .../RagRunningBadge/RagRunningBadge.tsx | 1 + .../DataSourceSettingsModal.tsx | 320 ++++++++++++++ src/components/UnifiedDataBar/SourcesTab.tsx | 404 ++++++++++++------ src/hooks/useBackgroundJob.ts | 5 + src/pages/RagInventoryPage.tsx | 36 +- src/pages/basedata/ConnectionsPage.tsx | 46 +- 8 files changed, 752 insertions(+), 135 deletions(-) create mode 100644 src/components/UnifiedDataBar/DataSourceSettingsModal.tsx diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index 828d2f1..27ee41e 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -324,11 +324,60 @@ export async function postKnowledgeStop( }); } +export interface RagLimits { + maxItems?: number; + maxBytes?: number; + maxFileSize?: number; + maxDepth?: number; + // ClickUp variant + maxTasks?: number; + maxWorkspaces?: number; + maxListsPerWorkspace?: number; +} + +export interface DataSourceSettings { + ragLimits?: RagLimits; +} + +export interface CostEstimate { + estimatedTokens: number; + estimatedUsd: number; + basis: { + kind: string; + limits: Record; + assumptions: Record; + notes: string; + }; + sourceId?: string; +} + +export async function patchDataSourceSettings( + request: ApiRequestFunction, + dataSourceId: string, + settings: DataSourceSettings +): Promise<{ sourceId: string; settings: DataSourceSettings; updated: boolean }> { + return await request({ + url: `/api/datasources/${dataSourceId}/settings`, + method: 'patch', + data: { settings } + }); +} + +export async function getDataSourceCostEstimate( + request: ApiRequestFunction, + dataSourceId: string +): Promise { + return await request({ + url: `/api/datasources/${dataSourceId}/cost-estimate`, + method: 'get' + }); +} + export async function patchDataSourceRagIndex( request: ApiRequestFunction, dataSourceId: string, - ragIndexEnabled: boolean -): Promise<{ sourceId: string; ragIndexEnabled: boolean; updated: boolean }> { + ragIndexEnabled: boolean | null +): Promise<{ sourceId: string; ragIndexEnabled: boolean | null; updated: boolean; cascadedDescendants?: number }> { return await request({ url: `/api/datasources/${dataSourceId}/rag-index`, method: 'patch', @@ -345,8 +394,9 @@ export interface RagDataSourceDto { label: string; path: string; sourceType: string; - ragIndexEnabled: boolean; - neutralize: boolean; + /** Three-state inherit semantics on backend; UI reads as effective boolean from RAG inventory aggregator. */ + ragIndexEnabled: boolean | null; + neutralize: boolean | null; lastIndexed: number | null; /** Distinct files indexed for this DataSource (one row per source document). */ fileCount: number; @@ -363,7 +413,12 @@ export interface RagConnectionDto { dataSources: RagDataSourceDto[]; totalFiles: number; totalChunks: number; - runningJobs: { jobId: string; progress: number; progressMessage: string }[]; + runningJobs: { + jobId: string; + progress: number; + /** Already translated server-side. */ + progressMessage: string; + }[]; lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null; lastSuccess?: { jobId: string; @@ -392,6 +447,7 @@ export interface RagActiveJobDto { connectionLabel?: string; jobType: string; progress: number | null; + /** Already translated server-side. */ progressMessage: string; } diff --git a/src/api/trusteeApi.ts b/src/api/trusteeApi.ts index f21a369..9419320 100644 --- a/src/api/trusteeApi.ts +++ b/src/api/trusteeApi.ts @@ -864,7 +864,14 @@ export async function syncPositionsToAccounting( request: ApiRequestFunction, instanceId: string, positionIds: string[], - opts?: { pollMs?: number; onProgress?: (progress: number, message?: string | null) => void } + opts?: { + pollMs?: number; + /** + * `message` is already translated server-side by the job route handler + * (`resolveJobMessage`). Render it 1:1; never feed it through `t()`. + */ + onProgress?: (progress: number, message?: string | null) => void; + } ): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> { const submission = await request({ url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`, diff --git a/src/components/RagRunningBadge/RagRunningBadge.tsx b/src/components/RagRunningBadge/RagRunningBadge.tsx index 5d450d6..438d09e 100644 --- a/src/components/RagRunningBadge/RagRunningBadge.tsx +++ b/src/components/RagRunningBadge/RagRunningBadge.tsx @@ -9,6 +9,7 @@ interface _RagJob { connectionLabel?: string; jobType: string; progress: number | null; + /** Already translated server-side (route handler runs `resolveJobMessage`). */ progressMessage: string; } diff --git a/src/components/UnifiedDataBar/DataSourceSettingsModal.tsx b/src/components/UnifiedDataBar/DataSourceSettingsModal.tsx new file mode 100644 index 0000000..a6b69de --- /dev/null +++ b/src/components/UnifiedDataBar/DataSourceSettingsModal.tsx @@ -0,0 +1,320 @@ +/** + * DataSourceSettingsModal + * + * Single modal for editing DataSource-scoped + Connection-scoped settings + * from the UDB tree (Settings ⚙️ icon). Three sections: + * + * 1. Connection — knowledgeIngestionEnabled master switch + mail/clickup prefs + * 2. DataSource RAG-Limits — maxBytes/maxFileSize/maxItems/maxDepth (or clickup variants) + * 3. Cost estimate — indicative, non-binding USD figure + * + * Why a single modal: + * - The architectural rule is "no icon inflation in the UDB". One ⚙️ opens + * the only place where ANY setting for a node is managed. + * + * Why both scopes in one modal: + * - Editing a DataSource without seeing whether the parent Connection's + * master switch is on is confusing. Surface both, with a clear visual + * separation between Connection vs. DataSource sections. + */ + +import React, { useEffect, useMemo, useState } from 'react'; +import { FaTimes, FaToggleOn, FaToggleOff } from 'react-icons/fa'; +import { useApiRequest } from '../../hooks/useApi'; +import { useLanguage } from '../../providers/language/LanguageContext'; +import { + patchDataSourceSettings, + getDataSourceCostEstimate, + patchKnowledgeConsent, + type RagLimits, + type CostEstimate, +} from '../../api/connectionApi'; + +interface Props { + open: boolean; + title: string; + dataSourceId?: string; + connectionId?: string; + initialKnowledgeIngestionEnabled?: boolean; + initialRagLimits?: RagLimits | null; + /** + * When false the RAG-Limits and Cost-Estimate sections are hidden. + * Only the DataSource-Root (Level 2 in the UDB tree) should show RAG + * settings — sub-elements inherit their parent's limits via the walker. + */ + showRagSection?: boolean; + /** Triggered after a successful save so the parent can refetch its lists. */ + onSaved?: () => void; + onClose: () => void; +} + +const _CLICKUP_KEYS: (keyof RagLimits)[] = ['maxTasks', 'maxWorkspaces', 'maxListsPerWorkspace']; +const _FILES_KEYS: (keyof RagLimits)[] = ['maxItems', 'maxBytes', 'maxFileSize', 'maxDepth']; + +function _isByteLimit(key: keyof RagLimits): boolean { + return key === 'maxBytes' || key === 'maxFileSize'; +} + +function _displayValue(key: keyof RagLimits, value: number | undefined): string { + if (value == null) return ''; + if (_isByteLimit(key)) { + return String(Math.round(value / 1024 / 1024)); + } + return String(value); +} + +function _parseInput(key: keyof RagLimits, raw: string): number | null { + if (raw == null || raw === '') return null; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return null; + return _isByteLimit(key) ? Math.round(n * 1024 * 1024) : Math.round(n); +} + +function _labelFor(key: keyof RagLimits, t: (k: string) => string): string { + switch (key) { + case 'maxBytes': return t('Max. Datenvolumen (MB)'); + case 'maxFileSize': return t('Max. Dateigrösse (MB)'); + case 'maxItems': return t('Max. Dateien (Anzahl)'); + case 'maxDepth': return t('Max. Ordnertiefe'); + case 'maxTasks': return t('Max. Tasks (Anzahl)'); + case 'maxWorkspaces': return t('Max. Workspaces'); + case 'maxListsPerWorkspace':return t('Max. Listen pro Workspace'); + default: return String(key); + } +} + +export const DataSourceSettingsModal: React.FC = ({ + open, title, dataSourceId, connectionId, + initialKnowledgeIngestionEnabled, initialRagLimits, showRagSection = true, + onSaved, onClose, +}) => { + const { t } = useLanguage(); + const { request } = useApiRequest(); + + const [knowledgeOn, setKnowledgeOn] = useState(!!initialKnowledgeIngestionEnabled); + const [ragLimits, setRagLimits] = useState(initialRagLimits || {}); + const [cost, setCost] = useState(null); + const [costLoading, setCostLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + + const limitKeys: (keyof RagLimits)[] = useMemo(() => { + if (cost?.basis?.kind === 'clickup') return _CLICKUP_KEYS; + if (ragLimits.maxTasks != null) return _CLICKUP_KEYS; + return _FILES_KEYS; + }, [cost, ragLimits]); + + useEffect(() => { + if (!open) return; + setKnowledgeOn(!!initialKnowledgeIngestionEnabled); + setRagLimits(initialRagLimits || {}); + setErrorMsg(null); + setCost(null); + if (!dataSourceId) return; + setCostLoading(true); + getDataSourceCostEstimate(request, dataSourceId) + .then(result => { + setCost(result); + if (Object.keys(initialRagLimits || {}).length === 0 && result?.basis?.limits) { + setRagLimits(result.basis.limits as RagLimits); + } + }) + .catch(err => { + setErrorMsg(typeof err === 'string' ? err : (err?.message || t('Kostenschätzung konnte nicht geladen werden.'))); + }) + .finally(() => setCostLoading(false)); + }, [open, dataSourceId, initialKnowledgeIngestionEnabled, initialRagLimits, request, t]); + + if (!open) return null; + + const _handleConsentToggle = async () => { + if (!connectionId) return; + const newValue = !knowledgeOn; + if (!newValue) { + const ok = window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?')); + if (!ok) return; + } + setSaving(true); + try { + await patchKnowledgeConsent(request, connectionId, newValue); + setKnowledgeOn(newValue); + onSaved?.(); + } catch (err: any) { + setErrorMsg(err?.message || t('Master-Switch konnte nicht geändert werden.')); + } finally { + setSaving(false); + } + }; + + const _handleLimitChange = (key: keyof RagLimits, raw: string) => { + setRagLimits(prev => { + const next = { ...prev }; + if (raw === '') { delete next[key]; return next; } + const parsed = _parseInput(key, raw); + if (parsed == null) return prev; + next[key] = parsed; + return next; + }); + }; + + const _handleSaveLimits = async () => { + if (!dataSourceId) return; + setSaving(true); + setErrorMsg(null); + try { + const cleaned: RagLimits = {}; + for (const k of limitKeys) { + const v = ragLimits[k]; + if (v != null) cleaned[k] = v; + } + await patchDataSourceSettings(request, dataSourceId, { ragLimits: cleaned }); + const refreshed = await getDataSourceCostEstimate(request, dataSourceId); + setCost(refreshed); + onSaved?.(); + } catch (err: any) { + setErrorMsg(err?.message || t('Speichern fehlgeschlagen.')); + } finally { + setSaving(false); + } + }; + + return ( +
+
e.stopPropagation()} + style={{ + background: '#fff', borderRadius: 8, padding: 0, + width: 'min(540px, 92vw)', maxHeight: '85vh', display: 'flex', flexDirection: 'column', + boxShadow: '0 12px 40px rgba(0,0,0,0.2)', + }} + > +
+

+ {'\u2699\uFE0F '}{t('Einstellungen')} — {title} +

+ +
+ +
+ {errorMsg && ( +
{errorMsg}
+ )} + + {/* --- Section: Connection --- */} + {connectionId && ( +
+

+ {t('Verbindung')} +

+
+
+
{t('Wissensdatenbank aktiv')}
+
+ {t('Master-Schalter — wirkt auf ALLE Datenquellen dieser Verbindung.')} +
+
+ +
+
+ )} + + {/* --- Section: RAG Limits (only on DataSource-Root, not sub-elements) --- */} + {dataSourceId && showRagSection && ( +
+

+ {t('RAG-Indexierungs-Limits')} +

+
+ {t('Walker stoppt bei den ersten erreichten Limit. Defaults greifen, wenn ein Feld leer ist.')} +
+
+ {limitKeys.map(key => ( + + + _handleLimitChange(key, e.target.value)} + placeholder={cost?.basis?.limits?.[key] != null ? _displayValue(key, cost.basis.limits[key]) : ''} + style={{ + padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4, + textAlign: 'right', + }} + /> + + ))} +
+
+ +
+
+ )} + + {/* --- Section: Cost estimate (only on DataSource-Root) --- */} + {dataSourceId && showRagSection && ( +
+

+ {t('Kostenschätzung (indikativ)')} +

+ {costLoading &&
{t('Wird berechnet…')}
} + {!costLoading && cost && ( +
+
+ {t('Voll-Sync (geschätzt)')} + ~ {cost.estimatedUsd.toFixed(4)} USD +
+
+ ~ {cost.estimatedTokens.toLocaleString()} {t('Tokens')} · {cost.basis.notes} +
+
+ )} +
+ )} +
+
+
+ ); +}; + +export default DataSourceSettingsModal; diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx index f2cc679..4e0f131 100644 --- a/src/components/UnifiedDataBar/SourcesTab.tsx +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -28,6 +28,7 @@ import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; import { getPageIcon } from '../../config/pageRegistry'; import styles from './SourcesTab.module.css'; +import { DataSourceSettingsModal } from './DataSourceSettingsModal'; import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaLink, FaFolder, FaEnvelope, FaComments, FaCalendarAlt, FaUser, FaCloudUploadAlt } from 'react-icons/fa'; import { SiJira } from 'react-icons/si'; @@ -42,9 +43,13 @@ interface UdbDataSource { path: string; label: string; displayPath?: string; - scope: string; - neutralize: boolean; - ragIndexEnabled?: boolean; + /** Three-state cascade-inherit. null = inherit from nearest ancestor. */ + scope: string | null; + /** Three-state cascade-inherit. null = inherit. */ + neutralize: boolean | null; + /** Three-state cascade-inherit. null = inherit. */ + ragIndexEnabled: boolean | null; + settings?: Record | null; } interface UdbFeatureDataSource { @@ -54,8 +59,10 @@ interface UdbFeatureDataSource { tableName: string; objectKey: string; label: string; - scope: string; - neutralize: boolean; + /** Three-state cascade-inherit. null = inherit from nearest ancestor FDS. */ + scope: string | null; + /** Three-state cascade-inherit. null = inherit. */ + neutralize: boolean | null; neutralizeFields?: string[]; recordFilter?: Record; } @@ -73,6 +80,7 @@ interface TreeNode { path?: string; displayPath?: string; authority?: string; + knowledgeIngestionEnabled?: boolean; } interface FeatureConnectionNode { @@ -450,6 +458,16 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe const [selectedKeys, setSelectedKeys] = useState>(new Set()); const lastClickedKeyRef = useRef(null); + /* ── DataSource Settings modal ── */ + const [settingsModal, setSettingsModal] = useState<{ + dataSourceId?: string; + connectionId?: string; + title: string; + initialKnowledgeIngestionEnabled?: boolean; + initialRagLimits?: any; + showRagSection?: boolean; + } | null>(null); + const _flattenVisibleKeys = useCallback((nodes: TreeNode[]): string[] => { const result: string[] = []; for (const n of nodes) { @@ -499,8 +517,10 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe path: d.path, label: d.label, displayPath: d.displayPath, - scope: d.scope || 'personal', - neutralize: d.neutralize ?? false, + scope: d.scope ?? null, + neutralize: d.neutralize ?? null, + ragIndexEnabled: d.ragIndexEnabled ?? null, + settings: d.settings ?? null, })); list.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); setDataSources(list); @@ -521,8 +541,8 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe tableName: d.tableName, objectKey: d.objectKey, label: d.label, - scope: d.scope || 'personal', - neutralize: d.neutralize ?? false, + scope: d.scope ?? null, + neutralize: d.neutralize ?? null, neutralizeFields: d.neutralizeFields || undefined, recordFilter: d.recordFilter || undefined, })); @@ -555,6 +575,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe children: null, connectionId: c.id, authority: c.authority, + knowledgeIngestionEnabled: !!c.knowledgeIngestionEnabled, })) .sort((a: TreeNode, b: TreeNode) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })); setTree(nodes); @@ -677,60 +698,118 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe } }, [instanceId, dataSources, onAttachDataSource, _fetchDataSources, onSourcesChanged]); - /* ── Scope change (personal data source, optimistic) ── */ - const _cyclePersonalScope = useCallback(async (ds: UdbDataSource) => { - const newScope = _nextScope(ds.scope); - setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: newScope } : d)); - try { - await api.patch(`/api/datasources/${ds.id}/scope`, { scope: newScope }); - } catch { - setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: ds.scope } : d)); + /* ── Node-based toggles (auto-create DS if missing) ──────────────────── + * Logik: Klick auf jedem Knoten togglt nur diesen Knoten. Children erben + * visuell. Wenn der Knoten noch keinen DataSource-Record hat, wird einer + * angelegt UND mit dem neuen Wert befüllt — atomar im UI-State, damit + * keine Race-Condition zwischen POST/PATCH und UI-Refetch entsteht. */ + /** + * Toggle on a node: + * - Compute newValue = !currentEffective (inverse of what user sees right now). + * - PATCH the explicit value; backend cascades and resets explicit descendants + * of this node back to NULL (= inherit). + * - Local state: refetch after PATCH so cascade-reset descendants update. + */ + const _toggleNeutralizeOnNode = useCallback(async (node: TreeNode, currentEffective: boolean) => { + const newValue = !currentEffective; + let ds = _findDs(dataSources, node); + let dsId = ds?.id; + if (!dsId) { + const newId = await _addAsDataSource(node); + if (!newId) return; + dsId = newId; } - }, []); + setDataSources(prev => prev.map(d => d.id === dsId ? { ...d, neutralize: newValue } : d)); + try { + await api.patch(`/api/datasources/${dsId}/neutralize`, { neutralize: newValue }); + _fetchDataSources(); + } catch { + _fetchDataSources(); + } + }, [dataSources, _addAsDataSource, _fetchDataSources]); - /* ── Neutralize toggle (personal data source, optimistic) ── */ - const _togglePersonalNeutralize = useCallback(async (ds: UdbDataSource) => { - const newValue = !ds.neutralize; - setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: newValue } : d)); - try { - await api.patch(`/api/datasources/${ds.id}/neutralize`, { neutralize: newValue }); - } catch { - setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: ds.neutralize } : d)); + const _toggleRagIndexOnNode = useCallback(async (node: TreeNode, currentEffective: boolean) => { + const newValue = !currentEffective; + let ds = _findDs(dataSources, node); + let dsId = ds?.id; + if (!dsId) { + const newId = await _addAsDataSource(node); + if (!newId) return; + dsId = newId; } - }, []); + setDataSources(prev => prev.map(d => d.id === dsId ? { ...d, ragIndexEnabled: newValue } : d)); + try { + await api.patch(`/api/datasources/${dsId}/rag-index`, { ragIndexEnabled: newValue }); + _fetchDataSources(); + } catch { + _fetchDataSources(); + } + }, [dataSources, _addAsDataSource, _fetchDataSources]); - /* ── RAG-Index toggle (personal data source, optimistic) ── */ - const _togglePersonalRagIndex = useCallback(async (ds: UdbDataSource) => { - const newValue = !ds.ragIndexEnabled; - setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: newValue } : d)); - try { - await api.patch(`/api/datasources/${ds.id}/rag-index`, { ragIndexEnabled: newValue }); - } catch { - setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, ragIndexEnabled: ds.ragIndexEnabled } : d)); + const _cycleScopeOnNode = useCallback(async (node: TreeNode, currentEffective: string | undefined) => { + const newScope = _nextScope(currentEffective || 'personal'); + let ds = _findDs(dataSources, node); + let dsId = ds?.id; + if (!dsId) { + const newId = await _addAsDataSource(node); + if (!newId) return; + dsId = newId; } - }, []); + setDataSources(prev => prev.map(d => d.id === dsId ? { ...d, scope: newScope } : d)); + try { + await api.patch(`/api/datasources/${dsId}/scope`, { scope: newScope }); + _fetchDataSources(); + } catch { + _fetchDataSources(); + } + }, [dataSources, _addAsDataSource, _fetchDataSources]); + + const _openSettingsForNode = useCallback(async (node: TreeNode) => { + const ds = _findDs(dataSources, node); + let dataSourceId = ds?.id; + if (!dataSourceId && node.type !== 'connection') { + const ensured = await _addAsDataSource(node); + if (ensured) dataSourceId = ensured; + } + const connNode = tree.find(n => n.connectionId === node.connectionId); + // RAG-Limits only on DataSource-Root (Level 2 = service node). + // Sub-elements (folder/file) inherit their parent's walker limits. + const isDataSourceRoot = node.type === 'service'; + setSettingsModal({ + dataSourceId, + connectionId: node.connectionId, + title: node.label || node.connectionId || t('Einstellungen'), + initialKnowledgeIngestionEnabled: connNode?.knowledgeIngestionEnabled ?? false, + initialRagLimits: (ds?.settings?.ragLimits ?? null) as any, + showRagSection: isDataSourceRoot, + }); + }, [dataSources, tree, _addAsDataSource, t]); /* ── Scope change (feature data source, optimistic) ── */ - const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => { - const newScope = _nextScope(fds.scope); + const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource, currentEffective?: string) => { + const baseline = currentEffective || fds.scope || 'personal'; + const newScope = _nextScope(baseline); setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: newScope } : d)); try { await api.patch(`/api/datasources/${fds.id}/scope`, { scope: newScope }); + _fetchFeatureDataSources(); } catch { - setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: fds.scope } : d)); + _fetchFeatureDataSources(); } - }, []); + }, [_fetchFeatureDataSources]); /* ── Neutralize toggle (feature data source, optimistic) ── */ - const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource) => { - const newValue = !fds.neutralize; + const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource, currentEffective?: boolean) => { + const baseline = currentEffective !== undefined ? currentEffective : !!fds.neutralize; + const newValue = !baseline; setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: newValue } : d)); try { await api.patch(`/api/datasources/${fds.id}/neutralize`, { neutralize: newValue }); + _fetchFeatureDataSources(); } catch { - setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: fds.neutralize } : d)); + _fetchFeatureDataSources(); } - }, []); + }, [_fetchFeatureDataSources]); /* ── Neutralize fields toggle (field-level, optimistic) ── */ const _toggleNeutralizeField = useCallback(async (fds: UdbFeatureDataSource, fieldName: string) => { @@ -1033,13 +1112,13 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe node={node} depth={0} onToggle={_toggleNode} - onEnsureDs={_addAsDataSource} isAdded={_isAdded} addingPath={addingPath} dataSources={dataSources} - onCycleScope={_cyclePersonalScope} - onToggleNeutralize={_togglePersonalNeutralize} - onToggleRagIndex={_togglePersonalRagIndex} + onCycleScopeOnNode={_cycleScopeOnNode} + onToggleNeutralizeOnNode={_toggleNeutralizeOnNode} + onToggleRagIndexOnNode={_toggleRagIndexOnNode} + onOpenSettings={_openSettingsForNode} onSendToChat={_sendNodeToChat} scopeCycleTitle={_scopeCycleTitle} selectedKeys={selectedKeys} @@ -1102,6 +1181,21 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe featureTree={featureTree} /> ))} + + { + _loadConnections(); + _fetchDataSources(); + }} + onClose={() => setSettingsModal(null)} + /> ); }; @@ -1109,25 +1203,79 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe /* ─── TreeNodeView (recursive — Browse Sources side) ─────────────────── */ function _findDs(dataSources: UdbDataSource[], node: TreeNode): UdbDataSource | undefined { - const expectedSourceType = node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : undefined; + // Discriminator per node level: + // - connection (Level 1): sourceType = authority string ('msft', 'google', 'clickup', ...) + // - service / folder / file (Level 2+): sourceType from _SERVICE_TO_SOURCE_TYPE mapping + // sourceType is mandatory — otherwise a Level 1 connection DS would shadow Level 2 + // service DS sharing the same connectionId+path='/'. + let expectedSourceType: string | undefined; + if (node.type === 'connection') { + expectedSourceType = node.authority || undefined; + } else if (node.service) { + expectedSourceType = _SERVICE_TO_SOURCE_TYPE[node.service] || node.service; + } + if (!expectedSourceType) return undefined; return dataSources.find(ds => ds.connectionId === node.connectionId && ds.path === (node.path || '/') && - (!expectedSourceType || ds.sourceType === expectedSourceType), + ds.sourceType === expectedSourceType, ); } +// Connection-root DataSources carry the authority as their sourceType. +// They sit above all service DataSources of the same connection in the +// visual tree, so inheritance crosses sourceType for that specific case. +const _AUTHORITY_SOURCE_TYPES = new Set(['local', 'google', 'msft', 'clickup', 'infomaniak']); + +/** Nearest ancestor DS in the connection — same-sourceType path-prefix first, + * connection-root (sourceType = authority, path='/') as the cross-tree fallback. */ +function _findAncestorDs(dataSources: UdbDataSource[], ds: UdbDataSource): UdbDataSource | undefined { + const sameType = dataSources.filter(d => + d.id !== ds.id && + d.connectionId === ds.connectionId && + d.sourceType === ds.sourceType && + ds.path !== d.path && + (d.path === '/' ? ds.path !== '/' : ds.path.startsWith(d.path + '/')) + ); + sameType.sort((a, b) => b.path.length - a.path.length); + if (sameType[0]) return sameType[0]; + + const dsIsConnectionRoot = _AUTHORITY_SOURCE_TYPES.has(ds.sourceType) && ds.path === '/'; + if (dsIsConnectionRoot) return undefined; + return dataSources.find(d => + d.id !== ds.id && + d.connectionId === ds.connectionId && + d.path === '/' && + _AUTHORITY_SOURCE_TYPES.has(d.sourceType) + ); +} + +/** Resolve effective value of a flag: own (if explicit) → ancestor chain → static default. */ +function _effectiveFlag( + ds: UdbDataSource | undefined, + dataSources: UdbDataSource[], + flag: K, +): UdbDataSource[K] { + const fallback = (flag === 'scope' ? 'personal' : false) as UdbDataSource[K]; + if (!ds) return fallback; + const own = ds[flag]; + if (own !== null && own !== undefined && own !== '') return own; + const ancestor = _findAncestorDs(dataSources, ds); + if (ancestor) return _effectiveFlag(ancestor, dataSources, flag); + return fallback; +} + interface _TreeNodeViewProps { node: TreeNode; depth: number; onToggle: (node: TreeNode) => void; - onEnsureDs: (node: TreeNode) => Promise; isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean; addingPath: string | null; dataSources: UdbDataSource[]; - onCycleScope: (ds: UdbDataSource) => void; - onToggleNeutralize: (ds: UdbDataSource) => void; - onToggleRagIndex: (ds: UdbDataSource) => void; + onCycleScopeOnNode: (node: TreeNode, currentEffective: string | undefined) => void; + onToggleNeutralizeOnNode: (node: TreeNode, currentEffective: boolean) => void; + onToggleRagIndexOnNode: (node: TreeNode, currentEffective: boolean) => void; + onOpenSettings: (node: TreeNode) => void; onSendToChat?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void; scopeCycleTitle: (scope: string) => string; selectedKeys: Set; @@ -1138,8 +1286,8 @@ interface _TreeNodeViewProps { } const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ - node, depth, onToggle, onEnsureDs, isAdded, addingPath, - dataSources, onCycleScope, onToggleNeutralize, onToggleRagIndex, onSendToChat, scopeCycleTitle, + node, depth, onToggle, isAdded, addingPath, + dataSources, onCycleScopeOnNode, onToggleNeutralizeOnNode, onToggleRagIndexOnNode, onOpenSettings, onSendToChat, scopeCycleTitle, selectedKeys, onSelect, inheritedScope, inheritedNeutralize, inheritedRagIndex, }) => { const { t } = useLanguage(); @@ -1150,12 +1298,17 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ : '\u00A0\u00A0'; const ds = _findDs(dataSources, node); - const effectiveScope = ds?.scope ?? inheritedScope; - const effectiveNeutralize = ds?.neutralize ?? inheritedNeutralize ?? false; - const effectiveRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex ?? false; - const childInheritedScope = ds?.scope ?? inheritedScope; - const childInheritedNeutralize = ds?.neutralize ?? inheritedNeutralize; - const childInheritedRagIndex = ds?.ragIndexEnabled ?? inheritedRagIndex; + // Effective values: own (if explicit) → DS-ancestor chain → tree-inherited (UI-only) → default. + // Tree-inherited values cover the case where children don't have their own DS record yet. + const effectiveScope = + (ds && _effectiveFlag(ds, dataSources, 'scope')) ?? inheritedScope ?? 'personal'; + const effectiveNeutralize = + (ds ? (_effectiveFlag(ds, dataSources, 'neutralize') as boolean) : undefined) ?? inheritedNeutralize ?? false; + const effectiveRagIndex = + (ds ? (_effectiveFlag(ds, dataSources, 'ragIndexEnabled') as boolean) : undefined) ?? inheritedRagIndex ?? false; + const childInheritedScope = effectiveScope; + const childInheritedNeutralize = effectiveNeutralize; + const childInheritedRagIndex = effectiveRagIndex; const _dragPayload = { connectionId: node.connectionId, @@ -1239,14 +1392,7 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ + {fds ? ( ) : ( = (props) => { )} {fds ? ( @@ -2108,8 +2261,8 @@ const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => { item={sub} pathSegments={segments} depth={depth + 1} - inheritedScope={fds?.scope ?? inheritedScope} - inheritedNeutralize={fds?.neutralize ?? inheritedNeutralize} + inheritedScope={effectiveScope} + inheritedNeutralize={effectiveNeutralize} {...ctx} /> ))} @@ -2132,8 +2285,8 @@ interface _FeatureTableRowProps { ) => Promise; onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; featureDataSources: UdbFeatureDataSource[]; - onCycleScope: (fds: UdbFeatureDataSource) => void; - onToggleNeutralize: (fds: UdbFeatureDataSource) => void; + onCycleScope: (fds: UdbFeatureDataSource, currentEffective?: string) => void; + onToggleNeutralize: (fds: UdbFeatureDataSource, currentEffective?: boolean) => void; onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void; featureTree: MandateGroupNode[]; inheritedScope?: string; @@ -2225,21 +2378,22 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ diff --git a/src/hooks/useBackgroundJob.ts b/src/hooks/useBackgroundJob.ts index e21bdc2..ae7b1ce 100644 --- a/src/hooks/useBackgroundJob.ts +++ b/src/hooks/useBackgroundJob.ts @@ -11,6 +11,11 @@ export interface BackgroundJob { triggeredBy?: string | null; status: BackgroundJobStatus; progress: number; + /** + * Walker progress text, already translated by the route handler + * (`resolveJobMessage` server-side). Render 1:1 -- do NOT pass through + * `t()`; the i18n key lives in the backend, not in user code here. + */ progressMessage?: string | null; payload?: Record; result?: Record | null; diff --git a/src/pages/RagInventoryPage.tsx b/src/pages/RagInventoryPage.tsx index 7731773..fbe06d1 100644 --- a/src/pages/RagInventoryPage.tsx +++ b/src/pages/RagInventoryPage.tsx @@ -12,8 +12,9 @@ import { useLanguage } from '../providers/language/LanguageContext'; import { useApiRequest } from '../hooks/useApi'; import { useUserMandates } from '../hooks/useUserMandates'; import type { RagInventoryDto, RagConnectionDto } from '../api/connectionApi'; -import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa'; +import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH } from 'react-icons/fa'; import { mandateDisplayLabel } from '../utils/mandateDisplayUtils'; +import { DataSourceSettingsModal } from '../components/UnifiedDataBar/DataSourceSettingsModal'; import styles from './RagInventoryPage.module.css'; export const RagInventoryPage: React.FC = () => { @@ -31,6 +32,23 @@ export const RagInventoryPage: React.FC = () => { const [error, setError] = useState(null); const pollRef = useRef | null>(null); + const [settingsModal, setSettingsModal] = useState<{ + dataSourceId?: string; + connectionId?: string; + title: string; + initialKnowledgeIngestionEnabled?: boolean; + } | null>(null); + + const _openSettingsForConnection = useCallback((conn: RagConnectionDto) => { + const activeDs = (conn.dataSources || []).find(ds => ds.ragIndexEnabled) || (conn.dataSources || [])[0]; + setSettingsModal({ + dataSourceId: activeDs?.id, + connectionId: conn.id, + title: `${conn.authority} · ${conn.externalEmail || conn.id}`, + initialKnowledgeIngestionEnabled: conn.knowledgeIngestionEnabled, + }); + }, []); + useEffect(() => { let cancelled = false; (async () => { @@ -296,8 +314,11 @@ export const RagInventoryPage: React.FC = () => { {' — '} {t('Limit {l} erreicht', { l: limitText })}. {stats && <> {stats}.}{' '} - {t('Weitere Dateien wurden NICHT indexiert. Limit erhöhen oder DataSource enger eingrenzen, dann erneut starten.')} + {t('Weitere Dateien wurden NICHT indexiert.')} + @@ -358,6 +379,17 @@ export const RagInventoryPage: React.FC = () => { )} )} + + _fetchInventory()} + onClose={() => setSettingsModal(null)} + /> ); }; diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 173946c..f721b38 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -9,13 +9,14 @@ import React, { useState, useMemo, useEffect, useRef } from 'react'; import { useConnections, type Connection } from '../../hooks/useConnections'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; -import { FaSync, FaLink, FaRedo, FaPlus, FaSpinner, FaTimes, FaSyncAlt } from 'react-icons/fa'; +import { FaSync, FaLink, FaRedo, FaPlus, FaSpinner, FaTimes, FaSyncAlt, FaToggleOn, FaToggleOff } from 'react-icons/fa'; import styles from '../admin/Admin.module.css'; import bannerStyles from './ConnectionsPage.module.css'; import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard'; import type { ConnectorType } from '../../components/AddConnectionWizard/AddConnectionWizard'; -import type { KnowledgePreferences } from '../../api/connectionApi'; +import { patchKnowledgeConsent, type KnowledgePreferences } from '../../api/connectionApi'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { useApiRequest } from '../../hooks/useApi'; import { resolveColumnTypes } from '../../utils/columnTypeResolver'; import { getApiBaseUrl } from '../../../config/config'; @@ -52,7 +53,10 @@ export const ConnectionsPage: React.FC = () => { const [deletingConnections, setDeletingConnections] = useState>(new Set()); const [refreshingConnections, setRefreshingConnections] = useState>(new Set()); const [reconnectingConnections, setReconnectingConnections] = useState>(new Set()); + const [togglingConsent, setTogglingConsent] = useState>(new Set()); const [wizardOpen, setWizardOpen] = useState(false); + + const { request } = useApiRequest(); // Banner shown while knowledge bootstrap is running in the background const [syncBanner, setSyncBanner] = useState<{ connector: string; @@ -235,6 +239,28 @@ export const ConnectionsPage: React.FC = () => { window.open(url, 'msft-admin-consent', 'width=560,height=720,scrollbars=yes,resizable=yes'); }; + const handleConsentToggle = async (connection: Connection) => { + const currentEnabled = !!(connection as any).knowledgeIngestionEnabled; + const newEnabled = !currentEnabled; + if (currentEnabled) { + const ok = window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?')); + if (!ok) return; + } + setTogglingConsent(prev => new Set(prev).add(connection.id)); + try { + await patchKnowledgeConsent(request, connection.id, newEnabled); + await refetch(); + } catch (error) { + console.error('Error toggling knowledge consent:', error); + } finally { + setTogglingConsent(prev => { + const next = new Set(prev); + next.delete(connection.id); + return next; + }); + } + }; + // Form attributes for edit modal const formAttributes = useMemo(() => { const excludedFields = [ @@ -344,6 +370,22 @@ export const ConnectionsPage: React.FC = () => { }] : []), ]} customActions={[ + { + id: 'knowledge-consent-on', + icon: , + onClick: handleConsentToggle, + title: t('Wissensdatenbank aktiv — klicken zum Deaktivieren'), + visible: (row: Connection) => !!(row as any).knowledgeIngestionEnabled, + loading: (row: Connection) => togglingConsent.has(row.id), + }, + { + id: 'knowledge-consent-off', + icon: , + onClick: handleConsentToggle, + title: t('Wissensdatenbank inaktiv — klicken zum Aktivieren'), + visible: (row: Connection) => !(row as any).knowledgeIngestionEnabled, + loading: (row: Connection) => togglingConsent.has(row.id), + }, { id: 'connect', icon: ,