From 55c20922cc7a671787fe94fdd53a7eb72ebd2596 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Wed, 11 Mar 2026 19:05:12 +0000 Subject: feat: add Stats tab with task distribution and execution health metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export computeTaskStats and computeExecutionStats from app.js - Add renderStatsPanel with state count grid, KPI row (total/success-rate/cost/avg-duration), and outcome bar chart - Wire stats tab into switchTab and poll for live refresh - Add Stats tab button and panel to index.html - Add CSS for .stats-counts, .stats-kpis, .stats-bar-chart using existing state color variables - Add docs/stats-tab-plan.md with component structure and data flow - 14 new unit tests in web/test/stats.test.mjs (140 total, all passing) No backend changes — derives all metrics from existing /api/tasks and /api/executions endpoints. Co-Authored-By: Claude Sonnet 4.6 --- web/app.js | 228 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) (limited to 'web/app.js') diff --git a/web/app.js b/web/app.js index 2c1e481..2b6330b 100644 --- a/web/app.js +++ b/web/app.js @@ -297,6 +297,57 @@ export function setTaskFilterTab(tab) { localStorage.setItem('taskFilterTab', tab); } +// ── Stats computations ───────────────────────────────────────────────────────── + +/** + * Computes task state distribution from a task array. + * Returns { byState: { [state]: count } } — only states with tasks are included. + */ +export function computeTaskStats(tasks) { + const byState = {}; + for (const t of tasks) { + byState[t.state] = (byState[t.state] || 0) + 1; + } + return { byState }; +} + +/** + * Computes execution health metrics from a RecentExecution array. + * Returns: + * { total, successRate, totalCostUSD, avgDurationMs, byOutcome } + * where successRate is a fraction (0–1), avgDurationMs is null if no durations. + */ +export function computeExecutionStats(executions) { + if (executions.length === 0) { + return { total: 0, successRate: 0, totalCostUSD: 0, avgDurationMs: null, byOutcome: {} }; + } + + let completed = 0; + let totalCost = 0; + let durationSum = 0; + let durationCount = 0; + const byOutcome = {}; + + for (const e of executions) { + const outcome = e.state || 'unknown'; + byOutcome[outcome] = (byOutcome[outcome] || 0) + 1; + if (outcome === 'completed') completed++; + totalCost += e.cost_usd || 0; + if (e.duration_ms != null) { + durationSum += e.duration_ms; + durationCount++; + } + } + + return { + total: executions.length, + successRate: executions.length > 0 ? completed / executions.length : 0, + totalCostUSD: totalCost, + avgDurationMs: durationCount > 0 ? Math.round(durationSum / durationCount) : null, + byOutcome, + }; +} + export function updateFilterTabs() { const current = getTaskFilterTab(); document.querySelectorAll('.filter-tab[data-filter]').forEach(el => { @@ -813,6 +864,11 @@ async function handleStartNextTask(btn) { // ── Polling ─────────────────────────────────────────────────────────────────── +function isStatsTabActive() { + const panel = document.querySelector('[data-panel="stats"]'); + return panel && !panel.hasAttribute('hidden'); +} + async function poll() { try { const tasks = await fetchTasks(); @@ -828,6 +884,11 @@ async function poll() { if (histEl) histEl.innerHTML = '

Could not load execution history.

'; }); } + if (isStatsTabActive()) { + fetchRecentExecutions(BASE_PATH, fetch) + .then(execs => renderStatsPanel(tasks, execs)) + .catch(() => {}); + } } catch { document.querySelector('.task-list').innerHTML = '
Could not reach server.
'; @@ -1915,6 +1976,163 @@ function renderRunningHistory(executions) { histEl.appendChild(table); } +// ── Stats rendering ─────────────────────────────────────────────────────────── + +// State display order for the task overview grid. +const STATS_STATE_ORDER = [ + 'RUNNING', 'QUEUED', 'READY', 'BLOCKED', + 'PENDING', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', +]; + +function formatDurationMs(ms) { + if (ms == null) return '—'; + const s = Math.round(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rs = s % 60; + if (m < 60) return rs > 0 ? `${m}m ${rs}s` : `${m}m`; + const h = Math.floor(m / 60); + const rm = m % 60; + return rm > 0 ? `${h}h ${rm}m` : `${h}h`; +} + +function renderStatsPanel(tasks, executions) { + const panel = document.querySelector('[data-panel="stats"]'); + if (!panel) return; + + const taskStats = computeTaskStats(tasks); + const execStats = computeExecutionStats(executions); + + panel.innerHTML = ''; + + // ── Task Overview ────────────────────────────────────────────────────────── + const taskSection = document.createElement('div'); + taskSection.className = 'stats-section'; + + const taskHeading = document.createElement('h2'); + taskHeading.textContent = 'Task Overview'; + taskSection.appendChild(taskHeading); + + const countsGrid = document.createElement('div'); + countsGrid.className = 'stats-counts'; + + const orderedStates = STATS_STATE_ORDER.filter(s => taskStats.byState[s] > 0); + const otherStates = Object.keys(taskStats.byState).filter(s => !STATS_STATE_ORDER.includes(s)); + + for (const state of [...orderedStates, ...otherStates]) { + const count = taskStats.byState[state] || 0; + if (count === 0) continue; + const box = document.createElement('div'); + box.className = 'stats-count-box'; + box.dataset.state = state; + + const num = document.createElement('span'); + num.className = 'stats-count-number'; + num.textContent = String(count); + + const label = document.createElement('span'); + label.className = 'stats-count-label'; + label.textContent = state.replace(/_/g, ' '); + + box.appendChild(num); + box.appendChild(label); + countsGrid.appendChild(box); + } + + if (orderedStates.length === 0 && otherStates.length === 0) { + const empty = document.createElement('p'); + empty.className = 'task-meta'; + empty.textContent = 'No tasks yet.'; + countsGrid.appendChild(empty); + } + + taskSection.appendChild(countsGrid); + panel.appendChild(taskSection); + + // ── Execution Health ─────────────────────────────────────────────────────── + const execSection = document.createElement('div'); + execSection.className = 'stats-section'; + + const execHeading = document.createElement('h2'); + execHeading.textContent = 'Executions (Last 24h)'; + execSection.appendChild(execHeading); + + const kpisRow = document.createElement('div'); + kpisRow.className = 'stats-kpis'; + + const kpis = [ + { label: 'Total Runs', value: String(execStats.total) }, + { label: 'Success Rate', value: execStats.total > 0 ? `${Math.round(execStats.successRate * 100)}%` : '—' }, + { label: 'Total Cost', value: execStats.totalCostUSD > 0 ? `$${execStats.totalCostUSD.toFixed(2)}` : '$0.00' }, + { label: 'Avg Duration', value: formatDurationMs(execStats.avgDurationMs) }, + ]; + + for (const kpi of kpis) { + const box = document.createElement('div'); + box.className = 'stats-kpi-box'; + + const val = document.createElement('span'); + val.className = 'stats-kpi-value'; + val.textContent = kpi.value; + + const lbl = document.createElement('span'); + lbl.className = 'stats-kpi-label'; + lbl.textContent = kpi.label; + + box.appendChild(val); + box.appendChild(lbl); + kpisRow.appendChild(box); + } + execSection.appendChild(kpisRow); + + // Bar chart of outcome distribution. + if (execStats.total > 0) { + const chartSection = document.createElement('div'); + chartSection.className = 'stats-bar-chart'; + + const chartLabel = document.createElement('p'); + chartLabel.className = 'stats-bar-chart-label'; + chartLabel.textContent = 'Outcome breakdown'; + chartSection.appendChild(chartLabel); + + const bars = document.createElement('div'); + bars.className = 'stats-bars'; + + for (const [outcome, count] of Object.entries(execStats.byOutcome)) { + const pct = (count / execStats.total) * 100; + const row = document.createElement('div'); + row.className = 'stats-bar-row'; + + const barLabel = document.createElement('span'); + barLabel.className = 'stats-bar-row-label'; + barLabel.textContent = outcome.replace(/_/g, ' '); + + const barTrack = document.createElement('div'); + barTrack.className = 'stats-bar-track'; + + const barFill = document.createElement('div'); + barFill.className = 'stats-bar-fill'; + barFill.dataset.state = outcome.toUpperCase(); + barFill.style.width = `${pct.toFixed(1)}%`; + + const barCount = document.createElement('span'); + barCount.className = 'stats-bar-count'; + barCount.textContent = `${count} (${Math.round(pct)}%)`; + + barTrack.appendChild(barFill); + row.appendChild(barLabel); + row.appendChild(barTrack); + row.appendChild(barCount); + bars.appendChild(row); + } + + chartSection.appendChild(bars); + execSection.appendChild(chartSection); + } + + panel.appendChild(execSection); +} + // ── Tab switching ───────────────────────────────────────────────────────────── function switchTab(name) { @@ -1948,6 +2166,16 @@ function switchTab(name) { if (histEl) histEl.innerHTML = '

Could not load execution history.

'; }); } + + if (name === 'stats') { + Promise.all([ + fetchTasks(), + fetchRecentExecutions(BASE_PATH, fetch), + ]).then(([tasks, execs]) => renderStatsPanel(tasks, execs)).catch(() => { + const panel = document.querySelector('[data-panel="stats"]'); + if (panel) panel.innerHTML = '

Could not load stats.

'; + }); + } } // ── Boot ────────────────────────────────────────────────────────────────────── -- cgit v1.2.3