diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-02-24 02:01:24 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-02-24 02:01:24 +0000 |
| commit | 135d8eb1e9612ede642e5341ccd865f72bab3869 (patch) | |
| tree | 57953da718e558506f27286a0f9896f3037d55a1 /web/app.js | |
| parent | f27d4f7ef3949627c9cb1077f90135a5268b7631 (diff) | |
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 <noreply@anthropic.com>
Diffstat (limited to 'web/app.js')
| -rw-r--r-- | web/app.js | 152 |
1 files changed, 152 insertions, 0 deletions
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 = '<div id="loading">No tasks found.</div>'; + 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 = + '<div id="loading">Could not reach server.</div>'; + } +} + +function startPolling(intervalMs = 10_000) { + poll(); + setInterval(poll, intervalMs); +} + +// ── Boot ────────────────────────────────────────────────────────────────────── + +startPolling(); |
