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 --- internal/api/server.go | 2 + web/app.js | 152 +++++++++++++++++++++++++++++++++++++++++++ web/embed.go | 6 ++ web/index.html | 20 ++++++ web/style.css | 172 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 352 insertions(+) create mode 100644 web/app.js create mode 100644 web/embed.go create mode 100644 web/index.html create mode 100644 web/style.css diff --git a/internal/api/server.go b/internal/api/server.go index 6caab9f..94095cb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -11,6 +11,7 @@ import ( "github.com/thepeterstone/claudomator/internal/executor" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" + webui "github.com/thepeterstone/claudomator/web" "github.com/google/uuid" ) @@ -53,6 +54,7 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution) s.mux.HandleFunc("GET /api/ws", s.handleWebSocket) s.mux.HandleFunc("GET /api/health", s.handleHealth) + s.mux.Handle("GET /", http.FileServerFS(webui.Files)) } // forwardResults listens on the executor pool's result channel and broadcasts via WebSocket. 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(); diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..99f142d --- /dev/null +++ b/web/embed.go @@ -0,0 +1,6 @@ +package webui + +import "embed" + +//go:embed *.html *.css *.js +var Files embed.FS diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..cb2b670 --- /dev/null +++ b/web/index.html @@ -0,0 +1,20 @@ + + + + + + Claudomator + + + +
+

Claudomator

+
+
+
+
Loading tasks…
+
+
+ + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..d868fdf --- /dev/null +++ b/web/style.css @@ -0,0 +1,172 @@ +/* Design tokens */ +:root { + --state-pending: #94a3b8; + --state-queued: #60a5fa; + --state-running: #f59e0b; + --state-completed: #34d399; + --state-failed: #f87171; + --state-timed-out: #c084fc; + --state-cancelled: #9ca3af; + --state-budget-exceeded: #fb923c; + + --bg: #0f172a; + --surface: #1e293b; + --border: #334155; + --text: #e2e8f0; + --text-muted: #94a3b8; + --accent: #38bdf8; +} + +/* Reset */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100dvh; +} + +/* Header */ +header { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 1rem; + position: sticky; + top: 0; + z-index: 10; +} + +header h1 { + font-size: 1.25rem; + font-weight: 700; + color: var(--accent); + letter-spacing: -0.01em; + max-width: 640px; + margin: 0 auto; +} + +/* Main layout */ +main { + max-width: 640px; + margin: 0 auto; + padding: 1rem; +} + +/* Task list */ +.task-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +#loading { + color: var(--text-muted); + text-align: center; + padding: 2rem 0; + font-size: 0.9rem; +} + +/* Task card */ +.task-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.875rem 1rem; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.task-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + flex-wrap: wrap; +} + +.task-name { + font-weight: 600; + font-size: 0.95rem; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* State badge */ +.state-badge { + display: inline-block; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + padding: 0.2em 0.55em; + border-radius: 999px; + color: #0f172a; + white-space: nowrap; + flex-shrink: 0; +} + +.state-badge[data-state="PENDING"] { background: var(--state-pending); } +.state-badge[data-state="QUEUED"] { background: var(--state-queued); } +.state-badge[data-state="RUNNING"] { background: var(--state-running); } +.state-badge[data-state="COMPLETED"] { background: var(--state-completed); } +.state-badge[data-state="FAILED"] { background: var(--state-failed); } +.state-badge[data-state="TIMED_OUT"] { background: var(--state-timed-out); } +.state-badge[data-state="CANCELLED"] { background: var(--state-cancelled); } +.state-badge[data-state="BUDGET_EXCEEDED"] { background: var(--state-budget-exceeded); } + +/* Task meta */ +.task-meta { + font-size: 0.78rem; + color: var(--text-muted); + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.task-description { + font-size: 0.82rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Run button */ +.task-card-footer { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.25rem; + flex-wrap: wrap; +} + +.btn-run { + font-size: 0.8rem; + font-weight: 600; + padding: 0.35em 0.85em; + border-radius: 0.375rem; + border: none; + cursor: pointer; + background: var(--accent); + color: #0f172a; + transition: opacity 0.15s; +} + +.btn-run:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.task-error { + font-size: 0.78rem; + color: var(--state-failed); +} -- cgit v1.2.3