352 lines
12 KiB
TypeScript
352 lines
12 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'];
|
||
|
||
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>
|
||
);
|
||
};
|