Skip to content

Commit f521a00

Browse files
committed
feat: component health status dashboard
- Backend: /api/v1/health/components endpoint - Extraction LLM: last run, latency, 24h stats, error count - Lifecycle Engine: last run, duration, trigger type, operations - Embedding: configured status, model, last query - Scheduler: running status, schedule, next run - Frontend: component status cards in Stats page - Backend: getSchedulerStatus() in scheduler.ts
1 parent 0e2d0a2 commit f521a00

6 files changed

Lines changed: 164 additions & 3 deletions

File tree

packages/dashboard/src/api/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ async function request(path: string, opts?: RequestInit) {
6363

6464
// Health
6565
export const getHealth = () => request('/health');
66+
export const getComponentHealth = () => request('/health/components');
6667

6768
// Stats
6869
export const getStats = (agentId?: string) =>

packages/dashboard/src/i18n/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export default {
7171
decayScore: 'Decay Score',
7272
confidence: 'Confidence',
7373
systemHealth: 'System Health',
74+
componentStatus: 'Component Status',
7475
status: 'Status',
7576
version: 'Version',
7677
uptime: 'Uptime',

packages/dashboard/src/i18n/locales/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export default {
7171
decayScore: '衰减分数',
7272
confidence: '置信度',
7373
systemHealth: '系统健康',
74+
componentStatus: '组件状态',
7475
status: '状态',
7576
version: '版本',
7677
uptime: '运行时间',

packages/dashboard/src/pages/Stats.tsx

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useEffect, useState, useRef } from 'react';
2-
import { getStats, getHealth, listMemories } from '../api/client.js';
2+
import { getStats, getHealth, getComponentHealth, listMemories } from '../api/client.js';
33
import { useI18n } from '../i18n/index.js';
44

55
function fmtNum(n: number): string {
@@ -9,6 +9,15 @@ function fmtNum(n: number): string {
99
return String(n);
1010
}
1111

12+
function timeAgo(dateStr: string, future = false): string {
13+
const diff = future ? new Date(dateStr).getTime() - Date.now() : Date.now() - new Date(dateStr).getTime();
14+
const abs = Math.abs(diff);
15+
if (abs < 60_000) return future ? '即将' : '刚刚';
16+
if (abs < 3600_000) return Math.floor(abs / 60_000) + '分钟' + (future ? '后' : '前');
17+
if (abs < 86400_000) return Math.floor(abs / 3600_000) + '小时' + (future ? '后' : '前');
18+
return Math.floor(abs / 86400_000) + '天' + (future ? '后' : '前');
19+
}
20+
1221
// ─── Mini Canvas Bar Chart ──────────────────────────────────────────────────
1322

1423
function BarChart({ data, colors, height = 220 }: { data: { label: string; value: number }[]; colors: string[]; height?: number }) {
@@ -184,13 +193,18 @@ export default function Stats() {
184193
const [health, setHealth] = useState<any>(null);
185194
const [error, setError] = useState('');
186195
const [allMemories, setAllMemories] = useState<any[]>([]);
196+
const [components, setComponents] = useState<any[]>([]);
187197
const { t } = useI18n();
188198

189199
useEffect(() => {
190200
Promise.all([getStats(), getHealth()])
191201
.then(([s, h]) => { setStats(s); setHealth(h); })
192202
.catch(e => setError(e.message));
193203

204+
getComponentHealth()
205+
.then((r: any) => setComponents(r.components || []))
206+
.catch(() => {});
207+
194208
// Load sample memories for distribution histograms
195209
listMemories({ limit: '500', offset: '0' })
196210
.then((r: any) => setAllMemories(r.items || []))
@@ -294,18 +308,68 @@ export default function Stats() {
294308

295309
{/* System Health */}
296310
{health && (
297-
<div className="card">
311+
<div className="card" style={{ marginBottom: 16 }}>
298312
<h3 style={{ marginBottom: 12 }}>{t('stats.systemHealth')}</h3>
299313
<table>
300314
<tbody>
301315
<tr><td>{t('stats.status')}</td><td><span style={{ color: health.status === 'ok' ? 'var(--success)' : 'var(--danger)' }}>{health.status}</span></td></tr>
302316
<tr><td>{t('stats.version')}</td><td>{health.version}</td></tr>
303317
<tr><td>{t('stats.uptime')}</td><td>{formatUptime(health.uptime)}</td></tr>
304-
<tr><td>{t('stats.timestamp')}</td><td>{health.timestamp}</td></tr>
305318
</tbody>
306319
</table>
307320
</div>
308321
)}
322+
323+
{/* Component Status */}
324+
{components.length > 0 && (
325+
<div className="card">
326+
<h3 style={{ marginBottom: 12 }}>{t('stats.componentStatus')}</h3>
327+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 12 }}>
328+
{components.map((c: any) => {
329+
const statusColor = c.status === 'ok' ? '#22c55e' : c.status === 'warning' ? '#f59e0b' : c.status === 'error' ? '#ef4444' : c.status === 'stopped' ? '#ef4444' : c.status === 'not_configured' ? '#71717a' : '#71717a';
330+
const statusLabel = c.status === 'ok' ? '✅ 正常' : c.status === 'warning' ? '⚠️ 警告' : c.status === 'error' ? '❌ 错误' : c.status === 'stopped' ? '⏹ 停止' : c.status === 'not_configured' ? '⚙️ 未配置' : '❓ 未知';
331+
const ago = c.lastRun ? timeAgo(c.lastRun) : null;
332+
return (
333+
<div key={c.id} style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, padding: 14 }}>
334+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
335+
<span style={{ fontWeight: 600, fontSize: 14 }}>{c.name}</span>
336+
<span style={{ color: statusColor, fontSize: 12, fontWeight: 600 }}>{statusLabel}</span>
337+
</div>
338+
{ago && (
339+
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 4 }}>
340+
上次运行: {ago}
341+
</div>
342+
)}
343+
{c.latencyMs != null && (
344+
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 4 }}>
345+
延迟: {c.latencyMs}ms
346+
</div>
347+
)}
348+
{c.details && (
349+
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
350+
{c.id === 'extraction_llm' && <>
351+
通道: {c.details.channel} · 24h: {c.details.last24h}
352+
{c.details.errorsLast24h > 0 && <span style={{ color: '#ef4444' }}> · 错误: {c.details.errorsLast24h}</span>}
353+
</>}
354+
{c.id === 'lifecycle' && <>
355+
触发: {c.details.trigger === 'scheduled' ? '⏰定时' : c.details.trigger === 'manual' ? '👆手动' : c.details.trigger || '-'}
356+
{' · '}升级: {c.details.promoted ?? 0} · 归档: {c.details.archived ?? 0}
357+
</>}
358+
{c.id === 'embedding' && <>
359+
模型: {c.details.model}
360+
</>}
361+
{c.id === 'scheduler' && <>
362+
计划: {c.details.schedule || '-'}
363+
{c.details.nextRun && <> · 下次: {timeAgo(c.details.nextRun, true)}</>}
364+
</>}
365+
</div>
366+
)}
367+
</div>
368+
);
369+
})}
370+
</div>
371+
</div>
372+
)}
309373
</div>
310374
);
311375
}

packages/server/src/api/system.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,90 @@ export function registerSystemRoutes(app: FastifyInstance, cortex: CortexApp): v
167167
return { ok: false, error: e.message };
168168
}
169169
});
170+
// Component health status
171+
app.get('/api/v1/health/components', async () => {
172+
const db = getDb();
173+
const config = getConfig();
174+
const components: any[] = [];
175+
176+
// 1. Extraction LLM
177+
try {
178+
const last = db.prepare(`SELECT channel, created_at, latency_ms, memories_written, memories_deduped FROM extraction_logs ORDER BY created_at DESC LIMIT 1`).get() as any;
179+
const errorCount = (db.prepare(`SELECT COUNT(*) as c FROM extraction_logs WHERE memories_written = 0 AND created_at > datetime('now', '-24 hours')`).get() as any)?.c || 0;
180+
const totalLast24h = (db.prepare(`SELECT COUNT(*) as c FROM extraction_logs WHERE created_at > datetime('now', '-24 hours')`).get() as any)?.c || 0;
181+
components.push({
182+
id: 'extraction_llm',
183+
name: 'Extraction LLM',
184+
status: last ? 'ok' : 'unknown',
185+
lastRun: last?.created_at || null,
186+
latencyMs: last?.latency_ms || null,
187+
details: {
188+
channel: last?.channel,
189+
memoriesWritten: last?.memories_written,
190+
last24h: totalLast24h,
191+
errorsLast24h: errorCount,
192+
},
193+
});
194+
} catch { components.push({ id: 'extraction_llm', name: 'Extraction LLM', status: 'error' }); }
195+
196+
// 2. Lifecycle Engine
197+
try {
198+
const last = db.prepare(`SELECT action, executed_at, details FROM lifecycle_log WHERE action = 'lifecycle_run' ORDER BY executed_at DESC LIMIT 1`).get() as any;
199+
let details: any = {};
200+
try { details = last?.details ? JSON.parse(last.details) : {}; } catch {}
201+
const hasErrors = details.errors && details.errors.length > 0;
202+
components.push({
203+
id: 'lifecycle',
204+
name: 'Lifecycle Engine',
205+
status: last ? (hasErrors ? 'warning' : 'ok') : 'unknown',
206+
lastRun: last?.executed_at || null,
207+
latencyMs: details.durationMs || null,
208+
details: {
209+
trigger: details.trigger,
210+
promoted: details.promoted,
211+
archived: details.archived,
212+
expired: details.expiredWorking,
213+
errors: details.errors?.length || 0,
214+
},
215+
});
216+
} catch { components.push({ id: 'lifecycle', name: 'Lifecycle Engine', status: 'error' }); }
217+
218+
// 3. Embedding Service
219+
try {
220+
// Check if embedding is configured
221+
const hasEmbedding = !!(config.embedding?.baseUrl || config.embedding?.apiKey || process.env.OPENAI_API_KEY);
222+
const lastAccess = db.prepare(`SELECT accessed_at FROM access_log ORDER BY accessed_at DESC LIMIT 1`).get() as any;
223+
components.push({
224+
id: 'embedding',
225+
name: 'Embedding',
226+
status: hasEmbedding ? (lastAccess ? 'ok' : 'unknown') : 'not_configured',
227+
lastRun: lastAccess?.accessed_at || null,
228+
details: {
229+
model: config.embedding?.model || 'default',
230+
configured: hasEmbedding,
231+
},
232+
});
233+
} catch { components.push({ id: 'embedding', name: 'Embedding', status: 'error' }); }
234+
235+
// 4. Scheduler
236+
try {
237+
const { getSchedulerStatus } = await import('../core/scheduler.js');
238+
const sched = getSchedulerStatus();
239+
components.push({
240+
id: 'scheduler',
241+
name: 'Scheduler',
242+
status: sched.running ? 'ok' : 'stopped',
243+
details: {
244+
schedule: sched.schedule,
245+
nextRun: sched.nextRun,
246+
running: sched.running,
247+
},
248+
});
249+
} catch { components.push({ id: 'scheduler', name: 'Scheduler', status: 'unknown' }); }
250+
251+
return { components };
252+
});
253+
170254
// Stats
171255
app.get('/api/v1/stats', async (req) => {
172256
const q = req.query as any;

packages/server/src/core/scheduler.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ const log = createLogger('scheduler');
1515

1616
let lifecycleCron: Cron | null = null;
1717

18+
export function getSchedulerStatus(): { running: boolean; schedule: string | null; nextRun: string | null } {
19+
if (!lifecycleCron) return { running: false, schedule: null, nextRun: null };
20+
const next = lifecycleCron.nextRun();
21+
return {
22+
running: true,
23+
schedule: lifecycleCron.getPattern() || null,
24+
nextRun: next ? next.toISOString() : null,
25+
};
26+
}
27+
1828
/**
1929
* Start the lifecycle cron job based on config.lifecycle.schedule.
2030
* Safe to call multiple times — stops previous job first.

0 commit comments

Comments
 (0)