3 Commits

4 changed files with 312 additions and 38 deletions

View File

@@ -1,32 +1,53 @@
"""Define API routes for status.""" """Define API routes for status."""
import time
from typing import Any
from fastapi import APIRouter from fastapi import APIRouter
from app.config.settings import settings from app.config.settings import settings
from app.shared.bootstrap import get_document_query_service, get_vector_index from app.shared.bootstrap import (
# Keep route handlers close to their transport-layer wiring for easier auditing. get_bm25_retriever,
get_binary_store,
get_conversation_store,
get_document_query_service,
get_vector_index,
)
router = APIRouter(prefix="/status", tags=["系统状态"]) router = APIRouter(prefix="/status", tags=["系统状态"])
# ---------------------------------------------------------------------------
# Simple TTL cache for /stats (avoids O(N) doc scan on every request)
# ---------------------------------------------------------------------------
_stats_cache: dict[str, Any] = {}
_stats_cache_time: float = 0.0
_STATS_TTL_SECONDS: float = 10.0
@router.get("/stats") @router.get("/stats")
async def get_stats(): async def get_stats():
"""Return stats.""" """Return document statistics (cached for 10 s)."""
global _stats_cache, _stats_cache_time
now = time.time()
if _stats_cache and (now - _stats_cache_time) < _STATS_TTL_SECONDS:
return _stats_cache
documents = get_document_query_service().list_documents() documents = get_document_query_service().list_documents()
indexed = sum(1 for item in documents if item.status.value == "indexed") indexed = sum(1 for d in documents if d.status.value == "indexed")
failed = sum(1 for item in documents if item.status.value == "failed") failed = sum(1 for d in documents if d.status.value == "failed")
return { _stats_cache = {
"documents_total": len(documents), "documents_total": len(documents),
"documents_indexed": indexed, "documents_indexed": indexed,
"documents_failed": failed, "documents_failed": failed,
"chunks_total": sum(item.chunk_count for item in documents), "chunks_total": sum(d.chunk_count for d in documents),
} }
_stats_cache_time = now
return _stats_cache
@router.get("/config") @router.get("/config")
async def get_config(): async def get_config():
"""Return config.""" """Return system configuration."""
return { return {
"embedding_model": settings.embedding_model, "embedding_model": settings.embedding_model,
"embedding_dim": settings.embedding_dim, "embedding_dim": settings.embedding_dim,
@@ -44,5 +65,49 @@ async def get_config():
@router.get("/milvus/health") @router.get("/milvus/health")
async def milvus_health(): async def milvus_health():
"""Handle milvus health.""" """Return Milvus health (kept for backwards compat)."""
return get_vector_index().health() return get_vector_index().health()
@router.get("/health")
async def get_health():
"""Return aggregate health of all backend services."""
# --- Milvus ---
try:
milvus_info = get_vector_index().health()
milvus_status = "ok" if milvus_info.get("connected") else "error"
except Exception as exc: # noqa: BLE001
milvus_info = {}
milvus_status = "error"
milvus_info["error"] = str(exc)
# --- MinIO ---
try:
minio_connected = get_binary_store().client.connected
minio_status = "ok" if minio_connected else "error"
except Exception: # noqa: BLE001
minio_status = "error"
minio_connected = False
# --- BM25 ---
bm25 = get_bm25_retriever()
# --- Sessions ---
try:
session_count = len(get_conversation_store().list_sessions())
except Exception: # noqa: BLE001
session_count = 0
return {
"milvus": {"status": milvus_status, **milvus_info},
"minio": {"status": minio_status, "connected": minio_connected},
"bm25": {"available": bm25 is not None},
"reranker": {
"enabled": settings.reranker_enabled,
"model": settings.reranker_model if settings.reranker_enabled else None,
},
"sessions": {
"active": session_count,
"max": settings.session_max_sessions,
},
}

View File

@@ -239,4 +239,21 @@ export interface SystemConfig {
document_metadata_path: string; document_metadata_path: string;
} }
export interface ServiceHealth {
status: 'ok' | 'error' | 'unknown';
error?: string;
}
export interface SystemHealth {
milvus: ServiceHealth & {
connected?: boolean;
collection_name?: string;
num_entities?: number;
};
minio: ServiceHealth & { connected?: boolean };
bm25: { available: boolean };
reranker: { enabled: boolean; model?: string | null };
sessions: { active: number; max: number };
}
export { API_BASE_URL }; export { API_BASE_URL };

View File

@@ -1,4 +1,4 @@
import { fetchAPI, type SystemConfig, type SystemStats } from './index'; import { fetchAPI, type SystemConfig, type SystemHealth, type SystemStats } from './index';
export async function getSystemStats(): Promise<SystemStats> { export async function getSystemStats(): Promise<SystemStats> {
return fetchAPI<SystemStats>('/status/stats'); return fetchAPI<SystemStats>('/status/stats');
@@ -8,8 +8,8 @@ export async function getSystemConfig(): Promise<SystemConfig> {
return fetchAPI<SystemConfig>('/status/config'); return fetchAPI<SystemConfig>('/status/config');
} }
export async function getMilvusHealth(): Promise<{ connected: boolean; collections: string[] }> { export async function getSystemHealth(): Promise<SystemHealth> {
return fetchAPI('/status/milvus/health'); return fetchAPI<SystemHealth>('/status/health');
} }
export type { SystemConfig, SystemStats }; export type { SystemConfig, SystemHealth, SystemStats };

View File

@@ -1,8 +1,8 @@
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useTheme } from '../../contexts'; import { useTheme } from '../../contexts';
import { Content } from '../../components/layout/Content'; import { Content } from '../../components/layout/Content';
import { TPattern } from '../../components/common/TPattern'; import { TPattern } from '../../components/common/TPattern';
import { getSystemStats, getSystemConfig, type SystemStats, type SystemConfig } from '../../api/status'; import { getSystemStats, getSystemConfig, getSystemHealth, type SystemStats, type SystemConfig, type SystemHealth } from '../../api/status';
import { getDocumentList, type DocInfo } from '../../api/docs'; import { getDocumentList, type DocInfo } from '../../api/docs';
const StatsCard = ({ label, value, accent = false }: { const StatsCard = ({ label, value, accent = false }: {
@@ -36,6 +36,40 @@ const StatsCard = ({ label, value, accent = false }: {
); );
}; };
const ServiceBadge = ({
label,
status,
detail,
}: {
label: string;
status: 'ok' | 'error' | 'unknown' | boolean;
detail?: string;
}) => {
const { theme } = useTheme();
const isOk = status === 'ok' || status === true;
const isUnknown = status === 'unknown';
const color = isUnknown ? theme.text3 : isOk ? theme.green : '#d64545';
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
background: theme.bgCard,
borderRadius: 10,
border: `1px solid ${theme.border}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color, fontSize: 10 }}></span>
<span className="mono" style={{ fontSize: 12, color: theme.text2 }}>{label}</span>
</div>
<span style={{ fontSize: 12, color, fontWeight: 600 }}>
{detail ?? (isUnknown ? '—' : isOk ? 'OK' : 'ERROR')}
</span>
</div>
);
};
export const StatusPage: React.FC = () => { export const StatusPage: React.FC = () => {
const { theme, isDark } = useTheme(); const { theme, isDark } = useTheme();
const [stats, setStats] = useState<SystemStats>({ const [stats, setStats] = useState<SystemStats>({
@@ -46,33 +80,110 @@ export const StatusPage: React.FC = () => {
}); });
const [config, setConfig] = useState<SystemConfig | null>(null); const [config, setConfig] = useState<SystemConfig | null>(null);
const [docs, setDocs] = useState<DocInfo[]>([]); const [docs, setDocs] = useState<DocInfo[]>([]);
const [health, setHealth] = useState<SystemHealth | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
async function loadData() { const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try { try {
const [statsRes, configRes, docsRes] = await Promise.all([ const [statsRes, configRes, docsRes, healthRes] = await Promise.all([
getSystemStats(), getSystemStats(),
getSystemConfig(), getSystemConfig(),
getDocumentList(), getDocumentList(),
getSystemHealth(),
]); ]);
setStats(statsRes); setStats(statsRes);
setConfig(configRes); setConfig(configRes);
setDocs(docsRes.docs); setDocs(docsRes.docs);
} catch (error) { setHealth(healthRes);
console.error('Failed to load status data:', error); } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load status data');
} finally {
setLoading(false);
} }
}
useEffect(() => {
const timerId = window.setTimeout(() => {
void loadData();
}, 0);
return () => window.clearTimeout(timerId);
}, []); }, []);
// Initial load
useEffect(() => {
void loadData();
}, [loadData]);
// Auto-poll every 5 s while any document is still processing
useEffect(() => {
const hasProcessing = docs.some(d => d.status === 'parsing' || d.status === 'pending');
if (!hasProcessing) return;
const id = window.setInterval(() => void loadData(), 5000);
return () => window.clearInterval(id);
}, [docs, loadData]);
return ( return (
<Content> <Content>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
<TPattern /> <TPattern />
{/* Loading indicator */}
{loading && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, color: theme.text3, fontSize: 13 }}>
<span style={{
display: 'inline-block',
width: 12,
height: 12,
borderRadius: '50%',
border: `2px solid ${theme.accent}`,
borderTopColor: 'transparent',
animation: 'spin 0.8s linear infinite',
}} />
<span className="mono">LOADING...</span>
</div>
)}
{/* Error banner */}
{error && (
<div style={{
marginBottom: 16,
padding: '12px 16px',
background: '#d6454520',
border: '1px solid #d64545',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ fontSize: 13, color: '#d64545' }}>{error}</span>
<button
onClick={() => void loadData()}
style={{ background: 'none', border: '1px solid #d64545', borderRadius: 6, color: '#d64545', cursor: 'pointer', padding: '4px 10px', fontSize: 12 }}
>
</button>
</div>
)}
{/* Stats section */}
<section style={{ marginBottom: 48 }}> <section style={{ marginBottom: 48 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, letterSpacing: '1px', margin: 0 }}>
DOCUMENT STATISTICS
</h2>
<button
onClick={() => void loadData()}
disabled={loading}
style={{
background: 'none',
border: `1px solid ${theme.border}`,
borderRadius: 6,
color: theme.text3,
cursor: loading ? 'not-allowed' : 'pointer',
padding: '4px 12px',
fontSize: 11,
opacity: loading ? 0.5 : 1,
}}
>
</button>
</div>
<div style={{ <div style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)', gridTemplateColumns: 'repeat(4, 1fr)',
@@ -85,6 +196,48 @@ export const StatusPage: React.FC = () => {
</div> </div>
</section> </section>
{/* Service health section */}
<section style={{ marginBottom: 48 }}>
<h2 style={{ fontSize: 14, fontWeight: 600, color: theme.accent, marginBottom: 20, letterSpacing: '1px' }}>
SERVICE HEALTH
</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 12 }}>
<ServiceBadge
label="MILVUS"
status={health?.milvus.status ?? 'unknown'}
detail={health ? (health.milvus.status === 'ok' ? `${health.milvus.num_entities ?? 0} entities` : 'disconnected') : '—'}
/>
<ServiceBadge
label="MINIO"
status={health?.minio.status ?? 'unknown'}
detail={health ? (health.minio.status === 'ok' ? 'connected' : 'disconnected') : '—'}
/>
<ServiceBadge
label="BM25 HYBRID"
status={health?.bm25.available ?? false}
detail={health ? (health.bm25.available ? 'enabled' : 'unavailable') : '—'}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
<ServiceBadge
label="RERANKER"
status={health?.reranker.enabled ?? false}
detail={health ? (health.reranker.enabled ? (health.reranker.model ?? 'enabled') : 'disabled') : '—'}
/>
<ServiceBadge
label="SESSIONS"
status="ok"
detail={health ? `${health.sessions.active} / ${health.sessions.max}` : '—'}
/>
<ServiceBadge
label="LLM"
status="ok"
detail={config ? `${config.llm_provider} · ${config.llm_model}` : '—'}
/>
</div>
</section>
{/* System configuration section */}
<section style={{ marginBottom: 48 }}> <section style={{ marginBottom: 48 }}>
<h2 style={{ <h2 style={{
fontSize: 14, fontSize: 14,
@@ -116,9 +269,24 @@ export const StatusPage: React.FC = () => {
borderRadius: 10, borderRadius: 10,
border: `1px solid ${theme.border}`, border: `1px solid ${theme.border}`,
boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none', boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none',
overflow: 'hidden',
}}> }}>
<span className="mono" style={{ fontSize: 12, color: theme.text3 }}>{k}</span> <span className="mono" style={{ fontSize: 12, color: theme.text3, flexShrink: 0 }}>{k}</span>
<span style={{ fontSize: 14, fontWeight: 500 }}>{v}</span> <span
title={v}
style={{
fontSize: 13,
fontWeight: 500,
maxWidth: 200,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'help',
marginLeft: 8,
}}
>
{v}
</span>
</div> </div>
))} ))}
</div> </div>
@@ -145,15 +313,31 @@ export const StatusPage: React.FC = () => {
borderRadius: 10, borderRadius: 10,
border: `1px solid ${theme.border}`, border: `1px solid ${theme.border}`,
boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none', boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none',
overflow: 'hidden',
}}> }}>
<span className="mono" style={{ fontSize: 12, color: theme.text3 }}>{k}</span> <span className="mono" style={{ fontSize: 12, color: theme.text3, flexShrink: 0 }}>{k}</span>
<span style={{ fontSize: 14, fontWeight: 500 }}>{v}</span> <span
title={v}
style={{
fontSize: 13,
fontWeight: 500,
maxWidth: 200,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'help',
marginLeft: 8,
}}
>
{v}
</span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</section> </section>
{/* Document index section */}
<section> <section>
<h2 style={{ <h2 style={{
fontSize: 14, fontSize: 14,
@@ -171,23 +355,31 @@ export const StatusPage: React.FC = () => {
background: theme.bgCard, background: theme.bgCard,
borderRadius: 10, borderRadius: 10,
marginBottom: 10, marginBottom: 10,
border: `1px solid ${theme.border}`, border: `1px solid ${
d.status === 'failed' ? '#d64545' :
d.status === 'parsing' || d.status === 'pending' ? theme.accent + '80' :
theme.border
}`,
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, overflow: 'hidden' }}>
<span style={{ fontSize: 14 }}>{d.name}</span> <span style={{ fontSize: 14, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.name}</span>
<span className="mono" style={{ fontSize: 11, color: theme.text3 }}> <span className="mono" style={{ fontSize: 11, color: theme.text3, flexShrink: 0 }}>
{d.updated_at ? new Date(d.updated_at).toLocaleString() : d.status} {d.updated_at ? new Date(d.updated_at).toLocaleString() : d.status}
</span> </span>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
<span className="mono" style={{ fontSize: 12, color: theme.text2 }}>{d.chunks} chunks</span> <span className="mono" style={{ fontSize: 12, color: theme.text2 }}>{d.chunks} chunks</span>
<div style={{ <div style={{
padding: '4px 12px', padding: '4px 12px',
background: d.status === 'failed' ? '#d64545' : theme.green, background:
d.status === 'failed' ? '#d64545' :
d.status === 'parsing' || d.status === 'pending' ? theme.accent :
theme.green,
borderRadius: 6, borderRadius: 6,
opacity: d.status === 'parsing' || d.status === 'pending' ? 0.85 : 1,
}}> }}>
<span className="mono" style={{ fontSize: 10, fontWeight: 600, color: '#fff' }}> <span className="mono" style={{ fontSize: 10, fontWeight: 600, color: '#fff' }}>
{d.status.toUpperCase()} {d.status === 'parsing' ? '⟳ ' : ''}{d.status.toUpperCase()}
</span> </span>
</div> </div>
</div> </div>