ui-nyla/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx
2026-04-11 22:23:35 +02:00

271 lines
9.5 KiB
TypeScript

/**
* WorkspaceRagInsightsPage — Aggregierte, nicht personenbezogene Kennzahlen zum
* Knowledge Store / RAG dieser Workspace-Instanz (Präsentationen, Monitoring).
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
BarChart,
Bar,
PieChart,
Pie,
Cell,
} from 'recharts';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import styles from './WorkspaceRagInsightsPage.module.css';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import { useLanguage } from '../../../providers/language/LanguageContext';
function _mimeLabel(key: string, t: (k: string) => string): string {
switch (key) {
case 'pdf': return t('PDF');
case 'office_doc': return t('Office (Text)');
case 'office_sheet': return t('Office (Tabellen)');
case 'office_slides': return t('Office (Folien)');
case 'text': return t('Text');
case 'image': return t('Bild');
case 'html': return t('HTML');
case 'other': return t('Sonstige');
default: return key;
}
}
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828'];
interface RagKpis {
indexedDocuments: number;
indexedBytesTotal: number;
contributorUsers: number;
contentChunks: number;
chunksWithEmbedding: number;
embeddingCoveragePercent: number;
workflowEntities: number;
}
interface RagStatsResponse {
error?: string;
scope?: {
featureInstanceId?: string;
mandateScopedShared?: boolean;
workspaceFileIdsResolved?: number;
};
kpis?: RagKpis;
indexedDocumentsByStatus?: Record<string, number>;
documentsByMimeCategory?: Record<string, number>;
chunksByContentType?: Record<string, number>;
timelineIndexedDocuments?: Array<{ date: string; indexedDocuments: number }>;
generatedAtUtc?: string;
}
export const WorkspaceRagInsightsPage: React.FC = () => {
const { t } = useLanguage();
const instanceId = useInstanceId();
const { request } = useApiRequest();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [stats, setStats] = useState<RagStatsResponse | null>(null);
const load = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
setError(null);
try {
const data = (await request({
url: `/api/workspace/${instanceId}/rag-statistics`,
method: 'get',
})) as RagStatsResponse;
if (data?.error) {
setError(String(data.error));
setStats(null);
} else {
setStats(data ?? null);
}
} catch (e) {
setError(e instanceof Error ? e.message : t('Laden fehlgeschlagen'));
setStats(null);
} finally {
setLoading(false);
}
}, [instanceId, request, t]);
useEffect(() => {
void load();
}, [load]);
if (!instanceId) {
return (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
{t('Keine Workspace-Instanz ausgewählt.')}
</div>
);
}
if (loading) {
return <div className={styles.wrap} style={{ padding: 24 }}>{t('Lade Kennzahlen')}</div>;
}
if (error) {
return <div className={styles.error}>{error}</div>;
}
const kpis = stats?.kpis;
const timeline = stats?.timelineIndexedDocuments ?? [];
const mimeRows = Object.entries(stats?.documentsByMimeCategory ?? {}).map(([key, value]) => ({
name: _mimeLabel(key, t),
value,
}));
const statusRows = Object.entries(stats?.indexedDocumentsByStatus ?? {}).map(([name, value]) => ({
name,
value,
}));
const chunkTypeRows = Object.entries(stats?.chunksByContentType ?? {}).map(([name, value]) => ({
name,
value,
}));
return (
<div className={styles.wrap}>
<p className={styles.disclaimer}>
{t(
'Dargestellt sind ausschliesslich aggregierte technische Masszahlen dieser Instanz (Anzahl Dokumente, Fragmente, Speicherumfang, Verteilungen). Es werden keine Inhalte, Dateinamen oder personenbezogene Angaben ausgewiesen. Geeignet für interne Berichte und Präsentationen.',
)}
</p>
{stats?.scope?.workspaceFileIdsResolved !== undefined && (
<p className={styles.meta} style={{ marginTop: 0 }}>
{t(
'Zuordnung Knowledge ↔ Dateien: {workspaceFileIdsResolved} Datei-ID(s) mit dieser Feature-Instanz in der Dateiverwaltung. Neu indexierte Uploads erhalten die Instanz automatisch; ältere Einträge ohne Zuordnung erscheinen erst nach erneuter Indexierung.',
{ workspaceFileIdsResolved: stats.scope.workspaceFileIdsResolved },
)}
</p>
)}
{kpis && (
<div className={styles.kpiGrid}>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.indexedDocuments}</p>
<p className={styles.kpiLabel}>{t('Indexierte Dokumente')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{formatBinaryDataSizeBytes(kpis.indexedBytesTotal)}</p>
<p className={styles.kpiLabel}>{t('Indexiertes Datenvolumen (geschätzt)')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.contentChunks}</p>
<p className={styles.kpiLabel}>{t('Inhaltsfragmente (Chunks)')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>
{kpis.embeddingCoveragePercent}%
</p>
<p className={styles.kpiLabel}>{t('Anteil Fragmente mit Embedding')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.contributorUsers}</p>
<p className={styles.kpiLabel}>{t('Beitragende Benutzeranzahl')}</p>
</div>
<div className={styles.kpiCard}>
<p className={styles.kpiValue}>{kpis.workflowEntities}</p>
<p className={styles.kpiLabel}>{t('Workflowentitäten-Cache')}</p>
</div>
</div>
)}
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Neu indexierte Dokumente pro Tag')}</h3>
{timeline.length === 0 ? (
<p className={styles.meta}>{t('Keine Zeitreihendaten für den gewählten')}</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={timeline}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip />
<Line type="monotone" dataKey="indexedDocuments" name={t('Dokumente')} stroke="#1976d2" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
)}
</div>
<div className={styles.row2}>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Dokumente nach Formatkategorie')}</h3>
{mimeRows.length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={mimeRows} layout="vertical" margin={{ left: 8, right: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" allowDecimals={false} />
<YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="value" name={t('Anzahl')} fill="#00897b" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Index-Status')}</h3>
{statusRows.length === 0 ? (
<p className={styles.meta}>{t('Keine Daten')}</p>
) : (
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={statusRows}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={88}
label={({ name, percent }) =>
`${name ?? ''} ${percent != null ? (percent * 100).toFixed(0) : '0'}%`}
>
{statusRows.map((_, i) => (
<Cell key={`st-${i}`} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Fragmente nach Inhaltstyp')}</h3>
{chunkTypeRows.length === 0 ? (
<p className={styles.meta}>{t('Keine Chunkdaten')}</p>
) : (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={chunkTypeRows}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="value" name={t('Fragmente')} fill="#6a1b9a" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{stats?.generatedAtUtc && (
<p className={styles.meta}>
{t('Stand (UTC):')} {stats.generatedAtUtc}
</p>
)}
</div>
);
};