bookworm-smart-assistant/scripts/dashboard.js

539 lines
19 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* Bookworm Smart Assistant - 指标仪表盘
*
* 用法:
* node scripts/dashboard.js # 今日概览
* node scripts/dashboard.js --date 2026-02-20 # 指定日期
* node scripts/dashboard.js --range 7 # 最近 7 天汇总
* node scripts/dashboard.js --json # JSON 输出 (供其他脚本消费)
*
* 数据源:
* debug/activity-YYYY-MM-DD.jsonl - 活动日志
* debug/security-YYYY-MM-DD.jsonl - 安全事件日志
* projects/{proj}/memory/evolution-log.jsonl - 进化日志
*/
const fs = require('fs');
const path = require('path');
// 动态检测配置根目录
const detectClaudeRoot = () => require('./paths.config.js').PATHS.root;
const CLAUDE_ROOT = detectClaudeRoot();
const DEBUG_DIR = path.join(CLAUDE_ROOT, 'debug');
// === 参数解析 ===
const args = process.argv.slice(2);
const jsonMode = args.includes('--json');
const dateIdx = args.indexOf('--date');
const rangeIdx = args.indexOf('--range');
const today = new Date().toISOString().slice(0, 10);
let targetDates = [today];
if (dateIdx >= 0 && args[dateIdx + 1]) {
targetDates = [args[dateIdx + 1]];
} else if (rangeIdx >= 0) {
const days = parseInt(args[rangeIdx + 1]) || 7;
targetDates = [];
for (let i = 0; i < days; i++) {
const d = new Date();
d.setDate(d.getDate() - i);
targetDates.push(d.toISOString().slice(0, 10));
}
}
// === 数据加载 ===
function loadJsonl(filePath) {
if (!fs.existsSync(filePath)) return [];
const lines = fs.readFileSync(filePath, 'utf8').trim().split('\n');
const entries = [];
for (const line of lines) {
if (!line) continue;
try { entries.push(JSON.parse(line)); } catch {}
}
return entries;
}
function loadActivityLogs(dates) {
const all = [];
for (const d of dates) {
all.push(...loadJsonl(path.join(DEBUG_DIR, `activity-${d}.jsonl`)));
}
return all;
}
function loadSecurityLogs(dates) {
const all = [];
for (const d of dates) {
all.push(...loadJsonl(path.join(DEBUG_DIR, `security-${d}.jsonl`)));
}
return all;
}
function loadEvolutionLog() {
// 搜索所有 projects 目录下的 evolution-log.jsonl
const projectsDir = path.join(CLAUDE_ROOT, 'projects');
if (!fs.existsSync(projectsDir)) return [];
const entries = [];
try {
for (const proj of fs.readdirSync(projectsDir)) {
const elog = path.join(projectsDir, proj, 'memory', 'evolution-log.jsonl');
if (fs.existsSync(elog)) {
entries.push(...loadJsonl(elog));
}
}
} catch {}
return entries;
}
// === Phase 3 数据加载 ===
function loadDetectionStats() {
const fp = path.join(DEBUG_DIR, 'detection-stats.json');
if (!fs.existsSync(fp)) return null;
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
}
function loadSkillCorrelation() {
const fp = path.join(DEBUG_DIR, 'skill-outcome-correlation.json');
if (!fs.existsSync(fp)) return null;
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
}
function loadOutcomeAggregation() {
const fp = path.join(DEBUG_DIR, 'outcome-aggregation.json');
if (!fs.existsSync(fp)) return null;
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
}
function loadTraceLogs(dates) {
const all = [];
for (const d of dates) {
all.push(...loadJsonl(path.join(DEBUG_DIR, `trace-${d}.jsonl`)));
}
return all;
}
function loadRemediationLog() {
return loadJsonl(path.join(DEBUG_DIR, 'remediation-log.jsonl'));
}
// === Phase 3 统计计算 ===
function computePhase3Stats(detectionStats, skillCorrelation, outcomeAgg, traceLogs, remediations) {
const result = {
detection: { trend: 'insufficient', todayCount: 0, topRules: [], topExtensions: [] },
skills: { entries: [], bestSkill: null, worstSkill: null },
builds: { totalCommands: 0, overallSuccessRate: 0, topFailures: [] },
traces: { totalEvents: 0, byHook: {}, byEventType: {} },
remediations: { total: 0, successful: 0, recent: [] },
};
// --- Detection ---
if (detectionStats) {
const ds = detectionStats;
result.detection.trend = ds.trend || 'insufficient';
result.detection.todayCount = ds.todayCount || ds.total || 0;
if (Array.isArray(ds.byRule)) {
result.detection.topRules = ds.byRule
.sort((a, b) => (b.total || b.count || 0) - (a.total || a.count || 0))
.slice(0, 5)
.map(r => ({ id: r.id || r.rule, total: r.total || r.count || 0, severity: r.severity || 'unknown' }));
}
if (Array.isArray(ds.byExtension)) {
result.detection.topExtensions = ds.byExtension
.sort((a, b) => (b.count || 0) - (a.count || 0))
.slice(0, 5)
.map(e => ({ ext: e.ext || e.extension, count: e.count || 0 }));
}
}
// --- Skills ---
if (skillCorrelation) {
const sc = skillCorrelation;
const entries = [];
const skills = sc.skills || sc.correlations || sc;
if (typeof skills === 'object' && !Array.isArray(skills)) {
for (const [name, data] of Object.entries(skills)) {
if (!data || typeof data !== 'object') continue;
const total = data.total || (data.success || 0) + (data.failure || 0) || 0;
const successRate = total > 0 ? Math.round(((data.success || 0) / total) * 100) : 0;
entries.push({ name, total, successRate, trend: data.trend || 'stable' });
}
} else if (Array.isArray(skills)) {
for (const s of skills) {
const total = s.total || (s.success || 0) + (s.failure || 0) || 0;
const successRate = total > 0 ? Math.round(((s.success || 0) / total) * 100) : 0;
entries.push({ name: s.name || s.skill, total, successRate, trend: s.trend || 'stable' });
}
}
entries.sort((a, b) => b.total - a.total);
result.skills.entries = entries;
if (entries.length > 0) {
const sorted = [...entries].filter(e => e.total > 0).sort((a, b) => b.successRate - a.successRate);
result.skills.bestSkill = sorted[0]?.name || null;
result.skills.worstSkill = sorted[sorted.length - 1]?.name || null;
}
}
// --- Builds ---
if (outcomeAgg) {
const oa = outcomeAgg;
const commands = oa.commands || oa.outcomes || {};
let totalCmd = 0, totalSuccess = 0;
const failures = [];
if (typeof commands === 'object' && !Array.isArray(commands)) {
for (const [cmd, data] of Object.entries(commands)) {
if (!data || typeof data !== 'object') continue;
const t = data.total || (data.success || 0) + (data.failure || 0) || 0;
const f = data.failure || 0;
totalCmd += t;
totalSuccess += (data.success || 0);
if (f > 0 && t > 0) {
failures.push({ command: cmd, failureRate: Math.round((f / t) * 100), total: t });
}
}
}
result.builds.totalCommands = totalCmd;
result.builds.overallSuccessRate = totalCmd > 0 ? Math.round((totalSuccess / totalCmd) * 100) : 0;
result.builds.topFailures = failures.sort((a, b) => b.failureRate - a.failureRate).slice(0, 5);
}
// --- Traces ---
if (Array.isArray(traceLogs) && traceLogs.length > 0) {
result.traces.totalEvents = traceLogs.length;
for (const t of traceLogs) {
const hook = t.hook || t.hookName || 'unknown';
result.traces.byHook[hook] = (result.traces.byHook[hook] || 0) + 1;
const evType = t.eventType || t.event || t.type || 'unknown';
result.traces.byEventType[evType] = (result.traces.byEventType[evType] || 0) + 1;
}
}
// --- Remediations ---
if (Array.isArray(remediations) && remediations.length > 0) {
result.remediations.total = remediations.length;
result.remediations.successful = remediations.filter(r => r.success || r.result === 'success').length;
result.remediations.recent = remediations
.slice(-5)
.reverse()
.map(r => ({
ts: r.ts || r.timestamp || '',
dimensionId: r.dimensionId || r.dimension || r.id || '',
action: r.action || r.description || '',
success: !!(r.success || r.result === 'success'),
improved: r.improved != null ? r.improved : null,
}));
}
return result;
}
// === 统计计算 ===
function computeStats(activities, securities) {
const stats = {
total: activities.length,
byEvent: {},
topSkills: {},
topMcps: {},
topAgents: {},
bashCount: 0,
writeCount: 0,
security: { deny: 0, ask: 0, byHook: {} },
};
for (const a of activities) {
// 按事件类型
stats.byEvent[a.event] = (stats.byEvent[a.event] || 0) + 1;
// 技能排名
if (a.event === 'skill' && a.detail) {
stats.topSkills[a.detail] = (stats.topSkills[a.detail] || 0) + 1;
}
// MCP 排名
if (a.event === 'mcp' && a.detail) {
stats.topMcps[a.detail] = (stats.topMcps[a.detail] || 0) + 1;
}
// Agent 排名
if (a.event === 'agent' && a.detail) {
stats.topAgents[a.detail] = (stats.topAgents[a.detail] || 0) + 1;
}
}
stats.bashCount = stats.byEvent.bash || 0;
stats.writeCount = stats.byEvent.write || 0;
// 安全事件
for (const s of securities) {
if (s.decision === 'deny') stats.security.deny++;
else if (s.decision === 'ask') stats.security.ask++;
const hook = s.hook || 'unknown';
if (!stats.security.byHook[hook]) {
stats.security.byHook[hook] = { deny: 0, ask: 0 };
}
stats.security.byHook[hook][s.decision === 'deny' ? 'deny' : 'ask']++;
}
return stats;
}
// === 磁盘统计 ===
function getDiskStats() {
const result = { totalMB: 0, debugMB: 0, activityFiles: 0, securityFiles: 0 };
try {
if (!fs.existsSync(DEBUG_DIR)) return result;
let totalBytes = 0;
for (const f of fs.readdirSync(DEBUG_DIR)) {
const fp = path.join(DEBUG_DIR, f);
const st = fs.statSync(fp);
if (st.isFile()) {
totalBytes += st.size;
if (f.startsWith('activity-')) result.activityFiles++;
if (f.startsWith('security-')) result.securityFiles++;
}
}
result.debugMB = Math.round(totalBytes / 1024 / 1024 * 10) / 10;
// 整个 .claude 目录大小 (只统计一层深度的子目录)
let claudeBytes = 0;
for (const sub of fs.readdirSync(CLAUDE_ROOT)) {
const subPath = path.join(CLAUDE_ROOT, sub);
try {
const st = fs.statSync(subPath);
if (st.isFile()) {
claudeBytes += st.size;
} else if (st.isDirectory()) {
// 粗略统计子目录
for (const f2 of fs.readdirSync(subPath)) {
const f2Path = path.join(subPath, f2);
try {
const st2 = fs.statSync(f2Path);
if (st2.isFile()) claudeBytes += st2.size;
} catch {}
}
}
} catch {}
}
result.totalMB = Math.round(claudeBytes / 1024 / 1024 * 10) / 10;
} catch {}
return result;
}
// === CLI 渲染 ===
function bar(count, max, width = 20) {
const filled = max > 0 ? Math.round((count / max) * width) : 0;
return '\u2588'.repeat(filled) + '\u2591'.repeat(width - filled);
}
function topN(obj, n = 5) {
return Object.entries(obj)
.sort((a, b) => b[1] - a[1])
.slice(0, n);
}
function renderPhase3(p3) {
const W = 60;
const line = '\u2500'.repeat(W);
console.log('\u251C' + line + '\u2524');
console.log('\u2502' + ' Phase 3 Intelligence'.padEnd(W) + '\u2502');
console.log('\u2502' + ''.padEnd(W) + '\u2502');
// Detection Trend
const trendArrow = p3.detection.trend === 'improving' ? '\u2193' : p3.detection.trend === 'worsening' ? '\u2191' : '\u2192';
console.log('\u2502' + ` Detection Trend: ${p3.detection.trend} ${trendArrow} (Today: ${p3.detection.todayCount})`.padEnd(W) + '\u2502');
if (p3.detection.topRules.length > 0) {
const rulesStr = p3.detection.topRules.map(r => `${r.id}(${r.total})`).join(' ');
console.log('\u2502' + ` Top Rules: ${rulesStr}`.slice(0, W).padEnd(W) + '\u2502');
}
if (p3.detection.topExtensions.length > 0) {
const extStr = p3.detection.topExtensions.map(e => `${e.ext}(${e.count})`).join(' ');
console.log('\u2502' + ` Top Files: ${extStr}`.slice(0, W).padEnd(W) + '\u2502');
}
console.log('\u2502' + ''.padEnd(W) + '\u2502');
// Skill Outcomes
if (p3.skills.entries.length > 0) {
console.log('\u2502' + ' Skill Outcomes:'.padEnd(W) + '\u2502');
for (const [i, s] of p3.skills.entries.slice(0, 5).entries()) {
const filled = Math.round(s.successRate / 10);
const skillBar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
const row = ` ${(i + 1)}. ${s.name.padEnd(18)} ${skillBar} ${s.successRate}% (${s.total}) ${s.trend}`;
console.log('\u2502' + row.slice(0, W).padEnd(W) + '\u2502');
}
console.log('\u2502' + ''.padEnd(W) + '\u2502');
}
// Build Success
if (p3.builds.totalCommands > 0) {
const succCount = Math.round(p3.builds.totalCommands * p3.builds.overallSuccessRate / 100);
console.log('\u2502' + ` Build Success: ${p3.builds.overallSuccessRate}% (${succCount}/${p3.builds.totalCommands})`.padEnd(W) + '\u2502');
if (p3.builds.topFailures.length > 0) {
const failStr = p3.builds.topFailures.map(f => `${f.command}(${f.failureRate}%)`).join(' ');
console.log('\u2502' + ` Top Failures: ${failStr}`.slice(0, W).padEnd(W) + '\u2502');
}
console.log('\u2502' + ''.padEnd(W) + '\u2502');
}
// Trace Events
if (p3.traces.totalEvents > 0) {
const hookCount = Object.keys(p3.traces.byHook).length;
console.log('\u2502' + ` Trace Events: ${p3.traces.totalEvents} (${hookCount} hooks)`.padEnd(W) + '\u2502');
const evtStr = Object.entries(p3.traces.byEventType).map(([k, v]) => `${k}: ${v}`).join(' ');
console.log('\u2502' + ` ${evtStr}`.slice(0, W).padEnd(W) + '\u2502');
console.log('\u2502' + ''.padEnd(W) + '\u2502');
}
// Auto-Remediation
if (p3.remediations.total > 0) {
console.log('\u2502' + ` Auto-Remediation: ${p3.remediations.successful}/${p3.remediations.total} successful`.padEnd(W) + '\u2502');
for (const r of p3.remediations.recent) {
const icon = r.success ? '[+]' : '[X]';
const imp = r.improved != null ? ` (${r.improved})` : '';
const desc = ` ${icon} ${r.dimensionId} ${r.action}${imp}`;
console.log('\u2502' + desc.slice(0, W).padEnd(W) + '\u2502');
}
}
}
function renderCli(stats, disk, evoLog, dates, phase3Stats) {
const W = 60;
const line = '\u2500'.repeat(W);
const dateLabel = dates.length === 1 ? dates[0] : `${dates[dates.length - 1]} ~ ${dates[0]}`;
console.log();
console.log('\u250C' + '\u2500'.repeat(W) + '\u2510');
console.log('\u2502' + ` Bookworm Dashboard \u2014 ${dateLabel}`.padEnd(W) + '\u2502');
console.log('\u251C' + line + '\u2524');
// 事件总览
const skillCount = stats.byEvent.skill || 0;
const agentCount = stats.byEvent.agent || 0;
const mcpCount = stats.byEvent.mcp || 0;
const row1 = ` Events: ${stats.total}`.padEnd(20)
+ `Skills: ${skillCount}`.padEnd(14)
+ `Agents: ${agentCount}`.padEnd(14)
+ `MCP: ${mcpCount}`;
const row2 = ` `.padEnd(20)
+ `Bash: ${stats.bashCount}`.padEnd(14)
+ `Write: ${stats.writeCount}`.padEnd(14);
console.log('\u2502' + row1.padEnd(W) + '\u2502');
console.log('\u2502' + row2.padEnd(W) + '\u2502');
// TOP 5 Skills
const top5 = topN(stats.topSkills, 5);
if (top5.length > 0) {
console.log('\u251C' + line + '\u2524');
console.log('\u2502' + ' TOP 5 Skills:'.padEnd(W) + '\u2502');
const maxVal = top5[0]?.[1] || 1;
for (const [i, [name, count]] of top5.entries()) {
const b = bar(count, maxVal, 16);
const row = ` ${i + 1}. ${name.padEnd(28)} ${b} ${count}`;
console.log('\u2502' + row.padEnd(W) + '\u2502');
}
}
// 安全事件
console.log('\u251C' + line + '\u2524');
const secTotal = stats.security.deny + stats.security.ask;
console.log('\u2502' + ` Security Events: ${secTotal} (${stats.security.deny} deny / ${stats.security.ask} ask)`.padEnd(W) + '\u2502');
for (const [hook, counts] of Object.entries(stats.security.byHook)) {
const shortHook = hook.replace('block-', '').slice(0, 20);
const row = ` ${shortHook}: ${counts.deny} deny / ${counts.ask} ask`;
console.log('\u2502' + row.padEnd(W) + '\u2502');
}
// 磁盘健康
console.log('\u251C' + line + '\u2524');
const diskStatus = disk.totalMB > 4096 ? 'CRITICAL' : disk.totalMB > 2048 ? 'WARNING' : 'GOOD';
const diskRow = ` Disk: ${disk.debugMB} MB (debug/)`.padEnd(30)
+ `Health: ${diskStatus}`;
console.log('\u2502' + diskRow.padEnd(W) + '\u2502');
console.log('\u2502' + ` Log files: ${disk.activityFiles} activity + ${disk.securityFiles} security`.padEnd(W) + '\u2502');
// 进化日志摘要
if (evoLog.length > 0) {
console.log('\u251C' + line + '\u2524');
const latest = evoLog[evoLog.length - 1];
console.log('\u2502' + ` Evolution: ${evoLog.length} entries, latest ${latest.version} (${latest.ts})`.padEnd(W) + '\u2502');
// 按版本统计修复数
const versionFixes = {};
for (const e of evoLog) {
versionFixes[e.version] = (versionFixes[e.version] || 0) + (e.fix_count || 0);
}
const versionRow = Object.entries(versionFixes)
.map(([v, c]) => `${v}:${c}`)
.join(' ');
console.log('\u2502' + ` Fixes by version: ${versionRow}`.padEnd(W) + '\u2502');
}
// Phase 3 Intelligence
if (phase3Stats) {
renderPhase3(phase3Stats);
}
console.log('\u2514' + '\u2500'.repeat(W) + '\u2518');
console.log();
}
// === 主流程 ===
function main() {
const activities = loadActivityLogs(targetDates);
const securities = loadSecurityLogs(targetDates);
const stats = computeStats(activities, securities);
const disk = getDiskStats();
const evoLog = loadEvolutionLog();
// Phase 3 数据
const detectionStats = loadDetectionStats();
const skillCorrelation = loadSkillCorrelation();
const outcomeAgg = loadOutcomeAggregation();
const traceLogs = loadTraceLogs(targetDates);
const remediations = loadRemediationLog();
const phase3Stats = computePhase3Stats(detectionStats, skillCorrelation, outcomeAgg, traceLogs, remediations);
if (jsonMode) {
console.log(JSON.stringify({
dates: targetDates,
stats,
disk,
evolution: {
total: evoLog.length,
latestVersion: evoLog[evoLog.length - 1]?.version || 'unknown',
},
phase3: phase3Stats,
}, null, 2));
} else {
renderCli(stats, disk, evoLog, targetDates, phase3Stats);
}
}
// 模块导出 (供测试使用)
if (typeof module !== 'undefined') {
module.exports = {
detectClaudeRoot,
loadJsonl,
loadActivityLogs,
loadSecurityLogs,
loadEvolutionLog,
loadDetectionStats,
loadSkillCorrelation,
loadOutcomeAggregation,
loadTraceLogs,
loadRemediationLog,
computeStats,
computePhase3Stats,
getDiskStats,
bar,
topN,
renderPhase3,
renderCli,
main,
};
}
if (require.main === module) {
main();
}