All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m22s
523 lines
24 KiB
TypeScript
523 lines
24 KiB
TypeScript
/**
|
|
* RagInventoryPage — Global RAG knowledge store management.
|
|
*
|
|
* Accessible via Start > Nutzung > RAG-Inventar.
|
|
* Context selector top-right (same pattern as BillingDataView / Statistiken):
|
|
* Dropdown: "Meine Verbindungen" | "Mandant: XY" | "Plattform (alle)"
|
|
* Checkbox: "nur meine Daten"
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import { useLanguage } from '../providers/language/LanguageContext';
|
|
import { useApiRequest } from '../hooks/useApi';
|
|
import { useConfirm } from '../hooks/useConfirm';
|
|
import type { RagInventoryDto, RagConnectionDto, RagFeatureInstanceDto } from '../api/connectionApi';
|
|
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH, FaCubes } 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 = () => {
|
|
const { t } = useLanguage();
|
|
const { request } = useApiRequest();
|
|
const { confirm, ConfirmDialog } = useConfirm();
|
|
|
|
const [mandates, setMandates] = useState<any[]>([]);
|
|
const [mandatesLoading, setMandatesLoading] = useState(true);
|
|
const [selectedScope, setSelectedScope] = useState<string>('personal');
|
|
const [onlyMyData, setOnlyMyData] = useState(false);
|
|
|
|
const [inventory, setInventory] = useState<RagInventoryDto | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
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 () => {
|
|
setMandatesLoading(true);
|
|
try {
|
|
const data = await request({ url: '/api/rag/inventory/my-mandates', method: 'get' });
|
|
if (!cancelled) {
|
|
const list = Array.isArray(data) ? data : [];
|
|
setMandates(list);
|
|
if (list.length === 1) setSelectedScope(list[0].id);
|
|
}
|
|
} catch {}
|
|
finally { if (!cancelled) setMandatesLoading(false); }
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [request]);
|
|
|
|
const _apiEndpoint = useMemo(() => {
|
|
if (selectedScope === 'personal') return '/api/rag/inventory/me';
|
|
if (selectedScope === 'platform') return '/api/rag/inventory/platform';
|
|
return '/api/rag/inventory/mandate';
|
|
}, [selectedScope]);
|
|
|
|
const _fetchInventory = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const params: Record<string, string> = {};
|
|
if (onlyMyData) params.onlyMine = 'true';
|
|
const isMandateScope = selectedScope !== 'personal' && selectedScope !== 'platform';
|
|
const headers: Record<string, string> = {};
|
|
if (isMandateScope) {
|
|
headers['X-Mandate-Id'] = selectedScope;
|
|
}
|
|
const data = await request({ url: _apiEndpoint, method: 'get', params, additionalConfig: { headers } });
|
|
setInventory(data);
|
|
} catch (err: any) {
|
|
if (err?.message?.includes('403')) {
|
|
setError(t('Keine Berechtigung für diese Sicht.'));
|
|
} else {
|
|
setError(err?.message || t('Fehler beim Laden'));
|
|
}
|
|
setInventory(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [request, _apiEndpoint, selectedScope, onlyMyData, t]);
|
|
|
|
useEffect(() => {
|
|
_fetchInventory();
|
|
}, [_fetchInventory]);
|
|
|
|
const _hasActiveJobs = !!(
|
|
inventory?.connections?.some(c => (c.runningJobs?.length || 0) > 0) ||
|
|
inventory?.featureInstances?.some(fi => (fi.runningJobs?.length || 0) > 0)
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (pollRef.current) clearInterval(pollRef.current);
|
|
// Fast poll (5s) while a sync is in flight so the user gets a snappy
|
|
// success/error confirmation; slow poll (60s) at rest to keep the DB
|
|
// load low. Visibility check skips polling for backgrounded tabs.
|
|
const intervalMs = _hasActiveJobs ? 5000 : 60000;
|
|
pollRef.current = setInterval(() => {
|
|
if (document.visibilityState === 'visible') _fetchInventory();
|
|
}, intervalMs);
|
|
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
|
}, [_fetchInventory, _hasActiveJobs]);
|
|
|
|
const _handleStop = async (connectionId: string) => {
|
|
try {
|
|
await request({ url: `/api/connections/${connectionId}/knowledge-stop`, method: 'post' });
|
|
_fetchInventory();
|
|
} catch {}
|
|
};
|
|
|
|
const _handleReindex = async (connectionId: string) => {
|
|
try {
|
|
await request({ url: `/api/rag/inventory/reindex/${connectionId}`, method: 'post' });
|
|
_fetchInventory();
|
|
} catch {}
|
|
};
|
|
|
|
const _handleReindexFeature = async (workspaceInstanceId: string) => {
|
|
try {
|
|
await request({ url: `/api/rag/inventory/reindex-feature/${workspaceInstanceId}`, method: 'post' });
|
|
_fetchInventory();
|
|
} catch {}
|
|
};
|
|
|
|
const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => {
|
|
if (currentEnabled) {
|
|
const ok = await confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'), { variant: 'danger', confirmLabel: t('Fortfahren') });
|
|
if (!ok) return;
|
|
}
|
|
try {
|
|
await request({
|
|
url: `/api/connections/${connectionId}/knowledge-consent`,
|
|
method: 'patch',
|
|
data: { enabled: !currentEnabled },
|
|
});
|
|
_fetchInventory();
|
|
} catch {}
|
|
};
|
|
|
|
const _formatRelative = useCallback((finishedAt: number | null | undefined): string => {
|
|
if (!finishedAt) return '';
|
|
const nowSec = Date.now() / 1000;
|
|
const diff = Math.max(0, nowSec - finishedAt);
|
|
if (diff < 45) return t('gerade eben');
|
|
if (diff < 3600) return t('vor {n} Min', { n: Math.floor(diff / 60) });
|
|
if (diff < 86400) return t('vor {n} Std', { n: Math.floor(diff / 3600) });
|
|
return t('vor {n} Tag(en)', { n: Math.floor(diff / 86400) });
|
|
}, [t]);
|
|
|
|
const _formatDuration = useCallback((ms: number | undefined): string => {
|
|
if (!ms || ms <= 0) return '';
|
|
if (ms < 1000) return `${ms}ms`;
|
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
|
}, []);
|
|
|
|
/** Render the budget value next to its name. Bytes get MB units so the user
|
|
* immediately recognises the 200 MB default; everything else stays raw. */
|
|
const _formatLimit = useCallback((name: string, budget: number | undefined, bytesProcessed: number | undefined): string => {
|
|
if (budget == null) return name;
|
|
if (name === 'maxBytes') {
|
|
const mb = Math.round(budget / 1024 / 1024);
|
|
const procMb = bytesProcessed != null ? ` (${(bytesProcessed / 1024 / 1024).toFixed(0)} MB ${t('verarbeitet')})` : '';
|
|
return `${name}=${mb} MB${procMb}`;
|
|
}
|
|
if (name === 'maxFileSize') {
|
|
return `${name}=${Math.round(budget / 1024 / 1024)} MB`;
|
|
}
|
|
return `${name}=${budget}`;
|
|
}, [t]);
|
|
|
|
const scopeOptions = useMemo(() => {
|
|
const opts: { value: string; label: string }[] = [
|
|
{ value: 'personal', label: t('Meine Verbindungen') },
|
|
];
|
|
for (const m of mandates) {
|
|
opts.push({ value: m.id, label: t('Mandant: {name}', { name: mandateDisplayLabel(m) }) });
|
|
}
|
|
opts.push({ value: 'platform', label: t('Plattform (alle)') });
|
|
return opts;
|
|
}, [mandates, t]);
|
|
|
|
return (
|
|
<div className={styles.page}>
|
|
<ConfirmDialog />
|
|
<header className={styles.pageHeader}>
|
|
<div className={styles.headerLeft}>
|
|
<FaDatabase className={styles.headerIcon} />
|
|
<div>
|
|
<h1 className={styles.pageTitle}>{t('RAG-Inventar')}</h1>
|
|
<p className={styles.pageDesc}>
|
|
{t('Übersicht und Steuerung der indexierten Wissensdaten.')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className={styles.headerRight}>
|
|
<div className={styles.filterGroup}>
|
|
<label className={styles.filterLabel}>{t('Kontext:')}</label>
|
|
<select
|
|
className={styles.scopeSelect}
|
|
value={selectedScope}
|
|
onChange={e => setSelectedScope(e.target.value)}
|
|
disabled={mandatesLoading}
|
|
>
|
|
{scopeOptions.map(opt => (
|
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<label className={styles.checkboxLabel}>
|
|
<input
|
|
type="checkbox"
|
|
checked={onlyMyData}
|
|
onChange={e => setOnlyMyData(e.target.checked)}
|
|
/>
|
|
{t('nur meine Daten')}
|
|
</label>
|
|
</div>
|
|
</header>
|
|
|
|
{loading && !inventory && <div className={styles.loading}>{t('Laden...')}</div>}
|
|
{error && <div className={styles.error}>{error}</div>}
|
|
|
|
{inventory && (
|
|
<div className={styles.content}>
|
|
<div className={styles.totals}>
|
|
<span className={styles.totalLabel}>{t('Total Dateien')}:</span>
|
|
<strong className={styles.totalValue}>{inventory.totals?.files ?? 0}</strong>
|
|
<span className={styles.totalLabel} title={t('Embedding-Fragmente (~400 Tokens), die der RAG-Retrieval trifft')}>{t('Total Chunks')}:</span>
|
|
<strong className={styles.totalValue}>{inventory.totals?.chunks ?? 0}</strong>
|
|
{inventory.totals?.bytes != null && inventory.totals.bytes > 0 && (
|
|
<span className={styles.totalBytes}>{(inventory.totals.bytes / 1024 / 1024).toFixed(1)} MB</span>
|
|
)}
|
|
</div>
|
|
|
|
{(inventory.connections || []).map((conn: RagConnectionDto) => (
|
|
<div key={conn.id} className={styles.connectionCard}>
|
|
<div className={styles.connectionHeader}>
|
|
<span className={styles.authority}>{conn.authority}</span>
|
|
<span className={styles.email}>{conn.externalEmail}</span>
|
|
{(conn.totalFiles > 0 || conn.totalChunks > 0) && (
|
|
<span
|
|
className={styles.connChunks}
|
|
title={t('Embedding-Fragmente (~400 Tokens), die der RAG-Retrieval trifft')}
|
|
>
|
|
{t('{f} Dateien · {c} Chunks', { f: conn.totalFiles, c: conn.totalChunks })}
|
|
</span>
|
|
)}
|
|
<button
|
|
className={styles.consentToggle}
|
|
onClick={() => _handleConsentToggle(conn.id, conn.knowledgeIngestionEnabled)}
|
|
title={conn.knowledgeIngestionEnabled ? t('Wissensdatenbank deaktivieren') : t('Wissensdatenbank aktivieren')}
|
|
>
|
|
{conn.knowledgeIngestionEnabled ? <FaToggleOn size={20} /> : <FaToggleOff size={20} />}
|
|
</button>
|
|
</div>
|
|
|
|
{!conn.knowledgeIngestionEnabled && conn.dataSources.length > 0 && (
|
|
<div className={styles.consentWarning}>
|
|
{t('Wissensdatenbank deaktiviert — Indexierung pausiert. Toggle aktivieren um Daten zu indexieren.')}
|
|
</div>
|
|
)}
|
|
|
|
{/* Status banner: priority is Running > Error-newer-than-Success > Success > Reindex-Hint.
|
|
This way a stale error doesn't override a fresh successful resync, and the
|
|
spinner is never shown without a real job behind it. */}
|
|
{conn.runningJobs.length > 0 ? (
|
|
<div className={styles.jobBanner}>
|
|
<FaSync className={styles.spinIcon} />
|
|
<span>{conn.runningJobs[0].progressMessage || t('Synchronisierung läuft...')}</span>
|
|
<button className={styles.stopBtn} onClick={() => _handleStop(conn.id)} title={t('Indexierung stoppen')}>
|
|
<FaStop size={12} /> {t('Stop')}
|
|
</button>
|
|
</div>
|
|
) : (() => {
|
|
const errAt = conn.lastError?.finishedAt ?? 0;
|
|
const okAt = conn.lastSuccess?.finishedAt ?? 0;
|
|
const errorIsNewer = !!conn.lastError && errAt > okAt;
|
|
|
|
if (errorIsNewer) {
|
|
return (
|
|
<div className={styles.errorBanner}>
|
|
<FaExclamationTriangle />
|
|
<span>
|
|
{t('Letzter Sync fehlgeschlagen')} ({_formatRelative(errAt)}): {conn.lastError?.errorMessage || t('unbekannter Fehler')}
|
|
</span>
|
|
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Neu indexieren')}>
|
|
<FaRedo size={12} /> {t('Neu indexieren')}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (conn.lastSuccess) {
|
|
const s = conn.lastSuccess;
|
|
const stats = [
|
|
s.indexed > 0 ? t('{n} neu indexiert', { n: s.indexed }) : null,
|
|
s.skippedDuplicate > 0 ? t('{n} unverändert', { n: s.skippedDuplicate }) : null,
|
|
s.skippedPolicy > 0 ? t('{n} übersprungen', { n: s.skippedPolicy }) : null,
|
|
s.failed > 0 ? t('{n} fehler', { n: s.failed }) : null,
|
|
].filter(Boolean).join(' · ');
|
|
|
|
const stop = s.stoppedAtLimit;
|
|
if (stop) {
|
|
const budget = s.limits?.[stop];
|
|
const limitText = _formatLimit(stop, budget, s.bytesProcessed);
|
|
return (
|
|
<div className={styles.partialBanner}>
|
|
<FaExclamationTriangle />
|
|
<span>
|
|
<strong>{t('Sync abgeschlossen, Korpus aber unvollständig')}</strong> ({_formatRelative(okAt)})
|
|
{' — '}
|
|
{t('Limit {l} erreicht', { l: limitText })}.
|
|
{stats && <> {stats}.</>}{' '}
|
|
{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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.successBanner}>
|
|
<FaCheckCircle />
|
|
<span>
|
|
{t('Sync erfolgreich')} {_formatRelative(okAt)}
|
|
{stats && <> — {stats}</>}
|
|
{s.durationMs > 0 && <span className={styles.duration}> ({_formatDuration(s.durationMs)})</span>}
|
|
</span>
|
|
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Erneut indexieren')}>
|
|
<FaRedo size={12} /> {t('Erneut indexieren')}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (conn.dataSources.some(ds => ds.ragIndexEnabled) && conn.knowledgeIngestionEnabled) {
|
|
return (
|
|
<div className={styles.reindexHint}>
|
|
<button className={styles.reindexBtn} onClick={() => _handleReindex(conn.id)} title={t('Indexierung starten')}>
|
|
<FaRedo size={12} /> {t('Indexierung starten')}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
|
|
<div className={styles.dsList}>
|
|
{conn.dataSources.map(ds => (
|
|
<div key={ds.id} className={`${styles.dsRow} ${ds.ragIndexEnabled ? styles.dsActive : ''}`}>
|
|
<span className={styles.dsLabel}>{ds.label || ds.path}</span>
|
|
<span className={styles.dsType}>{ds.sourceType}</span>
|
|
<span
|
|
className={styles.dsChunks}
|
|
title={t('{f} indizierte Dateien · {c} Embedding-Chunks (~400 Tokens)', { f: ds.fileCount, c: ds.chunkCount })}
|
|
>
|
|
{ds.fileCount} {t('Dateien')} · {ds.chunkCount} {t('Chunks')}
|
|
</span>
|
|
<span className={styles.dsIndex}>{ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}</span>
|
|
</div>
|
|
))}
|
|
{conn.dataSources.length === 0 && (
|
|
<div className={styles.dsEmpty}>{t('Keine Datenquellen konfiguriert')}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{(inventory.featureInstances || []).length > 0 && (
|
|
<>
|
|
<h2 className={styles.sectionTitle}>
|
|
<FaCubes style={{ marginRight: 8 }} />
|
|
{t('Feature-Daten')}
|
|
</h2>
|
|
{(inventory.featureInstances || []).map((fi: RagFeatureInstanceDto) => {
|
|
const runningJobs = fi.runningJobs || [];
|
|
const lastSuccess = fi.lastSuccess;
|
|
const lastError = fi.lastError;
|
|
|
|
return (
|
|
<div key={fi.featureInstanceId} className={styles.connectionCard}>
|
|
<div className={styles.connectionHeader}>
|
|
<span className={styles.authority}>{fi.featureCode}</span>
|
|
<span className={styles.email}>{fi.label}</span>
|
|
{(fi.fileCount > 0 || fi.chunkCount > 0) && (
|
|
<span
|
|
className={styles.connChunks}
|
|
title={t('Embedding-Fragmente (~400 Tokens), die der RAG-Retrieval trifft')}
|
|
>
|
|
{t('{f} Dateien · {c} Chunks', { f: fi.fileCount, c: fi.chunkCount })}
|
|
</span>
|
|
)}
|
|
<span className={styles.dsIndex} title={fi.ragEnabled ? t('RAG aktiv') : t('RAG inaktiv')}>
|
|
{fi.ragEnabled ? '\uD83E\uDDE0' : '\u2014'}
|
|
</span>
|
|
</div>
|
|
|
|
{!fi.ragEnabled && (fi.dataSources || []).length > 0 && (
|
|
<div className={styles.consentWarning}>
|
|
{t('RAG-Indexierung ist für keine Datenquelle dieser Feature-Instanz aktiviert. Aktivierung erfolgt in der UDB (Unified Data Bar) der jeweiligen Workspace-Sitzung.')}
|
|
</div>
|
|
)}
|
|
|
|
{runningJobs.length > 0 ? (
|
|
<div className={styles.jobBanner}>
|
|
<FaSync className={styles.spinIcon} />
|
|
<span>{runningJobs[0].progressMessage || t('Feature-Daten werden synchronisiert...')}</span>
|
|
</div>
|
|
) : (() => {
|
|
const errAt = lastError?.finishedAt ?? 0;
|
|
const okAt = lastSuccess?.finishedAt ?? 0;
|
|
const errorIsNewer = !!lastError && errAt > okAt;
|
|
|
|
if (errorIsNewer) {
|
|
return (
|
|
<div className={styles.errorBanner}>
|
|
<FaExclamationTriangle />
|
|
<span>
|
|
{t('Letzter Sync fehlgeschlagen')} ({_formatRelative(errAt)}): {lastError?.errorMessage || t('unbekannter Fehler')}
|
|
</span>
|
|
<button className={styles.reindexBtn} onClick={() => _handleReindexFeature(fi.featureInstanceId)} title={t('Neu indexieren')}>
|
|
<FaRedo size={12} /> {t('Neu indexieren')}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (lastSuccess) {
|
|
const s = lastSuccess;
|
|
const stats = [
|
|
s.indexed > 0 ? t('{n} neu indexiert', { n: s.indexed }) : null,
|
|
s.skippedDuplicate > 0 ? t('{n} unverändert', { n: s.skippedDuplicate }) : null,
|
|
s.failed > 0 ? t('{n} fehler', { n: s.failed }) : null,
|
|
].filter(Boolean).join(' · ');
|
|
|
|
return (
|
|
<div className={styles.successBanner}>
|
|
<FaCheckCircle />
|
|
<span>
|
|
{t('Sync erfolgreich')} {_formatRelative(okAt)}
|
|
{stats && <> — {stats}</>}
|
|
</span>
|
|
<button className={styles.reindexBtn} onClick={() => _handleReindexFeature(fi.featureInstanceId)} title={t('Erneut indexieren')}>
|
|
<FaRedo size={12} /> {t('Erneut indexieren')}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (fi.ragEnabled) {
|
|
return (
|
|
<div className={styles.reindexHint}>
|
|
<button className={styles.reindexBtn} onClick={() => _handleReindexFeature(fi.featureInstanceId)} title={t('Indexierung starten')}>
|
|
<FaRedo size={12} /> {t('Indexierung starten')}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
|
|
<div className={styles.dsList}>
|
|
{(fi.dataSources || []).map(ds => (
|
|
<div key={ds.id} className={`${styles.dsRow} ${ds.ragIndexEnabled ? styles.dsActive : ''}`}>
|
|
<span className={styles.dsLabel}>{ds.label || ds.tableName}</span>
|
|
<span className={styles.dsType}>{ds.featureCode}</span>
|
|
<span className={styles.dsIndex}>{ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}</span>
|
|
</div>
|
|
))}
|
|
{(fi.dataSources || []).length === 0 && fi.fileCount === 0 && (
|
|
<div className={styles.dsEmpty}>{t('Keine Datenquellen konfiguriert')}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{(inventory.connections || []).length === 0 && (inventory.featureInstances || []).length === 0 && (
|
|
<div className={styles.emptyState}>{t('Keine Daten für diese Sicht vorhanden.')}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<DataSourceSettingsModal
|
|
open={!!settingsModal}
|
|
title={settingsModal?.title || ''}
|
|
dataSourceId={settingsModal?.dataSourceId}
|
|
connectionId={settingsModal?.connectionId}
|
|
initialKnowledgeIngestionEnabled={settingsModal?.initialKnowledgeIngestionEnabled}
|
|
showRagSection
|
|
onSaved={() => _fetchInventory()}
|
|
onClose={() => setSettingsModal(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RagInventoryPage;
|