271 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
};
|