summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-02-24 02:01:24 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-02-24 02:01:24 +0000
commit135d8eb1e9612ede642e5341ccd865f72bab3869 (patch)
tree57953da718e558506f27286a0f9896f3037d55a1 /web
parentf27d4f7ef3949627c9cb1077f90135a5268b7631 (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')
-rw-r--r--web/app.js152
-rw-r--r--web/embed.go6
-rw-r--r--web/index.html20
-rw-r--r--web/style.css172
4 files changed, 350 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();
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Claudomator</title>
+ <link rel="stylesheet" href="style.css" />
+</head>
+<body>
+ <header>
+ <h1>Claudomator</h1>
+ </header>
+ <main id="app">
+ <div class="task-list">
+ <div id="loading">Loading tasks…</div>
+ </div>
+ </main>
+ <script src="app.js" defer></script>
+</body>
+</html>
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);
+}