Files
Aryan Tejani 89044e8c33 feat(design): skill health dashboard mockup (#518)
* feat(Design): skill health dashboard mockup

* fix(comments): code according to comments
2026-03-16 14:01:41 -07:00

402 lines
12 KiB
JavaScript

'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,
};