summaryrefslogtreecommitdiff
path: root/web/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'web/app.js')
-rw-r--r--web/app.js228
1 files changed, 228 insertions, 0 deletions
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 = '<p class="task-meta">Could not load execution history.</p>';
});
}
+ if (isStatsTabActive()) {
+ fetchRecentExecutions(BASE_PATH, fetch)
+ .then(execs => renderStatsPanel(tasks, execs))
+ .catch(() => {});
+ }
} catch {
document.querySelector('.task-list').innerHTML =
'<div id="loading">Could not reach server.</div>';
@@ -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 = '<p class="task-meta">Could not load execution history.</p>';
});
}
+
+ 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 = '<p class="task-meta">Could not load stats.</p>';
+ });
+ }
}
// ── Boot ──────────────────────────────────────────────────────────────────────