ui-nyla/src/pages/RagInventoryPage.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

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;