diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-06-04 00:22:00 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-06-04 00:22:00 +0000 |
| commit | 0d537df08fea539022cbcffe3ea778a3c84e7fb7 (patch) | |
| tree | c76eb20bbad16050cc969aa224ab7d0bfd725686 | |
| parent | be84621e0558184399a8d4452be795fdd72928f8 (diff) | |
feat: execution row click opens unified detail + log viewer
Replace the per-row "View Logs" button with a click handler on the entire
execution row. Clicking opens the logs-modal showing a metadata grid (id,
status, agent, exit code, cost, times, error, log paths) followed by an
inline streaming log viewer. The EventSource is torn down on modal close.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | web/app.js | 121 | ||||
| -rw-r--r-- | web/style.css | 22 |
2 files changed, 116 insertions, 27 deletions
@@ -1936,14 +1936,8 @@ export function renderTaskPanel(task, executions) { row.appendChild(commitList); } - const logsBtn = document.createElement('button'); - logsBtn.className = 'btn-view-logs'; - logsBtn.textContent = 'View Logs'; - logsBtn.addEventListener('click', () => { - const panelContent = document.getElementById('task-panel-content'); - openLogViewer(exec.ID, panelContent); - }); - row.appendChild(logsBtn); + row.style.cursor = 'pointer'; + row.addEventListener('click', () => openExecutionDetail(exec.ID)); list.appendChild(row); } @@ -1967,25 +1961,36 @@ export function renderTaskPanel(task, executions) { } } -async function handleViewLogs(execId) { +async function openExecutionDetail(execId) { const modal = document.getElementById('logs-modal'); const body = document.getElementById('logs-modal-body'); document.getElementById('logs-modal-title').textContent = `Execution ${execId.slice(0, 8)}`; body.innerHTML = '<div class="panel-loading">Loading…</div>'; modal.showModal(); + let detailLogSource = null; + + const onClose = () => { + detailLogSource?.close(); + detailLogSource = null; + modal.removeEventListener('close', onClose); + }; + modal.addEventListener('close', onClose); + try { const res = await fetch(`${API_BASE}/api/executions/${execId}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const exec = await res.json(); body.innerHTML = ''; + + // Metadata grid const grid = document.createElement('div'); grid.className = 'meta-grid'; - const entries = [ ['ID', exec.ID, { fullWidth: true, mono: true }], ['Status', exec.Status, { badge: true }], + ['Agent', exec.Agent || '—', {}], ['Exit Code', String(exec.ExitCode ?? '—'), {}], ['Cost', exec.CostUSD > 0 ? `$${exec.CostUSD.toFixed(4)}` : '—', {}], ['Start', formatDateLong(exec.StartTime), {}], @@ -1998,6 +2003,102 @@ async function handleViewLogs(execId) { grid.appendChild(makeMetaItem(label, value, opts)); } body.appendChild(grid); + + // Log stream + const logHeader = document.createElement('div'); + logHeader.style.cssText = 'margin: 1rem 0 0.5rem; font-size: 0.75rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em;'; + logHeader.textContent = 'Log'; + body.appendChild(logHeader); + + const statusEl = document.createElement('div'); + statusEl.className = 'log-status-indicator'; + statusEl.textContent = 'Streaming...'; + body.appendChild(statusEl); + + const logOutput = document.createElement('div'); + logOutput.className = 'log-output'; + logOutput.style.fontFamily = 'monospace'; + logOutput.style.overflowY = 'auto'; + logOutput.style.maxHeight = '400px'; + body.appendChild(logOutput); + + let userScrolled = false; + logOutput.addEventListener('scroll', () => { + const nearBottom = logOutput.scrollHeight - logOutput.scrollTop - logOutput.clientHeight < 50; + userScrolled = !nearBottom; + }); + + const source = new EventSource(`${API_BASE}/api/executions/${execId}/logs/stream`); + detailLogSource = source; + + source.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 'tool_result': { + line.classList.add('log-tool-result'); + line.style.opacity = '0.6'; + const content = Array.isArray(data.content) + ? data.content.map(c => c.text ?? '').join(' ') + : (data.content ?? ''); + line.textContent = String(content).slice(0, 120); + 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; + } + + logOutput.appendChild(line); + if (!userScrolled) { + logOutput.scrollTop = logOutput.scrollHeight; + } + }; + + source.addEventListener('done', () => { + source.close(); + detailLogSource = null; + userScrolled = false; + statusEl.classList.remove('log-status-indicator'); + statusEl.textContent = 'Stream complete'; + }); + + source.onerror = () => { + source.close(); + detailLogSource = null; + statusEl.hidden = true; + const errEl = document.createElement('div'); + errEl.className = 'log-line log-error'; + errEl.textContent = 'Connection error. Stream closed.'; + logOutput.appendChild(errEl); + }; + } catch (err) { body.innerHTML = `<div class="panel-fetch-error">Failed to load: ${err.message}</div>`; } diff --git a/web/style.css b/web/style.css index b6f0484..abbf3b9 100644 --- a/web/style.css +++ b/web/style.css @@ -817,6 +817,11 @@ dialog label select:focus { align-items: center; gap: 0.5rem 0.75rem; flex-wrap: wrap; + cursor: pointer; +} + +.execution-row:hover { + background: var(--surface-2, rgba(255,255,255,0.06)); } .execution-commits { @@ -910,23 +915,6 @@ dialog label select:focus { border: 1px solid color-mix(in srgb, var(--warn, #f59e0b) 35%, transparent); } -.btn-view-logs { - font-size: 0.72rem; - font-weight: 600; - padding: 0.2em 0.55em; - border-radius: 0.25rem; - border: 1px solid var(--border); - background: transparent; - color: var(--text-muted); - cursor: pointer; - white-space: nowrap; - flex-shrink: 0; -} - -.btn-view-logs:hover { - background: var(--border); - color: var(--text); -} /* Execution logs modal */ .logs-modal-body { |
