From 91fd90465acf4f5f0190c68850332a329199abf3 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 8 Mar 2026 07:23:49 +0000 Subject: feat: restore Running view (currently running + 24h execution history) - Running tab in nav with live SSE log streams per running task - Execution history table (last 24h) with duration, cost, exit code, view logs - Poll loop refreshes running view when tab is active - Smart diff: only full re-render when task set changes; elapsed updated in place Co-Authored-By: Claude Sonnet 4.6 --- web/app.js | 359 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) (limited to 'web/app.js') diff --git a/web/app.js b/web/app.js index a3c425e..944b4ff 100644 --- a/web/app.js +++ b/web/app.js @@ -15,6 +15,61 @@ async function fetchTemplates() { return res.json(); } +// Fetches recent executions (last 24h) from /api/executions?since=24h. +// fetchFn defaults to window.fetch; injectable for tests. +async function fetchRecentExecutions(basePath = BASE_PATH, fetchFn = fetch) { + const res = await fetchFn(`${basePath}/api/executions?since=24h`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +// Returns only tasks currently in state RUNNING. +function filterRunningTasks(tasks) { + return tasks.filter(t => t.state === 'RUNNING'); +} + +// Returns human-readable elapsed time from an ISO timestamp to now. +function formatElapsed(startISO) { + if (startISO == null) return ''; + const elapsed = Math.floor((Date.now() - new Date(startISO).getTime()) / 1000); + if (elapsed < 0) return '0s'; + const h = Math.floor(elapsed / 3600); + const m = Math.floor((elapsed % 3600) / 60); + const s = elapsed % 60; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +// Returns human-readable duration between two ISO timestamps. +// If endISO is null, uses now (for in-progress tasks). +// If startISO is null, returns '--'. +function formatDuration(startISO, endISO) { + if (startISO == null) return '--'; + const start = new Date(startISO).getTime(); + const end = endISO != null ? new Date(endISO).getTime() : Date.now(); + const elapsed = Math.max(0, Math.floor((end - start) / 1000)); + const h = Math.floor(elapsed / 3600); + const m = Math.floor((elapsed % 3600) / 60); + const s = elapsed % 60; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +// Returns last max lines from array (for testability). +function extractLogLines(lines, max = 500) { + if (lines.length <= max) return lines; + return lines.slice(lines.length - max); +} + +// Returns a new array of executions sorted by started_at descending. +function sortExecutionsDesc(executions) { + return [...executions].sort((a, b) => + new Date(b.started_at).getTime() - new Date(a.started_at).getTime(), + ); +} + // ── Render ──────────────────────────────────────────────────────────────────── function formatDate(iso) { @@ -796,6 +851,15 @@ async function poll() { const tasks = await fetchTasks(); renderTaskList(tasks); renderActiveTaskList(tasks); + if (isRunningTabActive()) { + renderRunningView(tasks); + fetchRecentExecutions(BASE_PATH, fetch) + .then(execs => renderRunningHistory(execs)) + .catch(() => { + const histEl = document.querySelector('.running-history'); + if (histEl) histEl.innerHTML = '

Could not load execution history.

'; + }); + } } catch { document.querySelector('.task-list').innerHTML = '
Could not reach server.
'; @@ -1588,6 +1652,288 @@ function closeLogViewer() { activeLogSource = null; } +// ── Running view ─────────────────────────────────────────────────────────────── + +// Map of taskId → EventSource for live log streams in the Running tab. +const runningViewLogSources = {}; + +function renderRunningView(tasks) { + const currentEl = document.querySelector('.running-current'); + if (!currentEl) return; + + const running = filterRunningTasks(tasks); + + // Close SSE streams for tasks that are no longer RUNNING. + for (const [id, src] of Object.entries(runningViewLogSources)) { + if (!running.find(t => t.id === id)) { + src.close(); + delete runningViewLogSources[id]; + } + } + + // Update elapsed spans in place if the same tasks are still running. + const existingCards = currentEl.querySelectorAll('[data-task-id]'); + const existingIds = new Set([...existingCards].map(c => c.dataset.taskId)); + const unchanged = running.length === existingCards.length && + running.every(t => existingIds.has(t.id)); + + if (unchanged) { + updateRunningElapsed(); + return; + } + + // Full re-render. + currentEl.innerHTML = ''; + + const h2 = document.createElement('h2'); + h2.textContent = 'Currently Running'; + currentEl.appendChild(h2); + + if (running.length === 0) { + const empty = document.createElement('p'); + empty.className = 'task-meta'; + empty.textContent = 'No tasks are currently running.'; + currentEl.appendChild(empty); + return; + } + + for (const task of running) { + const card = document.createElement('div'); + card.className = 'running-task-card task-card'; + card.dataset.taskId = task.id; + + const header = document.createElement('div'); + header.className = 'task-card-header'; + + const name = document.createElement('span'); + name.className = 'task-name'; + name.textContent = task.name; + + const badge = document.createElement('span'); + badge.className = 'state-badge'; + badge.dataset.state = task.state; + badge.textContent = task.state; + + const elapsed = document.createElement('span'); + elapsed.className = 'running-elapsed'; + elapsed.dataset.startedAt = task.updated_at ?? ''; + elapsed.textContent = formatElapsed(task.updated_at); + + header.append(name, badge, elapsed); + card.appendChild(header); + + // Parent context (async fetch) + if (task.parent_task_id) { + const parentEl = document.createElement('div'); + parentEl.className = 'task-meta'; + parentEl.textContent = 'Subtask of: …'; + card.appendChild(parentEl); + fetch(`${API_BASE}/api/tasks/${task.parent_task_id}`) + .then(r => r.ok ? r.json() : null) + .then(parent => { + if (parent) parentEl.textContent = `Subtask of: ${parent.name}`; + }) + .catch(() => { parentEl.textContent = ''; }); + } + + // Log area + const logArea = document.createElement('div'); + logArea.className = 'running-log'; + logArea.dataset.logTarget = task.id; + card.appendChild(logArea); + + // Footer with Cancel button + const footer = document.createElement('div'); + footer.className = 'task-card-footer'; + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'btn-cancel'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.addEventListener('click', (e) => { + e.stopPropagation(); + handleCancel(task.id, cancelBtn, footer); + }); + footer.appendChild(cancelBtn); + card.appendChild(footer); + + currentEl.appendChild(card); + + // Open SSE stream if not already streaming for this task. + if (!runningViewLogSources[task.id]) { + startRunningLogStream(task.id, logArea); + } + } +} + +function startRunningLogStream(taskId, logArea) { + fetch(`${API_BASE}/api/executions?task_id=${taskId}&limit=1`) + .then(r => r.ok ? r.json() : []) + .then(execs => { + if (!execs || execs.length === 0) return; + const execId = execs[0].id; + + let userScrolled = false; + logArea.addEventListener('scroll', () => { + const nearBottom = logArea.scrollHeight - logArea.scrollTop - logArea.clientHeight < 50; + userScrolled = !nearBottom; + }); + + const src = new EventSource(`${API_BASE}/api/executions/${execId}/logs/stream`); + runningViewLogSources[taskId] = src; + + src.onmessage = (event) => { + let data; + try { data = JSON.parse(event.data); } catch { return; } + + const line = document.createElement('div'); + line.className = 'log-line'; + + switch (data.type) { + case 'text': { + line.classList.add('log-text'); + line.textContent = data.text ?? data.content ?? ''; + break; + } + case 'tool_use': { + line.classList.add('log-tool-use'); + const toolName = document.createElement('span'); + toolName.className = 'tool-name'; + toolName.textContent = `[${data.name ?? 'Tool'}]`; + line.appendChild(toolName); + const inputStr = data.input ? JSON.stringify(data.input) : ''; + const inputPreview = document.createElement('span'); + inputPreview.textContent = ' ' + inputStr.slice(0, 120); + line.appendChild(inputPreview); + break; + } + case 'cost': { + line.classList.add('log-cost'); + const cost = data.total_cost ?? data.cost ?? 0; + line.textContent = `Cost: $${Number(cost).toFixed(3)}`; + break; + } + default: + return; + } + + logArea.appendChild(line); + // Trim to last 500 lines. + while (logArea.childElementCount > 500) { + logArea.removeChild(logArea.firstElementChild); + } + if (!userScrolled) logArea.scrollTop = logArea.scrollHeight; + }; + + src.addEventListener('done', () => { + src.close(); + delete runningViewLogSources[taskId]; + }); + + src.onerror = () => { + src.close(); + delete runningViewLogSources[taskId]; + const errEl = document.createElement('div'); + errEl.className = 'log-line log-error'; + errEl.textContent = 'Stream closed.'; + logArea.appendChild(errEl); + }; + }) + .catch(() => {}); +} + +function updateRunningElapsed() { + document.querySelectorAll('.running-elapsed[data-started-at]').forEach(el => { + el.textContent = formatElapsed(el.dataset.startedAt || null); + }); +} + +function isRunningTabActive() { + const panel = document.querySelector('[data-panel="running"]'); + return panel && !panel.hasAttribute('hidden'); +} + +function sortExecutionsByDate(executions) { + return sortExecutionsDesc(executions); +} + +function renderRunningHistory(executions) { + const histEl = document.querySelector('.running-history'); + if (!histEl) return; + + histEl.innerHTML = ''; + + const h2 = document.createElement('h2'); + h2.textContent = 'Execution History (Last 24h)'; + histEl.appendChild(h2); + + if (!executions || executions.length === 0) { + const empty = document.createElement('p'); + empty.className = 'task-meta'; + empty.textContent = 'No executions in the last 24h'; + histEl.appendChild(empty); + return; + } + + const sorted = sortExecutionsDesc(executions); + + const table = document.createElement('table'); + table.className = 'history-table'; + + const thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + for (const col of ['Date', 'Task', 'Status', 'Duration', 'Cost', 'Exit', 'Logs']) { + const th = document.createElement('th'); + th.textContent = col; + headerRow.appendChild(th); + } + thead.appendChild(headerRow); + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + for (const exec of sorted) { + const tr = document.createElement('tr'); + + const tdDate = document.createElement('td'); + tdDate.textContent = formatDate(exec.started_at); + tr.appendChild(tdDate); + + const tdTask = document.createElement('td'); + tdTask.textContent = exec.task_name || exec.task_id || '—'; + tr.appendChild(tdTask); + + const tdStatus = document.createElement('td'); + const stateBadge = document.createElement('span'); + stateBadge.className = 'state-badge'; + stateBadge.dataset.state = exec.state || ''; + stateBadge.textContent = exec.state || '—'; + tdStatus.appendChild(stateBadge); + tr.appendChild(tdStatus); + + const tdDur = document.createElement('td'); + tdDur.textContent = formatDuration(exec.started_at, exec.finished_at ?? null); + tr.appendChild(tdDur); + + const tdCost = document.createElement('td'); + tdCost.textContent = exec.cost_usd > 0 ? `$${exec.cost_usd.toFixed(4)}` : '—'; + tr.appendChild(tdCost); + + const tdExit = document.createElement('td'); + tdExit.textContent = exec.exit_code != null ? String(exec.exit_code) : '—'; + tr.appendChild(tdExit); + + const tdLogs = document.createElement('td'); + const viewBtn = document.createElement('button'); + viewBtn.className = 'btn-sm'; + viewBtn.textContent = 'View Logs'; + viewBtn.addEventListener('click', () => openLogViewer(exec.id, histEl)); + tdLogs.appendChild(viewBtn); + tr.appendChild(tdLogs); + + tbody.appendChild(tr); + } + table.appendChild(tbody); + histEl.appendChild(table); +} + // ── Tab switching ───────────────────────────────────────────────────────────── function switchTab(name) { @@ -1615,6 +1961,19 @@ function switchTab(name) { '
Could not reach server.
'; }); } + + if (name === 'running') { + fetchTasks().then(renderRunningView).catch(() => { + const currentEl = document.querySelector('.running-current'); + if (currentEl) currentEl.innerHTML = '

Could not reach server.

'; + }); + fetchRecentExecutions(BASE_PATH, fetch) + .then(execs => renderRunningHistory(execs)) + .catch(() => { + const histEl = document.querySelector('.running-history'); + if (histEl) histEl.innerHTML = '

Could not load execution history.

'; + }); + } } // ── Boot ────────────────────────────────────────────────────────────────────── -- cgit v1.2.3 From 7b53b9e30e81aca67e98c8fce04674461da2c78d Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 8 Mar 2026 07:24:58 +0000 Subject: feat: make Running the default view on page load Co-Authored-By: Claude Sonnet 4.6 --- web/app.js | 1 + web/index.html | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) (limited to 'web/app.js') diff --git a/web/app.js b/web/app.js index 944b4ff..3b807c4 100644 --- a/web/app.js +++ b/web/app.js @@ -1993,6 +1993,7 @@ if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded handleStartNextTask(this); }); + switchTab('running'); startPolling(); connectWebSocket(); diff --git a/web/index.html b/web/index.html index e32fbd4..3b7901c 100644 --- a/web/index.html +++ b/web/index.html @@ -15,13 +15,13 @@
-
+