2026-06-03 17:26:22 +08:00
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
import { Topbar } from '../../components/layout/Topbar';
|
2026-06-05 09:00:36 +08:00
|
|
|
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react';
|
2026-06-04 15:43:44 +08:00
|
|
|
import { UploadModal } from '../Docs/UploadModal';
|
2026-06-10 11:10:36 +08:00
|
|
|
import { useLanguage } from '../../contexts/LanguageContext';
|
2026-07-02 17:38:33 +08:00
|
|
|
import { getModelUsage, pingModelConnections } from '../../api/status';
|
|
|
|
|
import type { ModelUsageEntry } from '../../api/index';
|
2026-06-03 17:26:22 +08:00
|
|
|
|
2026-06-05 18:00:31 +08:00
|
|
|
const TOKEN_KEY = 'auth_token';
|
|
|
|
|
function authHeader(): Record<string, string> {
|
|
|
|
|
const t = localStorage.getItem(TOKEN_KEY);
|
|
|
|
|
return t ? { Authorization: `Bearer ${t}` } : {};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 09:00:36 +08:00
|
|
|
// ── API types ──────────────────────────────────────────────────────────────
|
|
|
|
|
interface Stats {
|
|
|
|
|
documents_total: number;
|
|
|
|
|
documents_indexed: number;
|
|
|
|
|
documents_failed: number;
|
|
|
|
|
chunks_total: number;
|
|
|
|
|
}
|
2026-06-03 17:26:22 +08:00
|
|
|
|
2026-06-05 09:00:36 +08:00
|
|
|
interface Health {
|
|
|
|
|
milvus: { status: string; connected?: boolean; collection_name?: string; num_entities?: number; error?: string };
|
|
|
|
|
minio: { status: string; connected: boolean };
|
|
|
|
|
bm25: { available: boolean };
|
|
|
|
|
reranker: { enabled: boolean; model: string | null };
|
|
|
|
|
sessions: { active: number; max: number };
|
|
|
|
|
}
|
2026-06-03 17:26:22 +08:00
|
|
|
|
2026-06-05 09:00:36 +08:00
|
|
|
interface Config {
|
|
|
|
|
embedding_model: string;
|
|
|
|
|
embedding_dim: number;
|
|
|
|
|
embedding_base_url: string;
|
|
|
|
|
milvus_collection: string;
|
|
|
|
|
parser_backend: string;
|
|
|
|
|
chunk_backend: string;
|
|
|
|
|
llm_provider: string;
|
|
|
|
|
llm_model: string;
|
|
|
|
|
parser_failure_mode: string;
|
|
|
|
|
artifact_prefix?: string;
|
|
|
|
|
document_metadata_path?: string;
|
|
|
|
|
}
|
2026-06-03 17:26:22 +08:00
|
|
|
|
2026-06-05 09:00:36 +08:00
|
|
|
// ── Small helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
function StatusIcon({ status }: { status: 'ok' | 'error' | 'warn' | 'info' }) {
|
|
|
|
|
if (status === 'ok') return <CheckCircle size={14} color="var(--ok)" />;
|
|
|
|
|
if (status === 'error') return <XCircle size={14} color="var(--danger)" />;
|
|
|
|
|
if (status === 'warn') return <AlertTriangle size={14} color="var(--warn)" />;
|
|
|
|
|
return <Info size={14} color="var(--muted)" />;
|
|
|
|
|
}
|
2026-06-03 17:26:22 +08:00
|
|
|
|
2026-06-05 09:00:36 +08:00
|
|
|
function ServiceRow({ name, status, detail }: { name: string; status: 'ok' | 'error' | 'warn' | 'info'; detail?: string }) {
|
2026-06-10 11:10:36 +08:00
|
|
|
const { t } = useLanguage();
|
2026-06-05 09:00:36 +08:00
|
|
|
return (
|
|
|
|
|
<div className="service-row">
|
|
|
|
|
<StatusIcon status={status} />
|
|
|
|
|
<span className="service-name" style={{ marginLeft: 8 }}>{name}</span>
|
|
|
|
|
{detail && <span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6 }}>{detail}</span>}
|
|
|
|
|
<span className={`status ${status}`} style={{ marginLeft: 'auto' }}>
|
2026-06-10 11:10:36 +08:00
|
|
|
{status === 'ok' ? t.status.badgeOnline : status === 'error' ? t.status.badgeError : status === 'warn' ? t.status.badgeDegraded : t.status.badgeUnknown}
|
2026-06-05 09:00:36 +08:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-03 17:26:22 +08:00
|
|
|
|
2026-06-05 09:00:36 +08:00
|
|
|
function ConfigRow({ label, value }: { label: string; value: string | number | null | undefined }) {
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 0', borderBottom: '1px solid var(--border)', fontSize: 12 }}>
|
|
|
|
|
<span style={{ color: 'var(--muted)' }}>{label}</span>
|
|
|
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--fg)', fontSize: 11, maxWidth: '60%', textAlign: 'right', wordBreak: 'break-all' }}>
|
|
|
|
|
{value ?? '—'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-03 17:26:22 +08:00
|
|
|
|
2026-06-05 09:00:36 +08:00
|
|
|
// ── Main component ─────────────────────────────────────────────────────────
|
2026-06-03 17:16:00 +08:00
|
|
|
export function StatusPage() {
|
2026-06-10 11:10:36 +08:00
|
|
|
const { t } = useLanguage();
|
2026-06-03 17:26:22 +08:00
|
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
2026-06-05 09:00:36 +08:00
|
|
|
const [health, setHealth] = useState<Health | null>(null);
|
|
|
|
|
const [config, setConfig] = useState<Config | null>(null);
|
2026-06-04 15:43:44 +08:00
|
|
|
const [loading, setLoading] = useState(true);
|
2026-06-05 09:00:36 +08:00
|
|
|
const [healthLoading, setHealthLoading] = useState(true);
|
2026-07-02 18:01:04 +08:00
|
|
|
const [modelsLoading, setModelsLoading] = useState(true);
|
2026-06-05 09:00:36 +08:00
|
|
|
const [configOpen, setConfigOpen] = useState(false);
|
2026-06-04 15:43:44 +08:00
|
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
|
|
|
const [showUpload, setShowUpload] = useState(false);
|
2026-06-05 09:00:36 +08:00
|
|
|
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
2026-07-02 17:38:33 +08:00
|
|
|
const [modelUsage, setModelUsage] = useState<ModelUsageEntry[] | null>(null);
|
|
|
|
|
const [pinging, setPinging] = useState(false);
|
2026-06-03 17:26:22 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-06-04 15:43:44 +08:00
|
|
|
setLoading(true);
|
2026-06-05 09:00:36 +08:00
|
|
|
setHealthLoading(true);
|
2026-07-02 18:01:04 +08:00
|
|
|
setModelsLoading(true);
|
2026-06-05 09:00:36 +08:00
|
|
|
|
2026-07-02 17:38:33 +08:00
|
|
|
// Fetch all endpoints in parallel. The first three use raw fetch() (legacy
|
|
|
|
|
// pattern already established in this file); model usage uses the typed
|
|
|
|
|
// fetchAPI-based client from api/status.ts — new code should prefer that.
|
2026-06-05 09:00:36 +08:00
|
|
|
Promise.allSettled([
|
2026-06-05 18:00:31 +08:00
|
|
|
fetch('/api/v1/status/stats', { headers: authHeader() }).then(r => r.json()),
|
|
|
|
|
fetch('/api/v1/status/health', { headers: authHeader() }).then(r => r.json()),
|
|
|
|
|
fetch('/api/v1/status/config', { headers: authHeader() }).then(r => r.json()),
|
2026-07-02 17:38:33 +08:00
|
|
|
getModelUsage(),
|
|
|
|
|
]).then(([statsRes, healthRes, configRes, modelsRes]) => {
|
2026-06-05 09:00:36 +08:00
|
|
|
if (statsRes.status === 'fulfilled') setStats(statsRes.value);
|
|
|
|
|
else setStats({ documents_total: 0, documents_indexed: 0, documents_failed: 0, chunks_total: 0 });
|
|
|
|
|
|
|
|
|
|
if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
|
|
|
|
|
if (configRes.status === 'fulfilled') setConfig(configRes.value);
|
2026-07-02 17:38:33 +08:00
|
|
|
if (modelsRes.status === 'fulfilled') setModelUsage(modelsRes.value.models);
|
|
|
|
|
else setModelUsage(null);
|
2026-06-05 09:00:36 +08:00
|
|
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
setHealthLoading(false);
|
2026-07-02 18:01:04 +08:00
|
|
|
setModelsLoading(false);
|
2026-06-05 09:00:36 +08:00
|
|
|
setLastRefresh(new Date());
|
|
|
|
|
});
|
2026-06-04 15:43:44 +08:00
|
|
|
}, [refreshKey]);
|
2026-06-03 17:26:22 +08:00
|
|
|
|
2026-06-05 09:00:36 +08:00
|
|
|
// ── Derived values ───────────────────────────────────────────────────────
|
|
|
|
|
const indexedPct = stats && stats.documents_total > 0
|
|
|
|
|
? Math.round((stats.documents_indexed / stats.documents_total) * 100)
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
|
|
|
function milvusStatus(): 'ok' | 'error' | 'warn' | 'info' {
|
|
|
|
|
if (!health) return 'info';
|
|
|
|
|
return health.milvus.status === 'ok' ? 'ok' : 'error';
|
|
|
|
|
}
|
|
|
|
|
function milvusDetail() {
|
|
|
|
|
if (!health) return undefined;
|
|
|
|
|
if (health.milvus.error) return health.milvus.error.slice(0, 60);
|
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
if (health.milvus.collection_name) parts.push(health.milvus.collection_name);
|
|
|
|
|
if (health.milvus.num_entities !== undefined) parts.push(`${health.milvus.num_entities.toLocaleString()} entities`);
|
|
|
|
|
return parts.join(' · ') || undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Export ───────────────────────────────────────────────────────────────
|
|
|
|
|
function handleExport() {
|
|
|
|
|
const data = { stats, health, config, exportedAt: new Date().toISOString() };
|
|
|
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
a.href = url; a.download = `regulation-hub-status-${Date.now()}.json`; a.click();
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 17:38:33 +08:00
|
|
|
async function handleTestConnections() {
|
|
|
|
|
setPinging(true);
|
|
|
|
|
try {
|
|
|
|
|
const res = await pingModelConnections();
|
|
|
|
|
setModelUsage(res.models);
|
|
|
|
|
} catch {
|
|
|
|
|
// Leave modelUsage as-is; the card below already shows a muted
|
|
|
|
|
// "never_called"/error state per row when data can't be refreshed.
|
|
|
|
|
} finally {
|
|
|
|
|
setPinging(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function modelBadgeStatus(status: ModelUsageEntry['status']): 'ok' | 'error' | 'warn' | 'info' {
|
|
|
|
|
if (status === 'ok') return 'ok';
|
|
|
|
|
if (status === 'error') return 'error';
|
|
|
|
|
if (status === 'disabled') return 'info';
|
|
|
|
|
return 'info'; // never_called
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function modelStatusLabel(entry: ModelUsageEntry): string {
|
|
|
|
|
if (entry.status === 'never_called') return t.status.modelStatusNeverCalled;
|
|
|
|
|
if (entry.status === 'disabled') return t.status.modelStatusDisabled;
|
|
|
|
|
return entry.status === 'ok' ? t.status.badgeOnline : t.status.badgeError;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Small relative-ish hint shown next to provider/model — "Never" or a local time string. */
|
|
|
|
|
function modelLastCalledLabel(entry: ModelUsageEntry): string {
|
|
|
|
|
if (!entry.last_called_at) return t.status.lastCalledNever;
|
|
|
|
|
return new Date(entry.last_called_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 17:26:22 +08:00
|
|
|
return (
|
|
|
|
|
<div className="status-page">
|
|
|
|
|
<Topbar
|
2026-06-10 11:10:36 +08:00
|
|
|
title={t.status.topbarTitle}
|
2026-06-03 17:26:22 +08:00
|
|
|
actions={
|
|
|
|
|
<>
|
|
|
|
|
<div className="search-box">
|
|
|
|
|
<Search size={13} />
|
2026-06-10 11:10:36 +08:00
|
|
|
<input placeholder={t.status.searchPlaceholder} />
|
2026-06-03 17:26:22 +08:00
|
|
|
</div>
|
2026-06-05 09:00:36 +08:00
|
|
|
<button className="btn sm" onClick={handleExport}>
|
2026-06-10 11:10:36 +08:00
|
|
|
<Download size={13} />{t.status.exportBtn}
|
2026-06-04 15:43:44 +08:00
|
|
|
</button>
|
|
|
|
|
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
2026-06-10 11:10:36 +08:00
|
|
|
<RefreshCw size={13} />{t.status.refreshBtn}
|
2026-06-04 15:43:44 +08:00
|
|
|
</button>
|
|
|
|
|
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
|
2026-06-10 11:10:36 +08:00
|
|
|
<Upload size={13} />{t.status.newUploadBtn}
|
2026-06-04 15:43:44 +08:00
|
|
|
</button>
|
2026-06-03 17:26:22 +08:00
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
/>
|
2026-06-05 09:00:36 +08:00
|
|
|
|
2026-06-03 17:26:22 +08:00
|
|
|
<div className="page-content">
|
2026-06-05 09:00:36 +08:00
|
|
|
|
|
|
|
|
{/* ── Stats grid ────────────────────────────────────────────────── */}
|
2026-06-03 17:26:22 +08:00
|
|
|
<div className="stats-grid">
|
|
|
|
|
<div className="stat-cell">
|
2026-06-04 15:43:44 +08:00
|
|
|
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_total ?? '—'}</div>}
|
2026-06-10 11:10:36 +08:00
|
|
|
<div className="stat-label">{t.status.statTotal}</div>
|
2026-06-03 17:26:22 +08:00
|
|
|
</div>
|
|
|
|
|
<div className="stat-cell">
|
2026-06-04 15:43:44 +08:00
|
|
|
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_indexed ?? '—'}</div>}
|
2026-06-10 11:10:36 +08:00
|
|
|
<div className="stat-label">{t.status.statIndexed}</div>
|
2026-06-03 17:26:22 +08:00
|
|
|
</div>
|
|
|
|
|
<div className="stat-cell danger">
|
2026-06-04 15:43:44 +08:00
|
|
|
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_failed ?? '—'}</div>}
|
2026-06-10 11:10:36 +08:00
|
|
|
<div className="stat-label">{t.status.statFailed}</div>
|
2026-06-03 17:26:22 +08:00
|
|
|
</div>
|
|
|
|
|
<div className="stat-cell">
|
2026-06-04 15:43:44 +08:00
|
|
|
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.chunks_total?.toLocaleString() ?? '—'}</div>}
|
2026-06-10 11:10:36 +08:00
|
|
|
<div className="stat-label">{t.status.statChunks}</div>
|
2026-06-03 17:26:22 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-05 09:00:36 +08:00
|
|
|
{/* Indexed progress bar */}
|
|
|
|
|
{!loading && stats && stats.documents_total > 0 && (
|
|
|
|
|
<div style={{ padding: '0 0 20px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
2026-06-10 11:10:36 +08:00
|
|
|
<span style={{ fontSize: 12, color: 'var(--muted)', whiteSpace: 'nowrap' }}>{t.status.statCoverage}</span>
|
2026-06-05 09:00:36 +08:00
|
|
|
<div style={{ flex: 1, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
|
|
|
|
|
<div style={{
|
|
|
|
|
height: '100%', borderRadius: 3,
|
|
|
|
|
width: `${indexedPct}%`,
|
|
|
|
|
background: indexedPct === 100 ? 'var(--ok)' : indexedPct > 60 ? 'var(--accent)' : 'var(--warn)',
|
|
|
|
|
transition: 'width 0.6s ease',
|
|
|
|
|
}} />
|
|
|
|
|
</div>
|
|
|
|
|
<span style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--fg)', whiteSpace: 'nowrap' }}>
|
|
|
|
|
{indexedPct}% ({stats.documents_indexed}/{stats.documents_total})
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ── Main panel grid ───────────────────────────────────────────── */}
|
2026-06-03 17:26:22 +08:00
|
|
|
<div className="panel-grid">
|
|
|
|
|
<div className="panel-left">
|
2026-06-05 09:00:36 +08:00
|
|
|
|
|
|
|
|
{/* System health */}
|
2026-06-03 17:26:22 +08:00
|
|
|
<div className="card">
|
2026-06-05 09:00:36 +08:00
|
|
|
<div className="card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
2026-06-10 11:10:36 +08:00
|
|
|
<span>{t.status.cardHealth}</span>
|
2026-06-05 09:00:36 +08:00
|
|
|
{lastRefresh && (
|
|
|
|
|
<span style={{ fontSize: 11, color: 'var(--muted)', fontWeight: 400 }}>
|
|
|
|
|
Updated {lastRefresh.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{healthLoading ? (
|
|
|
|
|
<div style={{ padding: '12px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
|
|
|
{[1, 2, 3, 4, 5].map(i => (
|
|
|
|
|
<div key={i} className="loading-shimmer" style={{ height: 28, borderRadius: 6 }} />
|
|
|
|
|
))}
|
2026-06-03 17:26:22 +08:00
|
|
|
</div>
|
2026-06-05 09:00:36 +08:00
|
|
|
) : health ? (
|
|
|
|
|
<>
|
|
|
|
|
<ServiceRow
|
|
|
|
|
name="Milvus vector store"
|
|
|
|
|
status={milvusStatus()}
|
|
|
|
|
detail={milvusDetail()}
|
|
|
|
|
/>
|
|
|
|
|
<ServiceRow
|
|
|
|
|
name="MinIO object storage"
|
|
|
|
|
status={health.minio.connected ? 'ok' : 'error'}
|
|
|
|
|
/>
|
|
|
|
|
<ServiceRow
|
|
|
|
|
name="BM25 keyword retriever"
|
|
|
|
|
status={health.bm25.available ? 'ok' : 'warn'}
|
2026-06-10 11:10:36 +08:00
|
|
|
detail={health.bm25.available ? undefined : t.status.serviceNotLoaded}
|
2026-06-05 09:00:36 +08:00
|
|
|
/>
|
|
|
|
|
<ServiceRow
|
|
|
|
|
name={`Reranker${health.reranker.model ? ` (${health.reranker.model})` : ''}`}
|
|
|
|
|
status={health.reranker.enabled ? 'ok' : 'info'}
|
2026-06-10 11:10:36 +08:00
|
|
|
detail={health.reranker.enabled ? t.status.serviceEnabled : t.status.serviceDisabled}
|
2026-06-05 09:00:36 +08:00
|
|
|
/>
|
|
|
|
|
<ServiceRow
|
|
|
|
|
name="Active sessions"
|
|
|
|
|
status={health.sessions.active < health.sessions.max ? 'ok' : 'warn'}
|
|
|
|
|
detail={`${health.sessions.active} / ${health.sessions.max} max`}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<div style={{ padding: '12px 0', color: 'var(--muted)', fontSize: 13 }}>
|
2026-06-10 11:10:36 +08:00
|
|
|
{t.status.healthEndpointError}
|
2026-06-05 09:00:36 +08:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-03 17:26:22 +08:00
|
|
|
</div>
|
|
|
|
|
|
2026-07-02 17:38:33 +08:00
|
|
|
{/* AI Models — connection status + cumulative token usage */}
|
|
|
|
|
<div className="card">
|
|
|
|
|
<div className="card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
|
|
|
<span>{t.status.cardModels}</span>
|
|
|
|
|
<button className="btn sm" onClick={handleTestConnections} disabled={pinging}>
|
|
|
|
|
{pinging ? t.status.testingBtn : t.status.testConnectionBtn}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-07-02 18:01:04 +08:00
|
|
|
{modelsLoading ? (
|
2026-07-02 17:38:33 +08:00
|
|
|
<div style={{ padding: '12px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
|
|
|
{[1, 2, 3, 4].map(i => (
|
|
|
|
|
<div key={i} className="loading-shimmer" style={{ height: 28, borderRadius: 6 }} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-07-02 18:01:04 +08:00
|
|
|
) : modelUsage ? (
|
2026-07-02 17:38:33 +08:00
|
|
|
modelUsage.map(entry => {
|
|
|
|
|
const roleLabel = entry.role === 'main_llm' ? t.status.roleMainLlm
|
|
|
|
|
: entry.role === 'hyde_llm' ? t.status.roleHydeLlm
|
|
|
|
|
: entry.role === 'embedding' ? t.status.roleEmbedding
|
|
|
|
|
: t.status.roleReranker;
|
|
|
|
|
return (
|
|
|
|
|
<div className="service-row" key={entry.role}>
|
|
|
|
|
<StatusIcon status={modelBadgeStatus(entry.status)} />
|
|
|
|
|
<span className="service-name" style={{ marginLeft: 8 }}>{roleLabel}</span>
|
|
|
|
|
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6, fontFamily: 'var(--font-mono)' }}>
|
|
|
|
|
{entry.provider}/{entry.model}
|
|
|
|
|
{entry.shares_usage_with && ` · ${t.status.sharesUsageWithMain}`}
|
|
|
|
|
{` · ${modelLastCalledLabel(entry)}`}
|
|
|
|
|
</span>
|
|
|
|
|
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg)' }}>
|
|
|
|
|
{entry.total_tokens > 0 || entry.status === 'ok' || entry.status === 'error'
|
|
|
|
|
? entry.total_tokens.toLocaleString()
|
|
|
|
|
: '—'}
|
|
|
|
|
</span>
|
|
|
|
|
<span className={`status ${modelBadgeStatus(entry.status)}`} style={{ marginLeft: 8 }}>
|
|
|
|
|
{modelStatusLabel(entry)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})
|
2026-07-02 18:01:04 +08:00
|
|
|
) : (
|
2026-07-02 20:03:27 +08:00
|
|
|
<div style={{ padding: '12px 0', color: 'var(--muted)', fontSize: 13 }}>{t.status.modelsLoadError}</div>
|
2026-07-02 17:38:33 +08:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-05 09:00:36 +08:00
|
|
|
{/* System config (collapsible) */}
|
2026-06-03 17:26:22 +08:00
|
|
|
<div className="card">
|
2026-06-05 09:00:36 +08:00
|
|
|
<button
|
|
|
|
|
onClick={() => setConfigOpen(v => !v)}
|
|
|
|
|
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
|
|
|
|
|
>
|
2026-06-10 11:10:36 +08:00
|
|
|
<div className="card-header" style={{ margin: 0, padding: 0, flex: 1, textAlign: 'left' }}>{t.status.cardConfig}</div>
|
2026-06-05 09:00:36 +08:00
|
|
|
<span style={{ fontSize: 11, color: 'var(--muted)', transform: configOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>▾</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{configOpen && (
|
|
|
|
|
<div style={{ marginTop: 12 }}>
|
|
|
|
|
{config ? (
|
|
|
|
|
<>
|
2026-06-10 11:10:36 +08:00
|
|
|
<ConfigRow label={t.status.labelLLMProvider} value={config.llm_provider} />
|
|
|
|
|
<ConfigRow label={t.status.labelLLMModel} value={config.llm_model} />
|
|
|
|
|
<ConfigRow label={t.status.labelEmbeddingModel} value={config.embedding_model} />
|
|
|
|
|
<ConfigRow label={t.status.labelEmbeddingDim} value={config.embedding_dim} />
|
|
|
|
|
<ConfigRow label={t.status.labelMilvusCollection} value={config.milvus_collection} />
|
|
|
|
|
<ConfigRow label={t.status.labelParserBackend} value={config.parser_backend} />
|
|
|
|
|
<ConfigRow label={t.status.labelChunkBackend} value={config.chunk_backend} />
|
|
|
|
|
<ConfigRow label={t.status.labelParserFailureMode} value={config.parser_failure_mode} />
|
2026-06-05 09:00:36 +08:00
|
|
|
</>
|
|
|
|
|
) : (
|
2026-06-10 11:10:36 +08:00
|
|
|
<div style={{ color: 'var(--muted)', fontSize: 13 }}>{t.status.configLoadError}</div>
|
2026-06-05 09:00:36 +08:00
|
|
|
)}
|
2026-06-03 17:26:22 +08:00
|
|
|
</div>
|
2026-06-05 09:00:36 +08:00
|
|
|
)}
|
2026-06-03 17:26:22 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="panel-right">
|
2026-06-05 09:00:36 +08:00
|
|
|
|
|
|
|
|
{/* Document breakdown */}
|
2026-06-03 17:26:22 +08:00
|
|
|
<div className="card">
|
2026-06-10 11:10:36 +08:00
|
|
|
<div className="card-header">{t.status.cardBreakdown}</div>
|
2026-06-05 09:00:36 +08:00
|
|
|
{loading ? (
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
|
|
|
{[1, 2, 3].map(i => <div key={i} className="loading-shimmer" style={{ height: 24, borderRadius: 4 }} />)}
|
2026-06-03 17:26:22 +08:00
|
|
|
</div>
|
2026-06-05 09:00:36 +08:00
|
|
|
) : stats ? (
|
|
|
|
|
<>
|
|
|
|
|
{[
|
2026-06-10 11:10:36 +08:00
|
|
|
{ label: t.status.breakdownIndexed, value: stats.documents_indexed, total: stats.documents_total, color: 'var(--ok)' },
|
|
|
|
|
{ label: t.status.breakdownProcessing, value: stats.documents_total - stats.documents_indexed - stats.documents_failed, total: stats.documents_total, color: 'var(--warn)' },
|
|
|
|
|
{ label: t.status.breakdownFailed, value: stats.documents_failed, total: stats.documents_total, color: 'var(--danger)' },
|
2026-06-05 09:00:36 +08:00
|
|
|
].map(row => {
|
|
|
|
|
const pct = stats.documents_total > 0 ? Math.round((Math.max(0, row.value) / stats.documents_total) * 100) : 0;
|
|
|
|
|
return (
|
|
|
|
|
<div key={row.label} style={{ marginBottom: 10 }}>
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
|
|
|
|
<span style={{ color: 'var(--muted)' }}>{row.label}</span>
|
|
|
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--fg)' }}>{Math.max(0, row.value)} ({pct}%)</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ height: 5, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' }}>
|
|
|
|
|
<div style={{ height: '100%', width: `${pct}%`, background: row.color, borderRadius: 2, transition: 'width 0.5s' }} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
2026-06-10 11:10:36 +08:00
|
|
|
<span style={{ color: 'var(--muted)' }}>{t.status.totalChunks}</span>
|
2026-06-05 09:00:36 +08:00
|
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{stats.chunks_total.toLocaleString()}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
2026-06-03 17:26:22 +08:00
|
|
|
</div>
|
|
|
|
|
|
2026-06-05 09:00:36 +08:00
|
|
|
{/* Sessions & reranker quick facts */}
|
|
|
|
|
{health && (
|
|
|
|
|
<div className="card">
|
2026-06-10 11:10:36 +08:00
|
|
|
<div className="card-header">{t.status.cardRuntime}</div>
|
2026-06-05 09:00:36 +08:00
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
2026-06-10 11:10:36 +08:00
|
|
|
<span style={{ color: 'var(--muted)' }}>{t.status.labelActiveSessions}</span>
|
2026-06-05 09:00:36 +08:00
|
|
|
<span style={{ fontFamily: 'var(--font-mono)' }}>{health.sessions.active}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
2026-06-10 11:10:36 +08:00
|
|
|
<span style={{ color: 'var(--muted)' }}>{t.status.labelSessionCapacity}</span>
|
2026-06-05 09:00:36 +08:00
|
|
|
<span style={{ fontFamily: 'var(--font-mono)' }}>{health.sessions.max}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
2026-06-10 11:10:36 +08:00
|
|
|
<span style={{ color: 'var(--muted)' }}>{t.status.labelBM25}</span>
|
2026-06-05 09:00:36 +08:00
|
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: health.bm25.available ? 'var(--ok)' : 'var(--muted)' }}>
|
2026-06-10 11:10:36 +08:00
|
|
|
{health.bm25.available ? t.status.statusActive : t.status.statusUnavailable}
|
2026-06-05 09:00:36 +08:00
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-06-03 17:26:22 +08:00
|
|
|
</div>
|
2026-06-05 09:00:36 +08:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-03 17:26:22 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<footer className="page-footer">
|
|
|
|
|
<div className="live-dot" />
|
2026-06-10 11:10:36 +08:00
|
|
|
<span>Regulation Hub · T-Systems AI · {health ? (health.milvus.status === 'ok' && health.minio.connected ? t.status.footerAllOk : t.status.footerDegraded) : t.status.footerChecking}</span>
|
2026-06-03 17:26:22 +08:00
|
|
|
</footer>
|
2026-06-04 15:43:44 +08:00
|
|
|
|
|
|
|
|
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
|
2026-06-03 17:26:22 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
2026-06-03 17:16:00 +08:00
|
|
|
}
|