From 59a219f12c2215e6fb82ea3a978702933b3d3fbc Mon Sep 17 00:00:00 2001 From: lairulan Date: Fri, 10 Apr 2026 16:59:43 +0800 Subject: [PATCH] Add health check bridge: Python checker integration + Web UI dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a bridge layer that spawns an external Python health checker and displays the results in a new "健康" (Health) tab. The checker evaluates skills across 4 dimensions (code quality, git sync, runtime health, activity) and outputs JSON consumed by the frontend. New files: - server/bridge/checker.ts — spawns Python process, caches results - server/routes/health.ts — 4 API endpoints (results/status/run/skill) - web/src/hooks/useHealth.ts — React hook for health data - web/src/components/HealthDashboard.tsx — health dashboard UI Modified: - server/index.ts — register health routes, rename /api/health → /api/ping - web/src/App.tsx — add "健康" view tab with HealthDashboard --- .gitignore | 1 + server/bridge/checker.ts | 133 +++++++++++++++++ server/index.ts | 8 +- server/routes/health.ts | 61 ++++++++ web/src/App.tsx | 20 ++- web/src/components/HealthDashboard.tsx | 189 +++++++++++++++++++++++++ web/src/hooks/useHealth.ts | 94 ++++++++++++ 7 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 server/bridge/checker.ts create mode 100644 server/routes/health.ts create mode 100644 web/src/components/HealthDashboard.tsx create mode 100644 web/src/hooks/useHealth.ts diff --git a/.gitignore b/.gitignore index d5706ae..b9b745e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ *.log .env .env.local +data/last-check.json diff --git a/server/bridge/checker.ts b/server/bridge/checker.ts new file mode 100644 index 0000000..21517d2 --- /dev/null +++ b/server/bridge/checker.ts @@ -0,0 +1,133 @@ +/** + * Python Checker Bridge + * Spawns the Python health checker and reads its JSON output. + */ +import { spawn } from 'child_process' +import fs from 'fs/promises' +import path from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const projectRoot = path.resolve(__dirname, '../..') +const dataDir = path.join(projectRoot, 'data') +const cacheFile = path.join(dataDir, 'last-check.json') +const checkerScript = path.join(projectRoot, 'checker', 'main.py') + +export interface SkillHealthResult { + name: string + path: string + category: string + health_score: number + staleness_level: string + staleness_days: number + status_icon: string + version: string + description: string + scores: { + code_quality: number + git_sync: number + runtime_health: number + activity: number + } + issues: string[] + last_active: string +} + +export interface CheckResult { + timestamp: string + duration_ms: number + total_checked: number + summary: { + healthy: number + warning: number + critical: number + } + skills: SkillHealthResult[] +} + +let runningProcess: ReturnType | null = null + +export async function getCachedResult(): Promise { + try { + const raw = await fs.readFile(cacheFile, 'utf-8') + return JSON.parse(raw) + } catch { + return null + } +} + +export function isRunning(): boolean { + return runningProcess !== null +} + +export async function runCheck( + onProgress?: (msg: string) => void, +): Promise { + if (runningProcess) { + throw new Error('A check is already running') + } + + await fs.mkdir(dataDir, { recursive: true }) + + return new Promise((resolve, reject) => { + const child = spawn('python3', [checkerScript, '--json'], { + cwd: projectRoot, + env: { ...process.env, PYTHONPATH: path.join(projectRoot, 'checker') }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + runningProcess = child + let stdout = '' + let stderr = '' + + child.stdout.on('data', (chunk: Buffer) => { + const text = chunk.toString() + stdout += text + // Forward progress lines (non-JSON) to the callback + for (const line of text.split('\n')) { + if (line.trim() && !line.startsWith('{') && !line.startsWith('[')) { + onProgress?.(line.trim()) + } + } + }) + + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + + child.on('close', async (code) => { + runningProcess = null + + if (code !== 0) { + reject(new Error(`Checker exited with code ${code}: ${stderr}`)) + return + } + + try { + // The JSON output is in data/last-check.json (written by Python) + const result = await getCachedResult() + if (result) { + resolve(result) + } else { + // Fallback: try parsing stdout + const jsonStart = stdout.indexOf('{') + if (jsonStart >= 0) { + const parsed = JSON.parse(stdout.slice(jsonStart)) + await fs.writeFile(cacheFile, JSON.stringify(parsed, null, 2)) + resolve(parsed) + } else { + reject(new Error('No JSON output from checker')) + } + } + } catch (e: any) { + reject(new Error(`Failed to parse checker output: ${e.message}`)) + } + }) + + child.on('error', (err) => { + runningProcess = null + reject(new Error(`Failed to spawn python3: ${err.message}`)) + }) + }) +} diff --git a/server/index.ts b/server/index.ts index 663607f..4feab11 100644 --- a/server/index.ts +++ b/server/index.ts @@ -9,6 +9,7 @@ import { skillRoutes } from './routes/skills.js' import { manageRoutes } from './routes/manage.js' import { versionRoutes } from './routes/versions.js' import { similarityRoutes } from './routes/similarity.js' +import { healthRoutes } from './routes/health.js' import { startWatcher } from './scanner/watcher.js' import { invalidateCache } from './routes/skills.js' import { fullScan } from './scanner/discovery.js' @@ -25,9 +26,10 @@ await app.register(skillRoutes) await app.register(manageRoutes) await app.register(versionRoutes) await app.register(similarityRoutes) +await app.register(healthRoutes) -// Health check -app.get('/api/health', async () => ({ status: 'ok' })) +// Ping check +app.get('/api/ping', async () => ({ status: 'ok' })) // WebSocket for real-time updates const wsClients = new Set() @@ -39,7 +41,7 @@ app.register(async function (fastify) { }) }) -function broadcast(data: any) { +export function broadcast(data: any) { const msg = JSON.stringify(data) for (const ws of wsClients) { if (ws.readyState === 1) { diff --git a/server/routes/health.ts b/server/routes/health.ts new file mode 100644 index 0000000..a9bf856 --- /dev/null +++ b/server/routes/health.ts @@ -0,0 +1,61 @@ +/** + * Health Check API Routes + * Bridges the Python health checker with the Web UI. + */ +import type { FastifyInstance } from 'fastify' +import { getCachedResult, runCheck, isRunning } from '../bridge/checker.js' + +export async function healthRoutes(app: FastifyInstance) { + // Get cached health check results + app.get('/api/health/results', async () => { + const result = await getCachedResult() + if (!result) { + return { ok: false, error: 'No check results available. Run a check first.' } + } + return { ok: true, ...result } + }) + + // Get single skill health detail + app.get<{ + Params: { name: string } + }>('/api/health/skill/:name', async (req, reply) => { + const result = await getCachedResult() + if (!result) { + return reply.status(404).send({ ok: false, error: 'No check results available' }) + } + const skill = result.skills.find((s) => s.name === req.params.name) + if (!skill) { + return reply.status(404).send({ ok: false, error: 'Skill not found in check results' }) + } + return { ok: true, skill } + }) + + // Get check status + app.get('/api/health/status', async () => { + return { running: isRunning() } + }) + + // Trigger a new check run + app.post('/api/health/run', async () => { + if (isRunning()) { + return { ok: false, error: 'A check is already running' } + } + + // Run async — don't await (it can take minutes) + runCheck((msg) => { + // Progress messages could be broadcast via WebSocket in the future + console.log(`[checker] ${msg}`) + }).then((result) => { + console.log( + `[checker] Done: ${result.total_checked} skills, ` + + `${result.summary.healthy} healthy, ` + + `${result.summary.warning} warning, ` + + `${result.summary.critical} critical` + ) + }).catch((err) => { + console.error(`[checker] Failed:`, err.message) + }) + + return { ok: true, message: 'Check started' } + }) +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 15986bf..511766c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,20 +2,23 @@ import { useEffect, useState, useCallback } from 'react' import { useSkills } from './hooks/useSkills' import { useWebSocket } from './hooks/useWebSocket' import { useTheme } from './hooks/useTheme' +import { useHealth } from './hooks/useHealth' import { StatsBar } from './components/StatsBar' import { Sidebar } from './components/Sidebar' import { SkillGrid } from './components/SkillGrid' import { SkillDetail } from './components/SkillDetail' import { Dashboard } from './components/Dashboard' import { SimilarView } from './components/SimilarView' +import { HealthDashboard } from './components/HealthDashboard' import type { Skill } from './hooks/useSkills' type GroupBy = 'none' | 'scope' | 'source' | 'project' -type View = 'skills' | 'similar' | 'dashboard' +type View = 'skills' | 'similar' | 'dashboard' | 'health' function App() { const { allSkills, skills, stats, projects, conflicts, loading, error, scan, filterSkills } = useSkills() const { theme, toggle: toggleTheme } = useTheme() + const { data: healthData, running: healthRunning, loadCached: loadHealth, runCheck } = useHealth() const [view, setView] = useState('skills') const [scopeFilter, setScopeFilter] = useState('all') @@ -102,6 +105,11 @@ function App() { applyFilters({ conflictOnly: next }) } + // Load health data when switching to health view + useEffect(() => { + if (view === 'health') loadHealth() + }, [view, loadHealth]) + // Keyboard shortcut useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -156,6 +164,14 @@ function App() { > 仪表盘 + @@ -248,6 +264,8 @@ function App() { ) : view === 'similar' ? ( + ) : view === 'health' ? ( + ) : ( <> {/* Mobile search */} diff --git a/web/src/components/HealthDashboard.tsx b/web/src/components/HealthDashboard.tsx new file mode 100644 index 0000000..1d97925 --- /dev/null +++ b/web/src/components/HealthDashboard.tsx @@ -0,0 +1,189 @@ +import { useEffect } from 'react' +import type { HealthData, SkillHealth } from '../hooks/useHealth' + +interface Props { + data: HealthData | null + running: boolean + onRunCheck: () => void + onSkillClick?: (name: string) => void +} + +function ScoreBar({ score, max = 25, color }: { score: number; max?: number; color: string }) { + const pct = Math.round((score / max) * 100) + return ( +
+
+
+ ) +} + +function ScoreBadge({ score }: { score: number }) { + const color = score >= 70 ? 'text-green-400 bg-green-500/20' : + score >= 50 ? 'text-amber-400 bg-amber-500/20' : + 'text-red-400 bg-red-500/20' + return ( + + {score} + + ) +} + +function CategoryBadge({ category }: { category: string }) { + const colors: Record = { + content_producer: 'bg-purple-500/20 text-purple-400', + tool: 'bg-cyan-500/20 text-cyan-400', + workflow: 'bg-amber-500/20 text-amber-400', + } + const labels: Record = { + content_producer: '内容', + tool: '工具', + workflow: '工作流', + } + return ( + + {labels[category] || category} + + ) +} + +function StaleIndicator({ level, days }: { level: string; days: number }) { + if (level === 'active') return null + const color = level === 'dormant' ? 'text-red-400' : 'text-amber-400' + return {days}天未更新 +} + +export function HealthDashboard({ data, running, onRunCheck, onSkillClick }: Props) { + if (!data) { + return ( +
+
🏥
+

暂无健康检查数据

+

点击下方按钮运行一次健康检查

+ +
+ ) + } + + const { summary, skills, timestamp, duration_ms, total_checked } = data + + return ( +
+ {/* Summary cards */} +
+
+
{total_checked}
+
已检查
+
+
+
{summary.healthy}
+
健康
+
+
+
{summary.warning}
+
警告
+
+
+
{summary.critical}
+
严重
+
+
+
{(duration_ms / 1000).toFixed(1)}s
+
{new Date(timestamp).toLocaleString('zh-CN')}
+
+
+ + {/* Run button */} +
+ + 上次检查: {new Date(timestamp).toLocaleTimeString('zh-CN')} + + +
+ + {/* Skills table */} +
+ + + + + + + + + + + + + + + {skills.map((skill) => ( + onSkillClick?.(skill.name)} + > + + + + + + + + + + ))} + +
Skill分类评分代码Git运行活跃状态
+
+ {skill.status_icon} +
+
{skill.name}
+ {skill.version && ( + v{skill.version} + )} +
+
+
+ + + + + + + + + + + + +
+ + {skill.issues.length > 0 && ( + + {skill.issues[0]} + + )} +
+
+
+
+ ) +} diff --git a/web/src/hooks/useHealth.ts b/web/src/hooks/useHealth.ts new file mode 100644 index 0000000..33fc561 --- /dev/null +++ b/web/src/hooks/useHealth.ts @@ -0,0 +1,94 @@ +import { useState, useCallback, useRef } from 'react' + +export interface SkillHealth { + name: string + path: string + category: string + health_score: number + staleness_level: string + staleness_days: number + status_icon: string + version: string + description: string + scores: { + code_quality: number + git_sync: number + runtime_health: number + activity: number + } + issues: string[] + last_active: string +} + +export interface HealthData { + timestamp: string + duration_ms: number + total_checked: number + summary: { + healthy: number + warning: number + critical: number + } + skills: SkillHealth[] +} + +export function useHealth() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [running, setRunning] = useState(false) + const pollRef = useRef>() + + const fetchResults = useCallback(async () => { + try { + const res = await fetch('/api/health/results') + const json = await res.json() + if (json.ok !== false) { + setData(json) + setError(null) + } + } catch (e: any) { + setError(e.message) + } + }, []) + + const fetchStatus = useCallback(async () => { + try { + const res = await fetch('/api/health/status') + const json = await res.json() + setRunning(json.running) + return json.running + } catch { + return false + } + }, []) + + const loadCached = useCallback(async () => { + setLoading(true) + await fetchResults() + await fetchStatus() + setLoading(false) + }, [fetchResults, fetchStatus]) + + const runCheck = useCallback(async () => { + setRunning(true) + setError(null) + try { + await fetch('/api/health/run', { method: 'POST' }) + // Poll for completion + pollRef.current = setInterval(async () => { + const stillRunning = await fetchStatus() + if (!stillRunning) { + clearInterval(pollRef.current) + await fetchResults() + setRunning(false) + } + }, 3000) + } catch (e: any) { + setError(e.message) + setRunning(false) + } + }, [fetchResults, fetchStatus]) + + return { data, loading, error, running, loadCached, runCheck } +}