frontend_nyla/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx
2026-04-14 08:40:44 +02:00

352 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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'];
function _formatTimestamp(ts: number | null | undefined): string {
if (ts == null || ts <= 0) return '';
try {
const d = new Date(ts * 1000);
return d.toLocaleString('de-CH', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
} catch {
return '';
}
}
function _shortMime(mime: string): string {
const m = (mime || '').toLowerCase();
if (m.includes('pdf')) return 'PDF';
if (m.includes('wordprocessing') || m.includes('msword')) return 'Word';
if (m.includes('spreadsheet') || m.includes('excel')) return 'Excel';
if (m.includes('presentation') || m.includes('powerpoint')) return 'PowerPoint';
if (m.startsWith('text/')) return 'Text';
if (m.startsWith('image/')) return 'Bild';
if (m.includes('html')) return 'HTML';
return mime || '';
}
const _STATUS_COLORS: Record<string, string> = {
indexed: '#2e7d32',
extracted: '#1565c0',
embedding: '#6a1b9a',
pending: '#e65100',
failed: '#c62828',
};
interface RagKpis {
indexedDocuments: number;
indexedBytesTotal: number;
contributorUsers: number;
contentChunks: number;
chunksWithEmbedding: number;
embeddingCoveragePercent: number;
workflowEntities: number;
}
interface RecentlyIndexedDoc {
fileName: string;
mimeType: string;
status: string;
extractedAt: number | null;
totalSize: 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 }>;
recentlyIndexedDocuments?: RecentlyIndexedDoc[];
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>
)}
{(stats?.recentlyIndexedDocuments ?? []).length > 0 && (
<div className={styles.chartBlock}>
<h3 className={styles.chartTitle}>{t('Zuletzt indexierte Dokumente')}</h3>
<div style={{ overflowX: 'auto' }}>
<table className={styles.recentTable}>
<thead>
<tr>
<th>{t('Dateiname')}</th>
<th>{t('Format')}</th>
<th>{t('Grösse')}</th>
<th>{t('Status')}</th>
<th>{t('Indexiert am')}</th>
</tr>
</thead>
<tbody>
{(stats?.recentlyIndexedDocuments ?? []).map((doc, i) => (
<tr key={i}>
<td title={doc.fileName} style={{ maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{doc.fileName || ''}
</td>
<td>{_shortMime(doc.mimeType)}</td>
<td style={{ whiteSpace: 'nowrap' }}>{formatBinaryDataSizeBytes(doc.totalSize)}</td>
<td>
<span style={{
color: _STATUS_COLORS[doc.status] ?? '#666',
fontWeight: 500,
}}>
{doc.status}
</span>
</td>
<td style={{ whiteSpace: 'nowrap' }}>{_formatTimestamp(doc.extractedAt)}</td>
</tr>
))}
</tbody>
</table>
</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>
);
};