summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-08 07:23:49 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-08 07:23:49 +0000
commit91fd90465acf4f5f0190c68850332a329199abf3 (patch)
tree3bd5eadb5911177b06700ef22eac6dbb9669dba7 /web
parent076c0faa0ae63278b3120cd6622e64ba1e36e36b (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'web')
-rw-r--r--web/app.js359
-rw-r--r--web/index.html5
-rw-r--r--web/style.css68
3 files changed, 432 insertions, 0 deletions
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 = '<p class="task-meta">Could not load execution history.</p>';
+ });
+ }
} catch {
document.querySelector('.task-list').innerHTML =
'<div id="loading">Could not reach server.</div>';
@@ -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) {
'<div id="loading">Could not reach server.</div>';
});
}
+
+ if (name === 'running') {
+ fetchTasks().then(renderRunningView).catch(() => {
+ const currentEl = document.querySelector('.running-current');
+ if (currentEl) currentEl.innerHTML = '<p class="task-meta">Could not reach server.</p>';
+ });
+ fetchRecentExecutions(BASE_PATH, fetch)
+ .then(execs => renderRunningHistory(execs))
+ .catch(() => {
+ const histEl = document.querySelector('.running-history');
+ if (histEl) histEl.innerHTML = '<p class="task-meta">Could not load execution history.</p>';
+ });
+ }
}
// ── Boot ──────────────────────────────────────────────────────────────────────
diff --git a/web/index.html b/web/index.html
index 8bfa6bb..e32fbd4 100644
--- a/web/index.html
+++ b/web/index.html
@@ -18,6 +18,7 @@
<button class="tab active" data-tab="tasks">Tasks</button>
<button class="tab" data-tab="templates">Templates</button>
<button class="tab" data-tab="active">Active</button>
+ <button class="tab" data-tab="running">Running</button>
</nav>
<main id="app">
<div data-panel="tasks">
@@ -40,6 +41,10 @@
<div data-panel="active" hidden>
<div class="active-task-list"></div>
</div>
+ <div data-panel="running" hidden>
+ <div class="running-current"></div>
+ <div class="running-history"></div>
+ </div>
</main>
<dialog id="task-modal">
diff --git a/web/style.css b/web/style.css
index 106ae04..9cfe140 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1057,6 +1057,74 @@ dialog label select:focus {
color: #94a3b8;
}
+/* ── Running tab ─────────────────────────────────────────────────────────────── */
+
+.running-current {
+ margin-bottom: 2rem;
+}
+
+.running-current h2 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 1rem;
+}
+
+.running-elapsed {
+ font-size: 0.85rem;
+ color: var(--state-running);
+ font-variant-numeric: tabular-nums;
+}
+
+.running-log {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 0.75rem;
+ font-family: monospace;
+ font-size: 0.8rem;
+ max-height: 300px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.running-history {
+ margin-top: 1.5rem;
+ overflow-x: auto;
+}
+
+.running-history h2 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 1rem;
+}
+
+.history-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+}
+
+.history-table th {
+ text-align: left;
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid var(--border);
+ color: var(--text-muted);
+ font-weight: 500;
+}
+
+.history-table td {
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid var(--border);
+ vertical-align: middle;
+}
+
/* ── Task delete button ──────────────────────────────────────────────────── */
.task-card {