add
This commit is contained in:
@@ -1,63 +1,132 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Topbar } from '../../components/layout/Topbar';
|
||||
import { Search, Upload, Download, RefreshCw } from 'lucide-react';
|
||||
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react';
|
||||
import { UploadModal } from '../Docs/UploadModal';
|
||||
|
||||
// Backend /api/v1/status/stats returns:
|
||||
// { documents_total, documents_indexed, documents_failed, chunks_total }
|
||||
interface Stats { documents_total: number; documents_indexed: number; documents_failed: number; chunks_total: number; }
|
||||
// ── API types ──────────────────────────────────────────────────────────────
|
||||
interface Stats {
|
||||
documents_total: number;
|
||||
documents_indexed: number;
|
||||
documents_failed: number;
|
||||
chunks_total: number;
|
||||
}
|
||||
|
||||
const TASKS = [
|
||||
{ name: 'EU AI Act — Article 13 check', status: 'ok', progress: 88, cta: 'View report' },
|
||||
{ name: 'GB/T 42118 compliance scan', status: 'warn', progress: 54, cta: 'Continue' },
|
||||
{ name: 'MIIT Draft — automotive AI embedding', status: 'info', progress: 12, cta: 'Start' },
|
||||
];
|
||||
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 };
|
||||
}
|
||||
|
||||
const PROGRAMS = [
|
||||
{ name: 'EU AI Act Readiness', status: 'ok', coverage: 88 },
|
||||
{ name: 'China MIIT Compliance', status: 'warn', coverage: 54 },
|
||||
{ name: 'ISO/SAE 21434 Audit', status: 'info', coverage: 32 },
|
||||
];
|
||||
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;
|
||||
}
|
||||
|
||||
const KPIS = [
|
||||
{ label: 'Retrieval hit rate', value: 94, unit: '%' },
|
||||
{ label: 'Evidence coverage', value: 78, unit: '%' },
|
||||
{ label: 'Reviewer SLA', value: 91, unit: '%' },
|
||||
];
|
||||
// ── 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)" />;
|
||||
}
|
||||
|
||||
const SERVICES = [
|
||||
{ name: 'Vector store (Chroma)', status: 'ok' },
|
||||
{ name: 'LLM gateway (Claude)', status: 'ok' },
|
||||
{ name: 'Document parser', status: 'ok' },
|
||||
{ name: 'SSE stream endpoint', status: 'ok' },
|
||||
{ name: 'Regulation feed sync', status: 'warn' },
|
||||
];
|
||||
function ServiceRow({ name, status, detail }: { name: string; status: 'ok' | 'error' | 'warn' | 'info'; detail?: string }) {
|
||||
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' }}>
|
||||
{status === 'ok' ? 'Online' : status === 'error' ? 'Error' : status === 'warn' ? 'Degraded' : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const EVENTS = [
|
||||
{ date: '2025-11-18', title: 'EU AI Act — Delegated acts published', summary: 'European Commission releases implementing rules for high-risk AI classification under Annex III.' },
|
||||
{ date: '2025-10-30', title: 'MIIT Draft — automotive AI', summary: 'New draft regulation covers in-vehicle AI training data provenance and OTA update governance.' },
|
||||
{ date: '2025-10-05', title: 'ISO/SAE 21434 amendment', summary: 'Amendment 1 clarifies cybersecurity management system scope for software-only updates.' },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = { ok: 'Complete', warn: 'In progress', info: 'Pending' };
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────
|
||||
export function StatusPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [health, setHealth] = useState<Health | null>(null);
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [healthLoading, setHealthLoading] = useState(true);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch('/api/v1/status/stats')
|
||||
.then(r => r.json())
|
||||
.then(d => { setStats(d); setLoading(false); })
|
||||
.catch(() => {
|
||||
setStats({ documents_total: 42, documents_indexed: 38, documents_failed: 1, chunks_total: 3841 });
|
||||
setLoading(false);
|
||||
});
|
||||
setHealthLoading(true);
|
||||
|
||||
// Fetch all three endpoints in parallel
|
||||
Promise.allSettled([
|
||||
fetch('/api/v1/status/stats').then(r => r.json()),
|
||||
fetch('/api/v1/status/health').then(r => r.json()),
|
||||
fetch('/api/v1/status/config').then(r => r.json()),
|
||||
]).then(([statsRes, healthRes, configRes]) => {
|
||||
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);
|
||||
|
||||
setLoading(false);
|
||||
setHealthLoading(false);
|
||||
setLastRefresh(new Date());
|
||||
});
|
||||
}, [refreshKey]);
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="status-page">
|
||||
<Topbar
|
||||
@@ -68,17 +137,8 @@ export function StatusPage() {
|
||||
<Search size={13} />
|
||||
<input placeholder="Search..." />
|
||||
</div>
|
||||
<button
|
||||
className="btn sm"
|
||||
onClick={() => {
|
||||
const blob = new Blob([JSON.stringify(stats, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'regulation-hub-status.json'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
<Download size={13} />Export status
|
||||
<button className="btn sm" onClick={handleExport}>
|
||||
<Download size={13} />Export
|
||||
</button>
|
||||
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
||||
<RefreshCw size={13} />Refresh
|
||||
@@ -89,7 +149,10 @@ export function StatusPage() {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="page-content">
|
||||
|
||||
{/* ── Stats grid ────────────────────────────────────────────────── */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-cell">
|
||||
{loading ? <span className="loading-shimmer stat-value-loading" /> : <div className="stat-value">{stats?.documents_total ?? '—'}</div>}
|
||||
@@ -109,72 +172,182 @@ export function StatusPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indexed progress bar */}
|
||||
{!loading && stats && stats.documents_total > 0 && (
|
||||
<div style={{ padding: '0 0 20px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)', whiteSpace: 'nowrap' }}>Index coverage</span>
|
||||
<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 ───────────────────────────────────────────── */}
|
||||
<div className="panel-grid">
|
||||
<div className="panel-left">
|
||||
|
||||
{/* System health */}
|
||||
<div className="card">
|
||||
<div className="card-header">Workflow queue</div>
|
||||
{TASKS.map(t => (
|
||||
<div key={t.name} className="task-row">
|
||||
<div className="task-info">
|
||||
<div className="task-name">{t.name}</div>
|
||||
<div className="task-progress-bar">
|
||||
<div className="task-progress-fill" style={{ width: `${t.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<span className={`status ${t.status}`}>{STATUS_LABEL[t.status]}</span>
|
||||
<button className="btn sm">{t.cta}</button>
|
||||
<div className="card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span>System health</span>
|
||||
{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 }} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
) : 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'}
|
||||
detail={health.bm25.available ? undefined : 'Not loaded'}
|
||||
/>
|
||||
<ServiceRow
|
||||
name={`Reranker${health.reranker.model ? ` (${health.reranker.model})` : ''}`}
|
||||
status={health.reranker.enabled ? 'ok' : 'info'}
|
||||
detail={health.reranker.enabled ? 'Enabled' : 'Disabled'}
|
||||
/>
|
||||
<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 }}>
|
||||
Could not reach health endpoint
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* System config (collapsible) */}
|
||||
<div className="card">
|
||||
<div className="card-header">Active compliance programs</div>
|
||||
{PROGRAMS.map(p => (
|
||||
<div key={p.name} className="program-row">
|
||||
<span className={`status ${p.status}`} style={{ marginRight: 'auto' }}>{p.name}</span>
|
||||
<span className="program-pct">{p.coverage}%</span>
|
||||
<button
|
||||
onClick={() => setConfigOpen(v => !v)}
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
<div className="card-header" style={{ margin: 0, padding: 0, flex: 1, textAlign: 'left' }}>System configuration</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', transform: configOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>▾</span>
|
||||
</button>
|
||||
|
||||
{configOpen && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{config ? (
|
||||
<>
|
||||
<ConfigRow label="LLM provider" value={config.llm_provider} />
|
||||
<ConfigRow label="LLM model" value={config.llm_model} />
|
||||
<ConfigRow label="Embedding model" value={config.embedding_model} />
|
||||
<ConfigRow label="Embedding dim" value={config.embedding_dim} />
|
||||
<ConfigRow label="Milvus collection" value={config.milvus_collection} />
|
||||
<ConfigRow label="Parser backend" value={config.parser_backend} />
|
||||
<ConfigRow label="Chunk backend" value={config.chunk_backend} />
|
||||
<ConfigRow label="Parser failure mode" value={config.parser_failure_mode} />
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: 'var(--muted)', fontSize: 13 }}>Could not load config</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="kpi-strip">
|
||||
{KPIS.map(k => (
|
||||
<div key={k.label} className="kpi-item">
|
||||
<div className="kpi-label">{k.label}</div>
|
||||
<div className="kpi-bar"><div className="kpi-fill" style={{ width: `${k.value}%` }} /></div>
|
||||
<div className="kpi-value">{k.value}{k.unit}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-right">
|
||||
|
||||
{/* Document breakdown */}
|
||||
<div className="card">
|
||||
<div className="card-header">System health</div>
|
||||
{SERVICES.map(s => (
|
||||
<div key={s.name} className="service-row">
|
||||
<span className="service-name">{s.name}</span>
|
||||
<span className={`status ${s.status}`}>{s.status === 'ok' ? 'Online' : 'Degraded'}</span>
|
||||
<div className="card-header">Document breakdown</div>
|
||||
{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 }} />)}
|
||||
</div>
|
||||
))}
|
||||
) : stats ? (
|
||||
<>
|
||||
{[
|
||||
{ label: 'Indexed', value: stats.documents_indexed, total: stats.documents_total, color: 'var(--ok)' },
|
||||
{ label: 'Processing / Parsed', value: stats.documents_total - stats.documents_indexed - stats.documents_failed, total: stats.documents_total, color: 'var(--warn)' },
|
||||
{ label: 'Failed', value: stats.documents_failed, total: stats.documents_total, color: 'var(--danger)' },
|
||||
].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 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Total vector chunks</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{stats.chunks_total.toLocaleString()}</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">Regulatory watch</div>
|
||||
{EVENTS.map(e => (
|
||||
<div key={e.title} className="event-row">
|
||||
<div className="event-date">{e.date}</div>
|
||||
<div className="event-title">{e.title}</div>
|
||||
<div className="event-summary">{e.summary}</div>
|
||||
{/* Sessions & reranker quick facts */}
|
||||
{health && (
|
||||
<div className="card">
|
||||
<div className="card-header">Runtime info</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Active chat sessions</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{health.sessions.active}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Session capacity</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{health.sessions.max}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--muted)' }}>Cross-encoder reranker</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: health.reranker.enabled ? 'var(--ok)' : 'var(--muted)' }}>
|
||||
{health.reranker.enabled ? (health.reranker.model ?? 'Enabled') : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--muted)' }}>BM25 hybrid retrieval</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: health.bm25.available ? 'var(--ok)' : 'var(--muted)' }}>
|
||||
{health.bm25.available ? 'Active' : 'Unavailable'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="page-footer">
|
||||
<div className="live-dot" />
|
||||
<span>Regulation Hub · T-Systems AI · Online</span>
|
||||
<span>Regulation Hub · T-Systems AI · {health ? (health.milvus.status === 'ok' && health.minio.connected ? 'All systems operational' : 'Degraded') : 'Checking…'}</span>
|
||||
</footer>
|
||||
|
||||
{showUpload && <UploadModal onClose={() => setShowUpload(false)} />}
|
||||
|
||||
Reference in New Issue
Block a user