feat(dashboard): replace raw Canvas with Chart.js for all Impact tab charts

- Add Chart.js 4.4.7 via CDN + datalabels plugin
- Agent Score: horizontal bar chart, sorted descending, color-coded
- Model Distribution: doughnut with right-side legend + percentages
- Migration Impact: grouped before/after bars with tooltip showing delta
- Dark theme defaults: #8ba3c0 text, #1e2d45 grid
- Chart instances destroyed before re-render to prevent memory leaks
- Responsive: maintainAspectRatio: false
This commit is contained in:
Deploy Bot
2026-05-25 15:45:14 +01:00
parent 19be5cf229
commit 699456b49e

View File

@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>APAW Agent Evolution Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0"></script>
<style>
:root {
--bg-deep: #0a0f1a;
@@ -608,8 +610,9 @@
.chart-wrap { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; margin-bottom: 24px; }
.chart-title { font-size: 1.1em; font-weight: 700; margin-bottom: 16px; }
.chart-sub { font-size: 0.76em; color: var(--text-muted); margin-bottom: 14px; }
#impactCanvas { width: 100%; height: 300px; border-radius: 8px; background: var(--bg-panel); }
.chart-placeholder { text-align: center; padding: 60px 20px; color: var(--text-muted); font-size: 0.95em; }
.chart-container { position:relative; height:280px; width:100%; }
.chart-container-sm { position:relative; height:240px; width:100%; }
/* Recommendation Cards */
.rec-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; transition: all 0.3s; margin-bottom: 16px; }
@@ -848,39 +851,26 @@
<!-- Impact Tab -->
<div id="tab-impact" class="tab-panel">
<div class="stats-row" id="impactStats"></div>
<!-- Agent Score Bar Chart -->
<!-- Chart 1: Agent Performance Scores -->
<div class="chart-wrap">
<div class="chart-title">Agent Performance Scores</div>
<div class="chart-sub">Current fit score per agent — sorted descending</div>
<canvas id="historyScoreCanvas" width="900" height="260" style="width:100%;height:260px;border-radius:8px;background:var(--bg-panel)"></canvas>
<div id="historyPlaceholder" class="chart-placeholder" style="display:none">No agent score data available.</div>
<div class="chart-sub">Composite score per agent based on model benchmarks</div>
<div class="chart-container"><canvas id="agentScoreChart"></canvas></div>
</div>
<!-- Model Distribution -->
<div class="chart-wrap">
<div class="chart-title">Model Distribution</div>
<div class="chart-sub">Current models across all agents</div>
<canvas id="modelDistCanvas" width="900" height="280" style="width:100%;height:280px;border-radius:8px;background:var(--bg-panel)"></canvas>
<div id="modelDistPlaceholder" class="chart-placeholder" style="display:none">No model data available</div>
</div>
<!-- Migration Impact Bars -->
<div class="chart-wrap">
<div class="chart-title">Migration Impact</div>
<div class="chart-sub">Agents with model history — current score vs estimated previous model score</div>
<canvas id="impactCanvas" width="900" height="340" style="width:100%;height:340px;border-radius:8px;background:var(--bg-panel)"></canvas>
<div id="impactPlaceholder" class="chart-placeholder" style="display:none">No migration data yet</div>
</div>
<!-- Sync Note -->
<div id="impactSyncNote" class="chart-wrap" style="display:none;text-align:center;padding:40px;">
<div style="font-size:1.2em;color:var(--text-secondary);margin-bottom:12px">📊 Data not synced</div>
<div style="color:var(--text-muted);margin-bottom:16px">Run the synchronization script to collect agent performance data.</div>
<button class="action-btn primary" onclick="runSync()">
<span>🔄</span> Sync Evolution Data
</button>
<pre style="margin-top:12px;color:var(--text-muted);font-size:.8em;font-family:JetBrains Mono,monospace">bun run agent-evolution/scripts/sync-agent-history.ts</pre>
<!-- Chart 2 & 3 side by side -->
<div style="display:grid;grid-template-columns:1fr 1.5fr;gap:20px;margin-bottom:24px">
<div class="chart-wrap">
<div class="chart-title">Model Distribution</div>
<div class="chart-sub">Agents per model</div>
<div class="chart-container-sm"><canvas id="modelDistChart"></canvas></div>
</div>
<div class="chart-wrap">
<div class="chart-title">Migration Impact</div>
<div class="chart-sub">Before vs after model change score</div>
<div class="chart-container-sm"><canvas id="migrationImpactChart"></canvas></div>
</div>
</div>
</div>
</div>
@@ -1005,6 +995,11 @@
// Supports both server and file:// mode
let agentData = {};
// Set Chart.js dark theme defaults
Chart.defaults.color = '#8ba3c0';
Chart.defaults.borderColor = '#1e2d45';
Chart.defaults.font.family = "'Inter', sans-serif";
// Inline recommendation data fallback (from model-research-latest.json)
const INLINE_RECOMMENDATIONS = [
{ agent: "frontend-developer", current_model_in_agent_versions: "ollama-cloud/qwen3-coder:480b", source_of_truth_model: "ollama-cloud/minimax-m2.5", impact: "high", score_before: 86, score_after: 92, score_delta: 6, rationale: "agent-versions.json is stale. kilo-meta.json (source of truth) already has minimax-m2.5. Matrix score for frontend-dev on M2.5 = 92 (highest!)." },
@@ -1718,290 +1713,258 @@ function renderModelsTab(agent) {
}
// Compute score for any model name using benchmark lookup + fallback
function getModelScore(modelName, defaultScore) {
// Try exact match first
function computeAgentScore(modelName) {
const bm = (agentData.model_benchmarks || {});
let score = null;
if (bm[modelName]) {
score = (bm[modelName].if_score || 70) * 0.6 + (bm[modelName].swe_bench || 0) * 0.3;
const ctx = bm[modelName].context_window || 128;
const ctxScore = ctx >= 1000 ? 15 : ctx >= 256 ? 8 : 4;
score += ctxScore;
const key = Object.keys(bm).find(k => modelName.includes(k)) || '';
if (bm[key]) {
const m = bm[key];
let score = (m.if_score || 70) * 0.6 + (m.swe_bench || 0) * 0.3;
const ctx = m.context_window || 128;
score += ctx >= 1000 ? 15 : ctx >= 256 ? 8 : 4;
return Math.round(score);
}
if (score) return Math.round(score);
// Fallback: hash-based deterministic score from model name
const hash = modelName.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
return defaultScore || 50 + (hash % 35);
return 50 + (hash % 35);
}
// Render Impact Tab - horizontal score bars + donut + before/after bars
function renderImpact() {
const allAgents = Object.entries(agentData.agents);
const modelCounts = {};
let totalScore = 0, agentsWithScore = 0;
let bestModel = { name: '', score: 0 };
let worstModel = { name: '', score: 100 };
// Pre-compute all agent scores
const scoredAgents = [];
// Chart 1: Agent Score Bar Chart
function drawAgentScoreChart(scoredAgents) {
const ctx = document.getElementById('agentScoreChart')?.getContext('2d');
if (!ctx) return;
allAgents.forEach(([name, agent]) => {
const model = agent.current?.model || 'unknown';
modelCounts[model] = (modelCounts[model] || 0) + 1;
const score = getModelScore(model, 70);
scoredAgents.push({ name, model, score, history: agent.history || [] });
totalScore += score;
agentsWithScore++;
if (score > bestModel.score) bestModel = { name: model, score };
if (score < worstModel.score) worstModel = { name: model, score };
});
const labels = scoredAgents.map(a => a.name);
const data = scoredAgents.map(a => a.score);
const bgColors = scoredAgents.map(a =>
a.score >= 85 ? '#00ff94' : a.score >= 70 ? '#00d4ff' : a.score >= 55 ? '#a855f7' : '#ff4757'
);
scoredAgents.sort((a, b) => b.score - a.score);
const totalAgents = allAgents.length;
const avgSystemScore = agentsWithScore > 0 ? (totalScore / agentsWithScore).toFixed(1) : 0;
const changesMade = allAgents.reduce((sum, [_, a]) => sum + ((a.history || []).length || 0), 0);
// Stats row
document.getElementById('impactStats').innerHTML = `
<div class="stat-card"><div class="stat-label">Total Agents</div><div class="stat-value grad-cyan">${totalAgents}</div><div class="stat-sub">in system</div></div>
<div class="stat-card"><div class="stat-label">Avg System Score</div><div class="stat-value grad-green">${avgSystemScore}</div><div class="stat-sub">composite</div></div>
<div class="stat-card"><div class="stat-label">Best Model</div><div class="stat-value grad-purple">${bestModel.name.split('/').pop()}</div><div class="stat-sub">score: ${bestModel.score}</div></div>
<div class="stat-card"><div class="stat-label">Worst Model</div><div class="stat-value grad-orange">${worstModel.name.split('/').pop()}</div><div class="stat-sub">score: ${worstModel.score}</div></div>
<div class="stat-card"><div class="stat-label">Changes Made</div><div class="stat-value grad-cyan">${changesMade}</div><div class="stat-sub">total migrations</div></div>
`;
// Draw all 3 charts
drawAgentScoreBars(scoredAgents);
drawModelDistributionDonut(modelCounts);
drawMigrationImpactBarsFromHistory(scoredAgents);
}
// 1. Agent Score Bar Chart — horizontal bars sorted descending
function drawAgentScoreBars(scoredAgents) {
const canvas = document.getElementById('historyScoreCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = 900, h = 260;
canvas.width = w; canvas.height = h;
ctx.clearRect(0, 0, w, h);
if (scoredAgents.length === 0) {
document.getElementById('historyPlaceholder').style.display = 'block';
return;
}
document.getElementById('historyPlaceholder').style.display = 'none';
const padding = { top: 20, right: 40, bottom: 10, left: 140 };
const barH = Math.min(28, (h - padding.top - padding.bottom) / scoredAgents.length);
const maxScore = Math.max(...scoredAgents.map(a => a.score), 100);
const chartW = w - padding.left - padding.right;
scoredAgents.forEach((ag, i) => {
const barW = (ag.score / maxScore) * chartW;
const y = padding.top + i * barH;
const x = padding.left;
const color = ag.score >= 85 ? 'rgba(0,255,148,.7)' : ag.score >= 70 ? 'rgba(0,212,255,.65)' : ag.score >= 55 ? 'rgba(168,85,247,.55)' : 'rgba(255,71,87,.5)';
// Bar
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(x, y + 3, barW, barH - 6, 4);
ctx.fill();
// Agent name (left)
ctx.fillStyle = '#e8f1ff';
ctx.font = '12px Inter';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(ag.name, x - 8, y + barH / 2);
// Score (right of bar)
ctx.fillStyle = '#e8f1ff';
ctx.textAlign = 'left';
ctx.font = 'bold 11px JetBrains Mono';
ctx.fillText(ag.score.toString(), x + barW + 8, y + barH / 2);
// Model short name inside bar if enough space
if (barW > 120) {
ctx.fillStyle = '#0e1219';
ctx.font = '9px JetBrains Mono';
ctx.textAlign = 'left';
ctx.fillText(ag.model.split('/').pop().substring(0, 16), x + 8, y + barH / 2);
window._agentScoreChart = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{
label: 'Composite Score',
data,
backgroundColor: bgColors,
borderRadius: 6,
borderSkipped: false,
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#0f1525',
titleColor: '#e8f1ff',
bodyColor: '#8ba3c0',
borderColor: '#1e2d45',
borderWidth: 1,
callbacks: {
label: (item) => `${item.raw}${scoredAgents[item.dataIndex].model.split('/').pop()}`
}
}
},
scales: {
x: {
grid: { color: '#1e2d45' },
ticks: { color: '#5a7090', font: { family: 'JetBrains Mono', size: 10 } }
},
y: {
grid: { display: false },
ticks: { color: '#8ba3c0', font: { family: 'JetBrains Mono', size: 11 } }
}
}
}
});
}
// 2. Model Distribution Donut Chart
function drawModelDistributionDonut(modelCounts) {
const canvas = document.getElementById('modelDistCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
// Chart 2: Model Distribution (Doughnut)
function drawModelDistChart(modelCounts) {
const ctx = document.getElementById('modelDistChart')?.getContext('2d');
if (!ctx) return;
const w = 900, h = 280;
canvas.width = w; canvas.height = h;
const entries = Object.entries(modelCounts).filter(([_, c]) => c > 0);
const labels = entries.map(([m, _]) => m.split('/').pop());
const data = entries.map(([_, c]) => c);
const colors = ['#00ff94','#00d4ff','#a855f7','#ff9f43','#ff4757','#3b82f6','#facc15','#e879f9'];
const modelEntries = Object.entries(modelCounts).filter(([_, c]) => c > 0);
if (modelEntries.length === 0) {
document.getElementById('modelDistPlaceholder').style.display = 'block';
return;
}
document.getElementById('modelDistPlaceholder').style.display = 'none';
ctx.clearRect(0, 0, w, h);
const centerX = w / 2 - 120;
const centerY = h / 2;
const outerRadius = Math.min(w, h) / 2 - 30;
const innerRadius = outerRadius * 0.58;
const colors = ['#00ff94','#00d4ff','#a855f7','#ff9f43','#ff4757','#3b82f6','#facc15','#e879f9','#4ade80','#fb7185'];
const total = modelEntries.reduce((s, [_, c]) => s + c, 0);
let startAngle = -Math.PI / 2;
modelEntries.forEach(([model, count], idx) => {
const sliceAngle = (count / total) * Math.PI * 2;
const color = colors[idx % colors.length];
ctx.beginPath();
ctx.arc(centerX, centerY, outerRadius, startAngle, startAngle + sliceAngle);
ctx.arc(centerX, centerY, innerRadius, startAngle + sliceAngle, startAngle, true);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
startAngle += sliceAngle;
});
// Center text
ctx.fillStyle = '#e8f1ff';
ctx.font = 'bold 28px Inter';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(total.toString(), centerX, centerY - 6);
ctx.fillStyle = '#8ba3c0';
ctx.font = '12px Inter';
ctx.fillText('agents', centerX, centerY + 18);
// Legend on right
let legendY = centerY - (modelEntries.length * 22) / 2;
const legendX = centerX + outerRadius + 30;
modelEntries.forEach(([model, count], idx) => {
const color = colors[idx % colors.length];
const short = model.split('/').pop();
ctx.fillStyle = color;
ctx.fillRect(legendX, legendY, 10, 10);
ctx.fillStyle = '#e8f1ff';
ctx.font = '12px Inter';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(`${short}${count}`, legendX + 16, legendY + 5);
const pct = ((count / total) * 100).toFixed(0);
ctx.fillStyle = '#5a7090';
ctx.font = '10px JetBrains Mono';
ctx.fillText(`${pct}%`, legendX + 200, legendY + 5);
legendY += 24;
window._modelDistChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels,
datasets: [{
data,
backgroundColor: colors.slice(0, entries.length),
borderColor: '#141c2e',
borderWidth: 2,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
plugins: {
legend: {
position: 'right',
labels: { color: '#8ba3c0', font: { family: 'JetBrains Mono', size: 11 } }
},
tooltip: {
backgroundColor: '#0f1525',
titleColor: '#e8f1ff',
bodyColor: '#8ba3c0',
borderColor: '#1e2d45',
borderWidth: 1,
callbacks: {
label: (item) => ` ${item.label}: ${item.raw} agents (${((item.raw/data.reduce((s,c)=>s+c,0))*100).toFixed(0)}%)`
}
}
}
}
});
}
// 3. Migration Impact Bars — compare current model vs previous model from history
function drawMigrationImpactBarsFromHistory(scoredAgents) {
const canvas = document.getElementById('impactCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
// Chart 3: Migration Impact (Grouped Bar)
function drawMigrationChart(scoredAgents) {
const ctx = document.getElementById('migrationImpactChart')?.getContext('2d');
if (!ctx) return;
const w = 900, h = 340;
canvas.width = w; canvas.height = h;
// Build impact data: agents with history showing current vs previous model score
// Build before/after data from agents with history
const impactData = [];
scoredAgents.forEach(ag => {
if (ag.history && ag.history.length > 0) {
if (ag.history.length > 0) {
const latest = ag.history[ag.history.length - 1];
if (latest.to && latest.from) {
const after = ag.score; // current score
// Estimate "before" score from previous model name
const before = getModelScore(latest.from, after - 5);
const after = ag.score;
const before = computeAgentScore(latest.from);
impactData.push({
agent: ag.name,
before: before,
after: after,
name: ag.name.split('-').map(s => s[0]?.toUpperCase() + s.slice(1)).join('-'),
before, after,
delta: after - before,
fromModel: (latest.from || '').split('/').pop() || 'unknown',
toModel: ag.model.split('/').pop()
from: latest.from.split('/').pop(),
to: ag.model.split('/').pop()
});
}
}
});
if (impactData.length === 0) {
document.getElementById('impactPlaceholder').style.display = 'block';
// No history — show single bars for all agents
window._migrationChart = new Chart(ctx, {
type: 'bar',
data: {
labels: scoredAgents.slice(0, 20).map(a => a.name),
datasets: [{
label: 'Current Score',
data: scoredAgents.slice(0, 20).map(a => a.score),
backgroundColor: '#00ff94',
borderRadius: 4
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { display: false }, ticks: { color: '#5a7090', font: { size: 9 }, maxRotation: 45 } },
y: { grid: { color: '#1e2d45' }, ticks: { color: '#5a7090' } }
}
}
});
return;
}
document.getElementById('impactPlaceholder').style.display = 'none';
ctx.clearRect(0, 0, w, h);
const padding = { top: 30, right: 40, bottom: 50, left: 45 };
const chartW = w - padding.left - padding.right;
const chartH = h - padding.top - padding.bottom;
const maxScore = Math.max(...impactData.map(d => Math.max(d.before, d.after)), 100);
const barGroupW = chartW / impactData.length;
const barW = Math.min(22, barGroupW * 0.35);
const scaleY = chartH / maxScore;
// Grid + labels
ctx.strokeStyle = '#1e2d45';
ctx.lineWidth = 1;
ctx.fillStyle = '#5a7090';
ctx.textAlign = 'right';
ctx.font = '10px JetBrains Mono';
for (let i = 0; i <= 4; i++) {
const y = padding.top + (i * chartH / 4);
const val = Math.round(maxScore - (i * maxScore / 4));
ctx.beginPath(); ctx.moveTo(padding.left, y); ctx.lineTo(w - padding.right, y); ctx.stroke();
ctx.fillText(val.toString(), padding.left - 8, y + 4);
}
impactData.forEach((d, i) => {
const cx = padding.left + i * barGroupW + barGroupW / 2;
// Before bar (dim red)
const bh = d.before * scaleY;
const by = padding.top + chartH - bh;
ctx.fillStyle = 'rgba(255,71,87,.45)';
ctx.fillRect(cx - barW - 3, by, barW, bh);
// After bar (green if improved, red if regressed)
const ah = d.after * scaleY;
const ay = padding.top + chartH - ah;
ctx.fillStyle = d.delta >= 0 ? 'rgba(0,255,148,.75)' : 'rgba(255,71,87,.75)';
ctx.fillRect(cx + 3, ay, barW, ah);
// Delta label
if (Math.abs(d.delta) >= 1) {
ctx.fillStyle = d.delta >= 0 ? '#00ff94' : '#ff4757';
ctx.font = 'bold 9px JetBrains Mono';
ctx.textAlign = 'center';
const dy = Math.min(ay, by) - 10;
ctx.fillText((d.delta >= 0 ? '+' : '') + d.delta, cx, dy);
window._migrationChart = new Chart(ctx, {
type: 'bar',
data: {
labels: impactData.map(d => d.name),
datasets: [
{
label: 'Before',
data: impactData.map(d => d.before),
backgroundColor: 'rgba(255,71,87,.6)',
borderRadius: 4
},
{
label: 'After',
data: impactData.map(d => d.after),
backgroundColor: impactData.map(d => d.delta >= 0 ? 'rgba(0,255,148,.6)' : 'rgba(255,71,87,.6)'),
borderRadius: 4
}
]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
tooltip: {
backgroundColor: '#0f1525',
titleColor: '#e8f1ff',
bodyColor: '#8ba3c0',
borderColor: '#1e2d45',
borderWidth: 1,
callbacks: {
afterBody: (items) => {
const idx = items[0].dataIndex;
const d = impactData[idx];
return `Change: ${d.from}${d.to}\nDelta: ${d.delta >= 0 ? '+' : ''}${d.delta}`;
}
}
}
},
scales: {
x: { grid: { display: false }, ticks: { color: '#5a7090', font: { size: 9 }, maxRotation: 45 } },
y: { grid: { color: '#1e2d45' }, ticks: { color: '#5a7090' } }
}
}
// Agent label (rotated)
ctx.save();
ctx.translate(cx, h - padding.bottom + 18);
ctx.rotate(-0.35);
ctx.fillStyle = '#8ba3c0';
ctx.font = '9px Inter';
ctx.textAlign = 'left';
const label = d.agent.length > 12 ? d.agent.substring(0, 10) + '..' : d.agent;
ctx.fillText(label, 0, 0);
ctx.restore();
});
// Legend
const ly = 12;
ctx.fillStyle = 'rgba(255,71,87,.45)'; ctx.fillRect(padding.left, ly, 12, 10);
ctx.fillStyle = '#e8f1ff'; ctx.font = '10px Inter'; ctx.textAlign = 'left'; ctx.fillText('Before', padding.left + 18, ly + 9);
ctx.fillStyle = 'rgba(0,255,148,.75)'; ctx.fillRect(padding.left + 70, ly, 12, 10);
ctx.fillStyle = '#e8f1ff'; ctx.fillText('After', padding.left + 88, ly + 9);
}
// Sync button handler
// Render Impact Tab - Chart.js based
function renderImpact() {
const allAgents = Object.entries(agentData.agents);
const modelCounts = {};
const scoredAgents = [];
// Compute scores for all agents
allAgents.forEach(([name, agent]) => {
const model = agent.current?.model || 'unknown';
modelCounts[model] = (modelCounts[model] || 0) + 1;
const score = computeAgentScore(model);
scoredAgents.push({ name, model, score, history: agent.history || [] });
});
// Sort by score descending
scoredAgents.sort((a, b) => b.score - a.score);
// Stats row
const totalAgents = allAgents.length;
const avgScore = scoredAgents.length > 0
? (scoredAgents.reduce((s, a) => s + a.score, 0) / scoredAgents.length).toFixed(1)
: 0;
const best = scoredAgents[0] || { name: 'N/A', score: 0 };
const worst = scoredAgents[scoredAgents.length - 1] || { name: 'N/A', score: 0 };
const changes = allAgents.reduce((sum, [_, a]) => sum + ((a.history || []).length), 0);
document.getElementById('impactStats').innerHTML = `
<div class="stat-card"><div class="stat-label">Total Agents</div><div class="stat-value grad-cyan">${totalAgents}</div><div class="stat-sub">in system</div></div>
<div class="stat-card"><div class="stat-label">Avg Score</div><div class="stat-value grad-green">${avgScore}</div><div class="stat-sub">composite</div></div>
<div class="stat-card"><div class="stat-label">Best Model</div><div class="stat-value grad-purple">${best.model.split('/').pop()}</div><div class="stat-sub">score: ${best.score}</div></div>
<div class="stat-card"><div class="stat-label">Worst Model</div><div class="stat-value grad-orange">${worst.model.split('/').pop()}</div><div class="stat-sub">score: ${worst.score}</div></div>
<div class="stat-card"><div class="stat-label">Changes Made</div><div class="stat-value grad-cyan">${changes}</div><div class="stat-sub">total migrations</div></div>
`;
// Destroy old charts before creating new ones
if (window._agentScoreChart) window._agentScoreChart.destroy();
if (window._modelDistChart) window._modelDistChart.destroy();
if (window._migrationChart) window._migrationChart.destroy();
drawAgentScoreChart(scoredAgents);
drawModelDistChart(modelCounts);
drawMigrationChart(scoredAgents);
}
// Filter Agents
function runSync() {
const btn = document.querySelector('#impactSyncNote button');
if (btn) btn.textContent = '⏳ Running...';