Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ dist/
*.log
.env
.env.local
data/last-check.json
133 changes: 133 additions & 0 deletions server/bridge/checker.ts
Original file line number Diff line number Diff line change
@@ -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<typeof spawn> | null = null

export async function getCachedResult(): Promise<CheckResult | null> {
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<CheckResult> {
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}`))
})
})
}
8 changes: 5 additions & 3 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<WebSocket>()
Expand All @@ -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) {
Expand Down
61 changes: 61 additions & 0 deletions server/routes/health.ts
Original file line number Diff line number Diff line change
@@ -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' }
})
}
20 changes: 19 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<View>('skills')
const [scopeFilter, setScopeFilter] = useState('all')
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -156,6 +164,14 @@ function App() {
>
仪表盘
</button>
<button
onClick={() => setView('health')}
className={`px-3 py-1 rounded-md text-xs transition-all ${
view === 'health' ? 'bg-slate-700 text-slate-200 shadow-sm' : 'text-slate-500 hover:text-slate-300'
}`}
>
健康
</button>
</div>
</div>

Expand Down Expand Up @@ -248,6 +264,8 @@ function App() {
<Dashboard stats={stats} projects={projects} conflicts={conflicts} skills={allSkills} />
) : view === 'similar' ? (
<SimilarView onSkillClick={setSelectedSkill} />
) : view === 'health' ? (
<HealthDashboard data={healthData} running={healthRunning} onRunCheck={runCheck} />
) : (
<>
{/* Mobile search */}
Expand Down
Loading