mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
feat(design): skill health dashboard mockup (#518)
* feat(Design): skill health dashboard mockup * fix(comments): code according to comments
This commit is contained in:
401
scripts/lib/skill-evolution/dashboard.js
Normal file
401
scripts/lib/skill-evolution/dashboard.js
Normal file
@@ -0,0 +1,401 @@
|
||||
'use strict';
|
||||
|
||||
const health = require('./health');
|
||||
const tracker = require('./tracker');
|
||||
const versioning = require('./versioning');
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
const SPARKLINE_CHARS = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';
|
||||
const EMPTY_BLOCK = '\u2591';
|
||||
const FILL_BLOCK = '\u2588';
|
||||
const DEFAULT_PANEL_WIDTH = 64;
|
||||
const VALID_PANELS = new Set(['success-rate', 'failures', 'amendments', 'versions']);
|
||||
|
||||
function sparkline(values) {
|
||||
if (!Array.isArray(values) || values.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return values.map(value => {
|
||||
if (value === null || value === undefined) {
|
||||
return EMPTY_BLOCK;
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(1, value));
|
||||
const index = Math.min(Math.round(clamped * (SPARKLINE_CHARS.length - 1)), SPARKLINE_CHARS.length - 1);
|
||||
return SPARKLINE_CHARS[index];
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function horizontalBar(value, max, width) {
|
||||
if (max <= 0 || width <= 0) {
|
||||
return EMPTY_BLOCK.repeat(width || 0);
|
||||
}
|
||||
|
||||
const filled = Math.round((Math.min(value, max) / max) * width);
|
||||
const empty = width - filled;
|
||||
return FILL_BLOCK.repeat(filled) + EMPTY_BLOCK.repeat(empty);
|
||||
}
|
||||
|
||||
function panelBox(title, lines, width) {
|
||||
const innerWidth = width || DEFAULT_PANEL_WIDTH;
|
||||
const output = [];
|
||||
output.push('\u250C\u2500 ' + title + ' ' + '\u2500'.repeat(Math.max(0, innerWidth - title.length - 4)) + '\u2510');
|
||||
|
||||
for (const line of lines) {
|
||||
const truncated = line.length > innerWidth - 2
|
||||
? line.slice(0, innerWidth - 2)
|
||||
: line;
|
||||
output.push('\u2502 ' + truncated.padEnd(innerWidth - 2) + '\u2502');
|
||||
}
|
||||
|
||||
output.push('\u2514' + '\u2500'.repeat(innerWidth - 1) + '\u2518');
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
function bucketByDay(records, nowMs, days) {
|
||||
const buckets = [];
|
||||
for (let i = days - 1; i >= 0; i -= 1) {
|
||||
const dayEnd = nowMs - (i * DAY_IN_MS);
|
||||
const dayStart = dayEnd - DAY_IN_MS;
|
||||
const dateStr = new Date(dayEnd).toISOString().slice(0, 10);
|
||||
buckets.push({ date: dateStr, start: dayStart, end: dayEnd, records: [] });
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
const recordMs = Date.parse(record.recorded_at);
|
||||
if (Number.isNaN(recordMs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const bucket of buckets) {
|
||||
if (recordMs > bucket.start && recordMs <= bucket.end) {
|
||||
bucket.records.push(record);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buckets.map(bucket => ({
|
||||
date: bucket.date,
|
||||
rate: bucket.records.length > 0
|
||||
? health.calculateSuccessRate(bucket.records)
|
||||
: null,
|
||||
runs: bucket.records.length,
|
||||
}));
|
||||
}
|
||||
|
||||
function getTrendArrow(successRate7d, successRate30d) {
|
||||
if (successRate7d === null || successRate30d === null) {
|
||||
return '\u2192';
|
||||
}
|
||||
|
||||
const delta = successRate7d - successRate30d;
|
||||
if (delta >= 0.1) {
|
||||
return '\u2197';
|
||||
}
|
||||
|
||||
if (delta <= -0.1) {
|
||||
return '\u2198';
|
||||
}
|
||||
|
||||
return '\u2192';
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
if (value === null) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function groupRecordsBySkill(records) {
|
||||
return records.reduce((grouped, record) => {
|
||||
const skillId = record.skill_id;
|
||||
if (!grouped.has(skillId)) {
|
||||
grouped.set(skillId, []);
|
||||
}
|
||||
|
||||
grouped.get(skillId).push(record);
|
||||
return grouped;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
function renderSuccessRatePanel(records, skills, options = {}) {
|
||||
const nowMs = Date.parse(options.now || new Date().toISOString());
|
||||
const days = options.days || 30;
|
||||
const width = options.width || DEFAULT_PANEL_WIDTH;
|
||||
const recordsBySkill = groupRecordsBySkill(records);
|
||||
|
||||
const skillData = [];
|
||||
const skillIds = Array.from(new Set([
|
||||
...Array.from(recordsBySkill.keys()),
|
||||
...skills.map(s => s.skill_id),
|
||||
])).sort();
|
||||
|
||||
for (const skillId of skillIds) {
|
||||
const skillRecords = recordsBySkill.get(skillId) || [];
|
||||
const dailyRates = bucketByDay(skillRecords, nowMs, days);
|
||||
const rateValues = dailyRates.map(b => b.rate);
|
||||
const records7d = health.filterRecordsWithinDays(skillRecords, nowMs, 7);
|
||||
const records30d = health.filterRecordsWithinDays(skillRecords, nowMs, 30);
|
||||
const current7d = health.calculateSuccessRate(records7d);
|
||||
const current30d = health.calculateSuccessRate(records30d);
|
||||
const trend = getTrendArrow(current7d, current30d);
|
||||
|
||||
skillData.push({
|
||||
skill_id: skillId,
|
||||
daily_rates: dailyRates,
|
||||
sparkline: sparkline(rateValues),
|
||||
current_7d: current7d,
|
||||
trend,
|
||||
});
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
if (skillData.length === 0) {
|
||||
lines.push('No skill execution data available.');
|
||||
} else {
|
||||
for (const skill of skillData) {
|
||||
const nameCol = skill.skill_id.slice(0, 14).padEnd(14);
|
||||
const sparkCol = skill.sparkline.slice(0, 30);
|
||||
const rateCol = formatPercent(skill.current_7d).padStart(5);
|
||||
lines.push(`${nameCol} ${sparkCol} ${rateCol} ${skill.trend}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: panelBox('Success Rate (30d)', lines, width),
|
||||
data: { skills: skillData },
|
||||
};
|
||||
}
|
||||
|
||||
function renderFailureClusterPanel(records, options = {}) {
|
||||
const width = options.width || DEFAULT_PANEL_WIDTH;
|
||||
const failures = records.filter(r => r.outcome === 'failure');
|
||||
|
||||
const clusterMap = new Map();
|
||||
for (const record of failures) {
|
||||
const reason = (record.failure_reason || 'unknown').toLowerCase().trim();
|
||||
if (!clusterMap.has(reason)) {
|
||||
clusterMap.set(reason, { count: 0, skill_ids: new Set() });
|
||||
}
|
||||
|
||||
const cluster = clusterMap.get(reason);
|
||||
cluster.count += 1;
|
||||
cluster.skill_ids.add(record.skill_id);
|
||||
}
|
||||
|
||||
const clusters = Array.from(clusterMap.entries())
|
||||
.map(([pattern, data]) => ({
|
||||
pattern,
|
||||
count: data.count,
|
||||
skill_ids: Array.from(data.skill_ids).sort(),
|
||||
percentage: failures.length > 0
|
||||
? Math.round((data.count / failures.length) * 100)
|
||||
: 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count || a.pattern.localeCompare(b.pattern));
|
||||
|
||||
const maxCount = clusters.length > 0 ? clusters[0].count : 0;
|
||||
const lines = [];
|
||||
|
||||
if (clusters.length === 0) {
|
||||
lines.push('No failure patterns detected.');
|
||||
} else {
|
||||
for (const cluster of clusters) {
|
||||
const label = cluster.pattern.slice(0, 20).padEnd(20);
|
||||
const bar = horizontalBar(cluster.count, maxCount, 16);
|
||||
const skillCount = cluster.skill_ids.length;
|
||||
const suffix = skillCount === 1 ? 'skill' : 'skills';
|
||||
lines.push(`${label} ${bar} ${String(cluster.count).padStart(3)} (${skillCount} ${suffix})`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: panelBox('Failure Patterns', lines, width),
|
||||
data: { clusters, total_failures: failures.length },
|
||||
};
|
||||
}
|
||||
|
||||
function renderAmendmentPanel(skillsById, options = {}) {
|
||||
const width = options.width || DEFAULT_PANEL_WIDTH;
|
||||
const amendments = [];
|
||||
|
||||
for (const [skillId, skill] of skillsById) {
|
||||
if (!skill.skill_dir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const log = versioning.getEvolutionLog(skill.skill_dir, 'amendments');
|
||||
for (const entry of log) {
|
||||
const status = typeof entry.status === 'string' ? entry.status : null;
|
||||
const isPending = status
|
||||
? health.PENDING_AMENDMENT_STATUSES.has(status)
|
||||
: entry.event === 'proposal';
|
||||
|
||||
if (isPending) {
|
||||
amendments.push({
|
||||
skill_id: skillId,
|
||||
event: entry.event || 'proposal',
|
||||
status: status || 'pending',
|
||||
created_at: entry.created_at || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
amendments.sort((a, b) => {
|
||||
const timeA = a.created_at ? Date.parse(a.created_at) : 0;
|
||||
const timeB = b.created_at ? Date.parse(b.created_at) : 0;
|
||||
return timeB - timeA;
|
||||
});
|
||||
|
||||
const lines = [];
|
||||
if (amendments.length === 0) {
|
||||
lines.push('No pending amendments.');
|
||||
} else {
|
||||
for (const amendment of amendments) {
|
||||
const name = amendment.skill_id.slice(0, 14).padEnd(14);
|
||||
const event = amendment.event.padEnd(10);
|
||||
const status = amendment.status.padEnd(10);
|
||||
const time = amendment.created_at ? amendment.created_at.slice(0, 19) : '-';
|
||||
lines.push(`${name} ${event} ${status} ${time}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(`${amendments.length} amendment${amendments.length === 1 ? '' : 's'} pending review`);
|
||||
}
|
||||
|
||||
return {
|
||||
text: panelBox('Pending Amendments', lines, width),
|
||||
data: { amendments, total: amendments.length },
|
||||
};
|
||||
}
|
||||
|
||||
function renderVersionTimelinePanel(skillsById, options = {}) {
|
||||
const width = options.width || DEFAULT_PANEL_WIDTH;
|
||||
const skillVersions = [];
|
||||
|
||||
for (const [skillId, skill] of skillsById) {
|
||||
if (!skill.skill_dir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const versions = versioning.listVersions(skill.skill_dir);
|
||||
if (versions.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const amendmentLog = versioning.getEvolutionLog(skill.skill_dir, 'amendments');
|
||||
const reasonByVersion = new Map();
|
||||
for (const entry of amendmentLog) {
|
||||
if (entry.version && entry.reason) {
|
||||
reasonByVersion.set(entry.version, entry.reason);
|
||||
}
|
||||
}
|
||||
|
||||
skillVersions.push({
|
||||
skill_id: skillId,
|
||||
versions: versions.map(v => ({
|
||||
version: v.version,
|
||||
created_at: v.created_at,
|
||||
reason: reasonByVersion.get(v.version) || null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
skillVersions.sort((a, b) => a.skill_id.localeCompare(b.skill_id));
|
||||
|
||||
const lines = [];
|
||||
if (skillVersions.length === 0) {
|
||||
lines.push('No version history available.');
|
||||
} else {
|
||||
for (const skill of skillVersions) {
|
||||
lines.push(skill.skill_id);
|
||||
for (const version of skill.versions) {
|
||||
const date = version.created_at ? version.created_at.slice(0, 10) : '-';
|
||||
const reason = version.reason || '-';
|
||||
lines.push(` v${version.version} \u2500\u2500 ${date} \u2500\u2500 ${reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: panelBox('Version History', lines, width),
|
||||
data: { skills: skillVersions },
|
||||
};
|
||||
}
|
||||
|
||||
function renderDashboard(options = {}) {
|
||||
const now = options.now || new Date().toISOString();
|
||||
const nowMs = Date.parse(now);
|
||||
if (Number.isNaN(nowMs)) {
|
||||
throw new Error(`Invalid now timestamp: ${now}`);
|
||||
}
|
||||
|
||||
const dashboardOptions = { ...options, now };
|
||||
const records = tracker.readSkillExecutionRecords(dashboardOptions);
|
||||
const skillsById = health.discoverSkills(dashboardOptions);
|
||||
const report = health.collectSkillHealth(dashboardOptions);
|
||||
const summary = health.summarizeHealthReport(report);
|
||||
|
||||
const panelRenderers = {
|
||||
'success-rate': () => renderSuccessRatePanel(records, report.skills, dashboardOptions),
|
||||
'failures': () => renderFailureClusterPanel(records, dashboardOptions),
|
||||
'amendments': () => renderAmendmentPanel(skillsById, dashboardOptions),
|
||||
'versions': () => renderVersionTimelinePanel(skillsById, dashboardOptions),
|
||||
};
|
||||
|
||||
const selectedPanel = options.panel || null;
|
||||
if (selectedPanel && !VALID_PANELS.has(selectedPanel)) {
|
||||
throw new Error(`Unknown panel: ${selectedPanel}. Valid panels: ${Array.from(VALID_PANELS).join(', ')}`);
|
||||
}
|
||||
|
||||
const panels = {};
|
||||
const textParts = [];
|
||||
|
||||
const header = [
|
||||
'ECC Skill Health Dashboard',
|
||||
`Generated: ${now}`,
|
||||
`Skills: ${summary.total_skills} total, ${summary.healthy_skills} healthy, ${summary.declining_skills} declining`,
|
||||
'',
|
||||
];
|
||||
|
||||
textParts.push(header.join('\n'));
|
||||
|
||||
if (selectedPanel) {
|
||||
const result = panelRenderers[selectedPanel]();
|
||||
panels[selectedPanel] = result.data;
|
||||
textParts.push(result.text);
|
||||
} else {
|
||||
for (const [panelName, renderer] of Object.entries(panelRenderers)) {
|
||||
const result = renderer();
|
||||
panels[panelName] = result.data;
|
||||
textParts.push(result.text);
|
||||
}
|
||||
}
|
||||
|
||||
const text = textParts.join('\n\n') + '\n';
|
||||
const data = {
|
||||
generated_at: now,
|
||||
summary,
|
||||
panels,
|
||||
};
|
||||
|
||||
return { text, data };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
VALID_PANELS,
|
||||
bucketByDay,
|
||||
horizontalBar,
|
||||
panelBox,
|
||||
renderAmendmentPanel,
|
||||
renderDashboard,
|
||||
renderFailureClusterPanel,
|
||||
renderSuccessRatePanel,
|
||||
renderVersionTimelinePanel,
|
||||
sparkline,
|
||||
};
|
||||
@@ -8,7 +8,7 @@ const tracker = require('./tracker');
|
||||
const versioning = require('./versioning');
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
const PENDING_AMENDMENT_STATUSES = new Set(['pending', 'proposed', 'queued', 'open']);
|
||||
const PENDING_AMENDMENT_STATUSES = Object.freeze(new Set(['pending', 'proposed', 'queued', 'open']));
|
||||
|
||||
function roundRate(value) {
|
||||
if (value === null) {
|
||||
@@ -253,8 +253,11 @@ function formatHealthReport(report, options = {}) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PENDING_AMENDMENT_STATUSES,
|
||||
calculateSuccessRate,
|
||||
collectSkillHealth,
|
||||
discoverSkills,
|
||||
filterRecordsWithinDays,
|
||||
formatHealthReport,
|
||||
summarizeHealthReport,
|
||||
};
|
||||
|
||||
@@ -4,14 +4,17 @@ const provenance = require('./provenance');
|
||||
const versioning = require('./versioning');
|
||||
const tracker = require('./tracker');
|
||||
const health = require('./health');
|
||||
const dashboard = require('./dashboard');
|
||||
|
||||
module.exports = {
|
||||
...provenance,
|
||||
...versioning,
|
||||
...tracker,
|
||||
...health,
|
||||
...dashboard,
|
||||
provenance,
|
||||
versioning,
|
||||
tracker,
|
||||
health,
|
||||
dashboard,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user