From 135d8eb1e9612ede642e5341ccd865f72bab3869 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 24 Feb 2026 02:01:24 +0000 Subject: Add embedded web UI and wire it into the HTTP server Serves a lightweight dashboard (HTML/CSS/JS) from an embedded FS at GET /. The JS polls the REST API to display tasks and stream logs via WebSocket. Static files are embedded at build time via web/embed.go. Co-Authored-By: Claude Sonnet 4.6 --- web/app.js | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 web/app.js (limited to 'web/app.js') diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..e66c878 --- /dev/null +++ b/web/app.js @@ -0,0 +1,152 @@ +const API_BASE = window.location.origin; + +// ── Fetch ───────────────────────────────────────────────────────────────────── + +async function fetchTasks() { + const res = await fetch(`${API_BASE}/api/tasks`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + +// ── Render ──────────────────────────────────────────────────────────────────── + +function formatDate(iso) { + if (!iso) return ''; + return new Date(iso).toLocaleString(undefined, { + month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }); +} + +function createTaskCard(task) { + const card = document.createElement('div'); + card.className = 'task-card'; + card.dataset.taskId = task.id; + + // Header: name + state badge + 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.replace(/_/g, ' '); + + header.append(name, badge); + card.appendChild(header); + + // Meta: priority + created_at + const meta = document.createElement('div'); + meta.className = 'task-meta'; + if (task.priority) { + const prio = document.createElement('span'); + prio.textContent = task.priority; + meta.appendChild(prio); + } + if (task.created_at) { + const when = document.createElement('span'); + when.textContent = formatDate(task.created_at); + meta.appendChild(when); + } + if (meta.children.length) card.appendChild(meta); + + // Description (truncated via CSS) + if (task.description) { + const desc = document.createElement('div'); + desc.className = 'task-description'; + desc.textContent = task.description; + card.appendChild(desc); + } + + // Footer: Run button (only for PENDING / FAILED) + if (task.state === 'PENDING' || task.state === 'FAILED') { + const footer = document.createElement('div'); + footer.className = 'task-card-footer'; + + const btn = document.createElement('button'); + btn.className = 'btn-run'; + btn.textContent = 'Run'; + btn.addEventListener('click', () => handleRun(task.id, btn, footer)); + + footer.appendChild(btn); + card.appendChild(footer); + } + + return card; +} + +function renderTaskList(tasks) { + const container = document.querySelector('.task-list'); + + if (!tasks || tasks.length === 0) { + container.innerHTML = '
No tasks found.
'; + return; + } + + // Replace contents with task cards + container.innerHTML = ''; + for (const task of tasks) { + container.appendChild(createTaskCard(task)); + } +} + +// ── Run action ──────────────────────────────────────────────────────────────── + +async function runTask(taskId) { + const res = await fetch(`${API_BASE}/api/tasks/${taskId}/run`, { method: 'POST' }); + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { const body = await res.json(); msg = body.error || body.message || msg; } catch {} + throw new Error(msg); + } + return res.json(); +} + +async function handleRun(taskId, btn, footer) { + btn.disabled = true; + btn.textContent = 'Queuing…'; + + // Remove any previous error + const prev = footer.querySelector('.task-error'); + if (prev) prev.remove(); + + try { + await runTask(taskId); + // Refresh list immediately so state flips to QUEUED + const tasks = await fetchTasks(); + renderTaskList(tasks); + } catch (err) { + btn.disabled = false; + btn.textContent = 'Run'; + + const errEl = document.createElement('span'); + errEl.className = 'task-error'; + errEl.textContent = `Failed to queue: ${err.message}`; + footer.appendChild(errEl); + } +} + +// ── Polling ─────────────────────────────────────────────────────────────────── + +async function poll() { + try { + const tasks = await fetchTasks(); + renderTaskList(tasks); + } catch { + document.querySelector('.task-list').innerHTML = + '
Could not reach server.
'; + } +} + +function startPolling(intervalMs = 10_000) { + poll(); + setInterval(poll, intervalMs); +} + +// ── Boot ────────────────────────────────────────────────────────────────────── + +startPolling(); -- cgit v1.2.3