fixed toggle icons udb

This commit is contained in:
ValueOn AG 2026-05-18 07:57:02 +02:00
parent f37774ff36
commit 65170d9e4c
8 changed files with 752 additions and 135 deletions

View file

@ -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<string, number>;
assumptions: Record<string, any>;
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<CostEstimate> {
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;
}

View file

@ -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`,

View file

@ -9,6 +9,7 @@ interface _RagJob {
connectionLabel?: string;
jobType: string;
progress: number | null;
/** Already translated server-side (route handler runs `resolveJobMessage`). */
progressMessage: string;
}

View file

@ -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<Props> = ({
open, title, dataSourceId, connectionId,
initialKnowledgeIngestionEnabled, initialRagLimits, showRagSection = true,
onSaved, onClose,
}) => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [knowledgeOn, setKnowledgeOn] = useState<boolean>(!!initialKnowledgeIngestionEnabled);
const [ragLimits, setRagLimits] = useState<RagLimits>(initialRagLimits || {});
const [cost, setCost] = useState<CostEstimate | null>(null);
const [costLoading, setCostLoading] = useState<boolean>(false);
const [saving, setSaving] = useState<boolean>(false);
const [errorMsg, setErrorMsg] = useState<string | null>(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 (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0,0,0,0.45)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div
onClick={e => 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)',
}}
>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 18px', borderBottom: '1px solid var(--border-color, #e0e0e0)',
}}>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>
{'\u2699\uFE0F '}{t('Einstellungen')} {title}
</h3>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 16, color: '#666' }}
title={t('Schliessen')}
>
<FaTimes />
</button>
</div>
<div style={{ padding: 18, overflowY: 'auto' }}>
{errorMsg && (
<div style={{
background: '#fef2f2', color: '#991b1b', padding: '8px 12px',
borderRadius: 4, marginBottom: 14, fontSize: 13,
}}>{errorMsg}</div>
)}
{/* --- Section: Connection --- */}
{connectionId && (
<section style={{ marginBottom: 22 }}>
<h4 style={{ margin: '0 0 8px', fontSize: 12, fontWeight: 700, color: '#666', textTransform: 'uppercase' }}>
{t('Verbindung')}
</h4>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 0' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('Wissensdatenbank aktiv')}</div>
<div style={{ fontSize: 11, color: '#777' }}>
{t('Master-Schalter — wirkt auf ALLE Datenquellen dieser Verbindung.')}
</div>
</div>
<button
onClick={_handleConsentToggle}
disabled={saving}
style={{
background: 'none', border: 'none', cursor: saving ? 'wait' : 'pointer',
fontSize: 22, color: knowledgeOn ? 'var(--primary-color, #F25843)' : '#999',
}}
title={knowledgeOn ? t('Wissensdatenbank deaktivieren') : t('Wissensdatenbank aktivieren')}
>
{knowledgeOn ? <FaToggleOn /> : <FaToggleOff />}
</button>
</div>
</section>
)}
{/* --- Section: RAG Limits (only on DataSource-Root, not sub-elements) --- */}
{dataSourceId && showRagSection && (
<section style={{ marginBottom: 22 }}>
<h4 style={{ margin: '0 0 8px', fontSize: 12, fontWeight: 700, color: '#666', textTransform: 'uppercase' }}>
{t('RAG-Indexierungs-Limits')}
</h4>
<div style={{ fontSize: 11, color: '#777', marginBottom: 10 }}>
{t('Walker stoppt bei den ersten erreichten Limit. Defaults greifen, wenn ein Feld leer ist.')}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 120px', gap: 8, alignItems: 'center' }}>
{limitKeys.map(key => (
<React.Fragment key={key}>
<label style={{ fontSize: 13 }}>{_labelFor(key, t)}</label>
<input
type="number"
min={1}
value={_displayValue(key, ragLimits[key])}
onChange={e => _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',
}}
/>
</React.Fragment>
))}
</div>
<div style={{ marginTop: 10, textAlign: 'right' }}>
<button
onClick={_handleSaveLimits}
disabled={saving}
style={{
background: 'var(--primary-color, #F25843)', color: '#fff', border: 'none',
padding: '7px 14px', borderRadius: 4, cursor: saving ? 'wait' : 'pointer', fontSize: 13,
}}
>
{saving ? t('Speichern…') : t('Limits speichern')}
</button>
</div>
</section>
)}
{/* --- Section: Cost estimate (only on DataSource-Root) --- */}
{dataSourceId && showRagSection && (
<section>
<h4 style={{ margin: '0 0 8px', fontSize: 12, fontWeight: 700, color: '#666', textTransform: 'uppercase' }}>
{t('Kostenschätzung (indikativ)')}
</h4>
{costLoading && <div style={{ fontSize: 12, color: '#999' }}>{t('Wird berechnet…')}</div>}
{!costLoading && cost && (
<div style={{
background: '#f9fafb', border: '1px solid #e5e7eb', borderRadius: 4, padding: '10px 12px',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontSize: 13 }}>{t('Voll-Sync (geschätzt)')}</span>
<span style={{ fontSize: 16, fontWeight: 600 }}>~ {cost.estimatedUsd.toFixed(4)} USD</span>
</div>
<div style={{ fontSize: 11, color: '#777', marginTop: 4 }}>
~ {cost.estimatedTokens.toLocaleString()} {t('Tokens')} · {cost.basis.notes}
</div>
</div>
)}
</section>
)}
</div>
</div>
</div>
);
};
export default DataSourceSettingsModal;

View file

@ -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<string, any> | 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<string, string>;
}
@ -73,6 +80,7 @@ interface TreeNode {
path?: string;
displayPath?: string;
authority?: string;
knowledgeIngestionEnabled?: boolean;
}
interface FeatureConnectionNode {
@ -450,6 +458,16 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ context, onSourcesChanged, onSe
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const lastClickedKeyRef = useRef<string | null>(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<SourcesTabProps> = ({ 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<SourcesTabProps> = ({ 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<SourcesTabProps> = ({ 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<SourcesTabProps> = ({ 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<SourcesTabProps> = ({ 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<SourcesTabProps> = ({ context, onSourcesChanged, onSe
featureTree={featureTree}
/>
))}
<DataSourceSettingsModal
open={!!settingsModal}
title={settingsModal?.title || ''}
dataSourceId={settingsModal?.dataSourceId}
connectionId={settingsModal?.connectionId}
initialKnowledgeIngestionEnabled={settingsModal?.initialKnowledgeIngestionEnabled}
initialRagLimits={settingsModal?.initialRagLimits}
showRagSection={settingsModal?.showRagSection ?? false}
onSaved={() => {
_loadConnections();
_fetchDataSources();
}}
onClose={() => setSettingsModal(null)}
/>
</div>
);
};
@ -1109,25 +1203,79 @@ const SourcesTab: React.FC<SourcesTabProps> = ({ 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<K extends 'neutralize' | 'ragIndexEnabled' | 'scope'>(
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<string | null>;
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<string>;
@ -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> = ({
</span>
<button
onClick={async (e) => {
e.stopPropagation();
if (ds) { onToggleRagIndex(ds); return; }
const newId = await onEnsureDs(node);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/rag-index`, { ragIndexEnabled: !effectiveRagIndex }); } catch {}
}
}}
onClick={(e) => { e.stopPropagation(); onToggleRagIndexOnNode(node, effectiveRagIndex); }}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
@ -1256,6 +1402,17 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
>
{'\uD83E\uDDE0'}
</button>
<button
onClick={(e) => { e.stopPropagation(); onOpenSettings(node); }}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
opacity: (ds?.settings && Object.keys(ds.settings).length > 0) ? 1 : (hovered ? 0.6 : 0.35),
}}
title={t('Einstellungen (RAG-Limits, Wissensdatenbank-Master)')}
>
{'\u2699\uFE0F'}
</button>
<button
onClick={e => { e.stopPropagation(); onSendToChat?.(_chatPayload); }}
style={{
@ -1269,28 +1426,14 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
{'\u{1F4AC}'}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (ds) { onCycleScope(ds); return; }
const newId = await onEnsureDs(node);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effectiveScope || 'personal') }); } catch {}
}
}}
onClick={(e) => { e.stopPropagation(); onCycleScopeOnNode(node, effectiveScope); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: ds ? 1 : 0.35 }}
title={ds ? scopeCycleTitle(ds.scope) : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
title={ds && ds.scope ? scopeCycleTitle(ds.scope) : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
>
{_SCOPE_ICONS[ds?.scope || effectiveScope || 'personal']}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (ds) { onToggleNeutralize(ds); return; }
const newId = await onEnsureDs(node);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effectiveNeutralize }); } catch {}
}
}}
onClick={(e) => { e.stopPropagation(); onToggleNeutralizeOnNode(node, effectiveNeutralize); }}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
@ -1311,13 +1454,13 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
node={child}
depth={depth + 1}
onToggle={onToggle}
onEnsureDs={onEnsureDs}
isAdded={isAdded}
addingPath={addingPath}
dataSources={dataSources}
onCycleScope={onCycleScope}
onToggleNeutralize={onToggleNeutralize}
onToggleRagIndex={onToggleRagIndex}
onCycleScopeOnNode={onCycleScopeOnNode}
onToggleNeutralizeOnNode={onToggleNeutralizeOnNode}
onToggleRagIndexOnNode={onToggleRagIndexOnNode}
onOpenSettings={onOpenSettings}
onSendToChat={onSendToChat}
scopeCycleTitle={scopeCycleTitle}
selectedKeys={selectedKeys}
@ -1368,8 +1511,8 @@ interface _FeatureActionContext {
addingKey: string | null;
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[];
}
@ -1515,21 +1658,22 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => {
<button
onClick={async (e) => {
e.stopPropagation();
if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
const effective = wildcardFds?.scope || 'personal';
if (wildcardFds) { ctx.onCycleScope(wildcardFds, effective); return; }
const newId = await ctx.onAddFeatureTable(node, { objectKey: `data.feature.${node.featureCode}.*`, tableName: '*', label: node.label, fields: [] } as FeatureTableNode);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope('personal') }); } catch {}
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effective) }); } catch {}
}
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : t('Scope setzen')}
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope ?? 'personal'}` : t('Scope setzen')}
>
{_SCOPE_ICONS[wildcardFds?.scope || 'personal']}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds, !!wildcardFds.neutralize); return; }
const newId = await ctx.onAddFeatureTable(node, { objectKey: `data.feature.${node.featureCode}.*`, tableName: '*', label: node.label, fields: [] } as FeatureTableNode);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
@ -1552,8 +1696,8 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => {
item={item}
pathSegments={[]}
depth={1}
inheritedScope={wildcardFds?.scope}
inheritedNeutralize={wildcardFds?.neutralize}
inheritedScope={wildcardFds?.scope ?? undefined}
inheritedNeutralize={wildcardFds?.neutralize ?? undefined}
{...ctx}
/>
))}
@ -1726,30 +1870,32 @@ const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => {
<button
onClick={async (e) => {
e.stopPropagation();
if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
const effective = (wildcardFds?.scope || inheritedScope || 'personal') as string;
if (wildcardFds) { ctx.onCycleScope(wildcardFds, effective); return; }
const newId = await ctx.onAddFeatureTable(
featureNode,
{ objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(inheritedScope || 'personal') }); } catch {}
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effective) }); } catch {}
}
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
title={wildcardFds?.scope ? `${t('Bereich')}: ${wildcardFds.scope}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
>
{_SCOPE_ICONS[wildcardFds?.scope || inheritedScope || 'personal']}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
const effective = !!(wildcardFds?.neutralize ?? inheritedNeutralize);
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds, effective); return; }
const newId = await ctx.onAddFeatureTable(
featureNode,
{ objectKey: containerObjectKey, tableName: '*', label, fields: [] } as FeatureTableNode,
);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effective }); } catch {}
}
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (wildcardFds?.neutralize ?? inheritedNeutralize) ? 1 : 0.35 }}
@ -1884,32 +2030,34 @@ const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => {
<button
onClick={async (e) => {
e.stopPropagation();
if (wildcardFds) { ctx.onCycleScope(wildcardFds); return; }
const effective = (wildcardFds?.scope || inheritedScope || 'personal') as string;
if (wildcardFds) { ctx.onCycleScope(wildcardFds, effective); return; }
const newId = await ctx.onAddFeatureTable(
featureNode,
{ ...table, objectKey: containerObjectKey } as FeatureTableNode,
{ labelOverride: _chatPayload.label },
);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(inheritedScope || 'personal') }); } catch {}
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effective) }); } catch {}
}
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: wildcardFds ? 1 : 0.35 }}
title={wildcardFds ? `${t('Bereich')}: ${wildcardFds.scope}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
title={wildcardFds?.scope ? `${t('Bereich')}: ${wildcardFds.scope}` : (inheritedScope ? `${t('Geerbt')}: ${inheritedScope}` : t('Scope setzen'))}
>
{_SCOPE_ICONS[wildcardFds?.scope || inheritedScope || 'personal']}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds); return; }
const effective = !!(wildcardFds?.neutralize ?? inheritedNeutralize);
if (wildcardFds) { ctx.onToggleNeutralize(wildcardFds, effective); return; }
const newId = await ctx.onAddFeatureTable(
featureNode,
{ ...table, objectKey: containerObjectKey } as FeatureTableNode,
{ labelOverride: _chatPayload.label },
);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: true }); } catch {}
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effective }); } catch {}
}
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: (wildcardFds?.neutralize ?? inheritedNeutralize) ? 1 : 0.35 }}
@ -1977,6 +2125,11 @@ const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => {
&& f.recordFilter?.id === record.id,
);
// Effective values: own explicit > inherited from parent FDS in tree.
// null on own fds means "inherit" (cascade-reset by backend).
const effectiveScope: string = (fds?.scope ?? inheritedScope ?? 'personal') as string;
const effectiveNeutralize: boolean = (fds?.neutralize ?? inheritedNeutralize ?? false) as boolean;
const childItems = useMemo(
() => _childrenForRecord(featureNode.tables || [], parentTable.tableName),
[featureNode.tables, parentTable.tableName],
@ -2067,11 +2220,11 @@ const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => {
</button>
{fds ? (
<button
onClick={(e) => { e.stopPropagation(); ctx.onCycleScope(fds); }}
onClick={(e) => { e.stopPropagation(); ctx.onCycleScope(fds, effectiveScope); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center' }}
title={`${t('Bereich')}: ${fds.scope}`}
title={fds.scope ? `${t('Bereich')}: ${fds.scope}` : `${t('Geerbt')}: ${effectiveScope}`}
>
{_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
{_SCOPE_ICONS[effectiveScope]}
</button>
) : (
<span
@ -2083,9 +2236,9 @@ const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => {
)}
{fds ? (
<button
onClick={(e) => { e.stopPropagation(); ctx.onToggleNeutralize(fds); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds.neutralize ? 1 : 0.35 }}
title={fds.neutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
onClick={(e) => { e.stopPropagation(); ctx.onToggleNeutralize(fds, effectiveNeutralize); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: effectiveNeutralize ? 1 : 0.35 }}
title={effectiveNeutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</button>
@ -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<string | null>;
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> = ({
<button
onClick={async (e) => {
e.stopPropagation();
if (fds) { onCycleScope(fds); return; }
const effective = (effectiveScope || 'personal') as string;
if (fds) { onCycleScope(fds, effective); return; }
const newId = await onAddFeatureTable(featureNode, table);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effectiveScope || 'personal') }); } catch {}
try { await api.patch(`/api/datasources/${newId}/scope`, { scope: _nextScope(effective) }); } catch {}
}
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center', opacity: fds ? 1 : 0.35 }}
title={fds ? `${t('Bereich')}: ${fds.scope}` : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
title={fds?.scope ? `${t('Bereich')}: ${fds.scope}` : (effectiveScope ? `${t('Geerbt')}: ${effectiveScope}` : t('Scope setzen'))}
>
{_SCOPE_ICONS[fds?.scope || effectiveScope || 'personal']}
</button>
<button
onClick={async (e) => {
e.stopPropagation();
if (fds) { onToggleNeutralize(fds); return; }
if (fds) { onToggleNeutralize(fds, effectiveNeutralize); return; }
const newId = await onAddFeatureTable(featureNode, table);
if (newId) {
try { await api.patch(`/api/datasources/${newId}/neutralize`, { neutralize: !effectiveNeutralize }); } catch {}
@ -2248,9 +2402,9 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12, padding: '0 2px', lineHeight: 1, flexShrink: 0, width: 22, textAlign: 'center',
opacity: (fds?.neutralize ?? effectiveNeutralize) ? 1 : 0.35,
opacity: effectiveNeutralize ? 1 : 0.35,
}}
title={(fds?.neutralize ?? effectiveNeutralize) ? t('Neutralisierung an') : t('Neutralisierung aus')}
title={effectiveNeutralize ? t('Neutralisierung an') : t('Neutralisierung aus')}
>
{'\uD83D\uDD12'}
</button>

View file

@ -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<string, any>;
result?: Record<string, any> | null;

View file

@ -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<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | 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.')}
</span>
<button className={styles.reindexBtn} onClick={() => _openSettingsForConnection(conn)} title={t('Limit für diese Datenquelle anpassen')}>
<FaSlidersH size={12} /> {t('Limit anpassen')}
</button>
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Erneut indexieren')}>
<FaRedo size={12} /> {t('Erneut indexieren')}
</button>
@ -358,6 +379,17 @@ export const RagInventoryPage: React.FC = () => {
)}
</div>
)}
<DataSourceSettingsModal
open={!!settingsModal}
title={settingsModal?.title || ''}
dataSourceId={settingsModal?.dataSourceId}
connectionId={settingsModal?.connectionId}
initialKnowledgeIngestionEnabled={settingsModal?.initialKnowledgeIngestionEnabled}
showRagSection
onSaved={() => _fetchInventory()}
onClose={() => setSettingsModal(null)}
/>
</div>
);
};

View file

@ -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<Set<string>>(new Set());
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set());
const [togglingConsent, setTogglingConsent] = useState<Set<string>>(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: <FaToggleOn />,
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: <FaToggleOff />,
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: <FaLink />,