feat(status): loading/error states, refresh button, auto-poll, health panel, config truncation, doc status highlights
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user