main-ruqi #1
@@ -3,6 +3,8 @@ import { Topbar } from '../../components/layout/Topbar';
|
|||||||
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react';
|
import { Search, Upload, Download, RefreshCw, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react';
|
||||||
import { UploadModal } from '../Docs/UploadModal';
|
import { UploadModal } from '../Docs/UploadModal';
|
||||||
import { useLanguage } from '../../contexts/LanguageContext';
|
import { useLanguage } from '../../contexts/LanguageContext';
|
||||||
|
import { getModelUsage, pingModelConnections } from '../../api/status';
|
||||||
|
import type { ModelUsageEntry } from '../../api/index';
|
||||||
|
|
||||||
const TOKEN_KEY = 'auth_token';
|
const TOKEN_KEY = 'auth_token';
|
||||||
function authHeader(): Record<string, string> {
|
function authHeader(): Record<string, string> {
|
||||||
@@ -85,22 +87,29 @@ export function StatusPage() {
|
|||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||||
|
const [modelUsage, setModelUsage] = useState<ModelUsageEntry[] | null>(null);
|
||||||
|
const [pinging, setPinging] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setHealthLoading(true);
|
setHealthLoading(true);
|
||||||
|
|
||||||
// Fetch all three endpoints in parallel
|
// 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.
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
fetch('/api/v1/status/stats', { headers: authHeader() }).then(r => r.json()),
|
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/health', { headers: authHeader() }).then(r => r.json()),
|
||||||
fetch('/api/v1/status/config', { headers: authHeader() }).then(r => r.json()),
|
fetch('/api/v1/status/config', { headers: authHeader() }).then(r => r.json()),
|
||||||
]).then(([statsRes, healthRes, configRes]) => {
|
getModelUsage(),
|
||||||
|
]).then(([statsRes, healthRes, configRes, modelsRes]) => {
|
||||||
if (statsRes.status === 'fulfilled') setStats(statsRes.value);
|
if (statsRes.status === 'fulfilled') setStats(statsRes.value);
|
||||||
else setStats({ documents_total: 0, documents_indexed: 0, documents_failed: 0, chunks_total: 0 });
|
else setStats({ documents_total: 0, documents_indexed: 0, documents_failed: 0, chunks_total: 0 });
|
||||||
|
|
||||||
if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
|
if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
|
||||||
if (configRes.status === 'fulfilled') setConfig(configRes.value);
|
if (configRes.status === 'fulfilled') setConfig(configRes.value);
|
||||||
|
if (modelsRes.status === 'fulfilled') setModelUsage(modelsRes.value.models);
|
||||||
|
else setModelUsage(null);
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHealthLoading(false);
|
setHealthLoading(false);
|
||||||
@@ -136,6 +145,38 @@ export function StatusPage() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="status-page">
|
<div className="status-page">
|
||||||
<Topbar
|
<Topbar
|
||||||
@@ -254,6 +295,50 @@ export function StatusPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{!modelUsage ? (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* System config (collapsible) */}
|
{/* System config (collapsible) */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<button
|
<button
|
||||||
@@ -335,12 +420,6 @@ export function StatusPage() {
|
|||||||
<span style={{ color: 'var(--muted)' }}>{t.status.labelSessionCapacity}</span>
|
<span style={{ color: 'var(--muted)' }}>{t.status.labelSessionCapacity}</span>
|
||||||
<span style={{ fontFamily: 'var(--font-mono)' }}>{health.sessions.max}</span>
|
<span style={{ fontFamily: 'var(--font-mono)' }}>{health.sessions.max}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
|
||||||
<span style={{ color: 'var(--muted)' }}>{t.status.labelReranker}</span>
|
|
||||||
<span style={{ fontFamily: 'var(--font-mono)', color: health.reranker.enabled ? 'var(--ok)' : 'var(--muted)' }}>
|
|
||||||
{health.reranker.enabled ? (health.reranker.model ?? t.status.serviceEnabled) : t.status.serviceDisabled}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '4px 0' }}>
|
||||||
<span style={{ color: 'var(--muted)' }}>{t.status.labelBM25}</span>
|
<span style={{ color: 'var(--muted)' }}>{t.status.labelBM25}</span>
|
||||||
<span style={{ fontFamily: 'var(--font-mono)', color: health.bm25.available ? 'var(--ok)' : 'var(--muted)' }}>
|
<span style={{ fontFamily: 'var(--font-mono)', color: health.bm25.available ? 'var(--ok)' : 'var(--muted)' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user