ui-nyla/src/components/UnifiedDataBar/DataSourceSettingsModal.tsx
ValueOn AG 4475a45a26
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m22s
security and mfa
2026-06-03 23:21:33 +02:00

325 lines
13 KiB
TypeScript

/**
* 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 CHF 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 { useConfirm } from '../../hooks/useConfirm';
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 { confirm, ConfirmDialog } = useConfirm();
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 = await confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'), { variant: 'danger', confirmLabel: t('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 (
<>
<ConfirmDialog />
<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.estimatedChf.toFixed(4)} CHF</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;