summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-06-04 00:22:00 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-06-04 00:22:00 +0000
commit0d537df08fea539022cbcffe3ea778a3c84e7fb7 (patch)
treec76eb20bbad16050cc969aa224ab7d0bfd725686 /web
parentbe84621e0558184399a8d4452be795fdd72928f8 (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>
Diffstat (limited to 'web')
-rw-r--r--web/app.js121
-rw-r--r--web/style.css22
2 files changed, 116 insertions, 27 deletions
diff --git a/web/app.js b/web/app.js
index 7f96e09..0328579 100644
--- a/web/app.js
+++ b/web/app.js
@@ -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 {