/** * 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 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 [mandates, setMandates] = useState([]); const [mandatesLoading, setMandatesLoading] = useState(true); const [selectedScope, setSelectedScope] = useState('personal'); const [onlyMyData, setOnlyMyData] = useState(false); const [inventory, setInventory] = useState(null); const [loading, setLoading] = useState(false); 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 () => { 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 = {}; if (onlyMyData) params.onlyMine = 'true'; const isMandateScope = selectedScope !== 'personal' && selectedScope !== 'platform'; const headers: Record = {}; 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 || window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'))) { 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 (

{t('RAG-Inventar')}

{t('Übersicht und Steuerung der indexierten Wissensdaten.')}

{loading && !inventory &&
{t('Laden...')}
} {error &&
{error}
} {inventory && (
{t('Total Dateien')}: {inventory.totals?.files ?? 0} {t('Total Chunks')}: {inventory.totals?.chunks ?? 0} {inventory.totals?.bytes != null && inventory.totals.bytes > 0 && ( {(inventory.totals.bytes / 1024 / 1024).toFixed(1)} MB )}
{(inventory.connections || []).map((conn: RagConnectionDto) => (
{conn.authority} {conn.externalEmail} {(conn.totalFiles > 0 || conn.totalChunks > 0) && ( {t('{f} Dateien · {c} Chunks', { f: conn.totalFiles, c: conn.totalChunks })} )}
{!conn.knowledgeIngestionEnabled && conn.dataSources.length > 0 && (
{t('Wissensdatenbank deaktiviert — Indexierung pausiert. Toggle aktivieren um Daten zu indexieren.')}
)} {/* 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 ? (
{conn.runningJobs[0].progressMessage || t('Synchronisierung läuft...')}
) : (() => { const errAt = conn.lastError?.finishedAt ?? 0; const okAt = conn.lastSuccess?.finishedAt ?? 0; const errorIsNewer = !!conn.lastError && errAt > okAt; if (errorIsNewer) { return (
{t('Letzter Sync fehlgeschlagen')} ({_formatRelative(errAt)}): {conn.lastError?.errorMessage || t('unbekannter Fehler')}
); } 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 (
{t('Sync abgeschlossen, Korpus aber unvollständig')} ({_formatRelative(okAt)}) {' — '} {t('Limit {l} erreicht', { l: limitText })}. {stats && <> {stats}.}{' '} {t('Weitere Dateien wurden NICHT indexiert.')}
); } return (
{t('Sync erfolgreich')} {_formatRelative(okAt)} {stats && <> — {stats}} {s.durationMs > 0 && ({_formatDuration(s.durationMs)})}
); } if (conn.dataSources.some(ds => ds.ragIndexEnabled) && conn.knowledgeIngestionEnabled) { return (
); } return null; })()}
{conn.dataSources.map(ds => (
{ds.label || ds.path} {ds.sourceType} {ds.fileCount} {t('Dateien')} · {ds.chunkCount} {t('Chunks')} {ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}
))} {conn.dataSources.length === 0 && (
{t('Keine Datenquellen konfiguriert')}
)}
))} {(inventory.featureInstances || []).length > 0 && ( <>

{t('Feature-Daten')}

{(inventory.featureInstances || []).map((fi: RagFeatureInstanceDto) => { const runningJobs = fi.runningJobs || []; const lastSuccess = fi.lastSuccess; const lastError = fi.lastError; return (
{fi.featureCode} {fi.label} {(fi.fileCount > 0 || fi.chunkCount > 0) && ( {t('{f} Dateien · {c} Chunks', { f: fi.fileCount, c: fi.chunkCount })} )} {fi.ragEnabled ? '\uD83E\uDDE0' : '\u2014'}
{!fi.ragEnabled && (fi.dataSources || []).length > 0 && (
{t('RAG-Indexierung ist für keine Datenquelle dieser Feature-Instanz aktiviert. Aktivierung erfolgt in der UDB (Unified Data Bar) der jeweiligen Workspace-Sitzung.')}
)} {runningJobs.length > 0 ? (
{runningJobs[0].progressMessage || t('Feature-Daten werden synchronisiert...')}
) : (() => { const errAt = lastError?.finishedAt ?? 0; const okAt = lastSuccess?.finishedAt ?? 0; const errorIsNewer = !!lastError && errAt > okAt; if (errorIsNewer) { return (
{t('Letzter Sync fehlgeschlagen')} ({_formatRelative(errAt)}): {lastError?.errorMessage || t('unbekannter Fehler')}
); } 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 (
{t('Sync erfolgreich')} {_formatRelative(okAt)} {stats && <> — {stats}}
); } if (fi.ragEnabled) { return (
); } return null; })()}
{(fi.dataSources || []).map(ds => (
{ds.label || ds.tableName} {ds.featureCode} {ds.ragIndexEnabled ? '\uD83E\uDDE0' : '\u2014'}
))} {(fi.dataSources || []).length === 0 && fi.fileCount === 0 && (
{t('Keine Datenquellen konfiguriert')}
)}
); })} )} {(inventory.connections || []).length === 0 && (inventory.featureInstances || []).length === 0 && (
{t('Keine Daten für diese Sicht vorhanden.')}
)}
)} _fetchInventory()} onClose={() => setSettingsModal(null)} />
); }; export default RagInventoryPage;