commit 8a859e2e9221da404e8f21af5024f54679bb23be Author: Christopher Mayor Date: Tue Mar 3 15:02:01 2026 -0800 Initial commit: OpenClaw Agent Fleet Dashboard - Kanban board with 5 columns (Backlog, Todo, In Progress, Review, Done) - Agent assignment for all OpenClaw agents - Priority levels and tags - Wiki auto-generation on task completion - REST API for agent heartbeat integration - Real-time updates via WebSocket - SQLite database for task storage - Docker deployment configuration - Traefik ingress configuration diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..757c58e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +data/*.db +data/*.db-wal +data/*.db-shm +.env +.DS_Store +*.log +wiki/*.md +!wiki/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b8ba9f5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-bookworm-slim + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY . . + +ENV NODE_ENV=production +ENV PORT=8395 + +EXPOSE 8395 + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c963e5 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# OpenClaw Agent Fleet Dashboard + +A real-time task coordination board for the OpenClaw agent fleet. + +## Features + +- **Kanban Board**: Backlog → Todo → In Progress → Review → Done +- **Agent Assignment**: Assign tasks to specific OpenClaw agents +- **Priority Levels**: High, Medium, Low +- **Tags**: Categorize tasks with tags +- **Wiki Auto-Generation**: Completed tasks generate wiki documentation +- **Real-time Updates**: WebSocket-powered live updates +- **REST API**: For agent heartbeat integration + +## Quick Start + +```bash +cd /home/bear/homelab/ubuntu/taskboard +docker compose up -d --build +``` + +Access at: https://agentdash.local.tophermayor.com + +## API Endpoints + +### Tasks + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/tasks` | List all tasks (filter: `?assignee=ubuntu&status=todo`) | +| GET | `/api/tasks/:id` | Get single task | +| POST | `/api/tasks` | Create task | +| PATCH | `/api/tasks/:id` | Update task | +| POST | `/api/tasks/:id/complete` | Complete task (creates wiki) | +| DELETE | `/api/tasks/:id` | Delete task | + +### Wiki + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/wiki` | List wiki pages | +| GET | `/api/wiki/:filename` | Get wiki page content | + +### Agent Heartbeat + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/heartbeat/:agent` | Get pending tasks for agent | + +## Agent Integration + +Add to agent's HEARTBEAT.md: + +```bash +# Check for assigned tasks +TASKS=$(curl -s http://192.168.50.61:8395/api/heartbeat/ubuntu) + +# If tasks pending, process them +if echo "$TASKS" | jq -e '.pending_tasks > 0' > /dev/null; then + echo "Processing assigned tasks..." + # Process tasks... +fi +``` + +## Example: Create Task via API + +```bash +curl -X POST http://localhost:8395/api/tasks \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Restart PostgreSQL container", + "description": "The postgres-shared container needs a restart for config changes", + "assignee": "ubuntu", + "priority": "high", + "tags": ["docker", "database"] + }' +``` + +## Example: Complete Task with Wiki + +```bash +curl -X POST http://localhost:8395/api/tasks/TASK_ID/complete \ + -H "Content-Type: application/json" \ + -d '{ + "implementation_details": "Restarted the container using docker restart postgres-shared. Verified connections working.", + "files_changed": ["/home/bear/homelab/ubuntu/postgres/docker-compose.yml"] + }' +``` + +## Task Schema + +```json +{ + "id": "uuid", + "title": "string", + "description": "string", + "assignee": "ubuntu|pve|truenas|grizzley|ice|panda|zeroclaw|docs", + "status": "backlog|todo|in_progress|review|done", + "priority": "high|medium|low", + "tags": ["array", "of", "tags"], + "created_at": "ISO timestamp", + "updated_at": "ISO timestamp", + "completed_at": "ISO timestamp or null", + "wiki_path": "filename.md or null" +} +``` + +## Directory Structure + +``` +taskboard/ +├── docker-compose.yml +├── Dockerfile +├── README.md +├── package.json +├── server.js +├── client/ +│ └── index.html +├── public/ +│ ├── index.html +│ └── app.js +├── data/ +│ └── tasks.db (SQLite) +└── wiki/ + └── (auto-generated wiki pages) +``` + +## Deployment + +The taskboard is deployed on the ubuntu host at: +- **URL**: https://agentdash.local.tophermayor.com +- **Port**: 8395 +- **Container**: openclaw-taskboard +- **Traefik Route**: /home/bear/homelab/ubuntu/traefik/config/dynamic/taskboard.yml + +## License + +MIT diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b6c27d8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + openclaw-taskboard: + build: . + container_name: openclaw-taskboard + restart: unless-stopped + ports: + - "8395:8395" + environment: + - PORT=8395 + - DB_PATH=/app/data/tasks.db + - WIKI_DIR=/home/bear/.openclaw/workspace/wiki + volumes: + - ./data:/app/data + - /home/bear/.openclaw/workspace/wiki:/home/bear/.openclaw/workspace/wiki + networks: + - proxy-net + +networks: + proxy-net: + external: true diff --git a/package.json b/package.json new file mode 100644 index 0000000..61a069a --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "openclaw-taskboard", + "version": "1.0.0", + "description": "OpenClaw agent fleet task tracking dashboard", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.19.2", + "sqlite3": "^5.1.7", + "ws": "^8.18.0" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..5c9e9eb --- /dev/null +++ b/public/app.js @@ -0,0 +1,141 @@ +const STATUSES = ['Backlog', 'Todo', 'In Progress', 'Review', 'Done']; + +const board = document.getElementById('board'); +const template = document.getElementById('task-template'); +const form = document.getElementById('task-form'); + +let tasks = []; + +function renderBoard() { + board.innerHTML = ''; + + for (const status of STATUSES) { + const column = document.createElement('section'); + column.className = 'column'; + + const heading = document.createElement('h2'); + heading.textContent = `${status} (${tasks.filter((t) => t.status === status).length})`; + + const cards = document.createElement('div'); + cards.className = 'cards'; + + tasks + .filter((task) => task.status === status) + .forEach((task) => cards.appendChild(renderCard(task))); + + column.appendChild(heading); + column.appendChild(cards); + board.appendChild(column); + } +} + +function renderCard(task) { + const node = template.content.firstElementChild.cloneNode(true); + + node.querySelector('.card-title').textContent = task.title; + node.querySelector('.card-desc').textContent = task.description || 'No description'; + node.querySelector('.assignee').textContent = `Assignee: ${task.assignee || 'Unassigned'}`; + node.querySelector('.tags').textContent = `Tags: ${(task.tags || []).join(', ') || 'None'}`; + + const badge = node.querySelector('.priority'); + badge.textContent = task.priority; + badge.classList.add((task.priority || '').toLowerCase()); + + const statusSelect = node.querySelector('.status-select'); + statusSelect.value = task.status; + statusSelect.addEventListener('change', async () => { + await updateTask(task.id, { status: statusSelect.value }); + }); + + return node; +} + +async function loadTasks() { + const res = await fetch('/api/tasks'); + tasks = await res.json(); + renderBoard(); +} + +async function createTask(payload) { + const res = await fetch('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'failed_to_create_task'); + } + + return res.json(); +} + +async function updateTask(id, payload) { + const res = await fetch(`/api/tasks/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'failed_to_update_task'); + } + + return res.json(); +} + +form.addEventListener('submit', async (e) => { + e.preventDefault(); + + const payload = { + title: form.title.value, + description: form.description.value, + assignee: form.assignee.value, + priority: form.priority.value, + status: form.status.value, + tags: form.tags.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + }; + + try { + await createTask(payload); + form.reset(); + form.priority.value = 'Medium'; + form.status.value = 'Backlog'; + } catch (err) { + alert(err.message); + } +}); + +function upsertTask(task) { + const idx = tasks.findIndex((t) => t.id === task.id); + if (idx === -1) { + tasks.unshift(task); + } else { + tasks[idx] = task; + } + renderBoard(); +} + +function connectWebSocket() { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${proto}//${location.host}`); + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type === 'task_created' || msg.type === 'task_updated') { + upsertTask(msg.payload); + } + }; + + ws.onclose = () => { + setTimeout(connectWebSocket, 1200); + }; +} + +loadTasks(); +connectWebSocket(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..3d15d19 --- /dev/null +++ b/public/index.html @@ -0,0 +1,65 @@ + + + + + + OpenClaw Fleet Taskboard + + + +
+

OpenClaw Agent Fleet Dashboard

+

Real-time task coordination board

+
+ +
+

Create Task

+
+ + + + + + + +
+
+ +
+ + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..f495498 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,182 @@ +:root { + --bg: #0e1117; + --panel: #161b22; + --muted: #98a6b3; + --text: #e6edf3; + --border: #2d333b; + --accent: #2f81f7; + --critical: #f85149; + --high: #db6d28; + --medium: #d29922; + --low: #238636; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + background: radial-gradient(circle at top, #1c2431, var(--bg) 55%); + color: var(--text); +} + +.topbar { + padding: 1.2rem; + border-bottom: 1px solid var(--border); + background: rgba(22, 27, 34, 0.9); +} + +.topbar h1 { + margin: 0; + font-size: 1.4rem; +} + +.topbar p { + margin: 0.25rem 0 0; + color: var(--muted); +} + +.composer { + padding: 1rem 1.2rem; +} + +.composer h2 { + margin: 0 0 0.7rem; + font-size: 1rem; +} + +#task-form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.6rem; +} + +input, +select, +textarea, +button { + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); + border-radius: 8px; + padding: 0.55rem 0.7rem; +} + +textarea { + grid-column: 1 / -1; + min-height: 70px; + resize: vertical; +} + +button { + cursor: pointer; + background: linear-gradient(90deg, #1f6feb, var(--accent)); + border: none; + font-weight: 600; +} + +.board { + display: grid; + grid-template-columns: repeat(5, minmax(220px, 1fr)); + gap: 1rem; + padding: 0 1.2rem 1.2rem; + overflow-x: auto; +} + +.column { + background: rgba(22, 27, 34, 0.9); + border: 1px solid var(--border); + border-radius: 10px; + min-height: 220px; + padding: 0.7rem; +} + +.column h2 { + font-size: 0.95rem; + margin: 0 0 0.6rem; + color: var(--muted); +} + +.cards { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.card { + background: #202632; + border: 1px solid #334055; + border-radius: 8px; + padding: 0.6rem; +} + +.card-head { + display: flex; + justify-content: space-between; + gap: 0.4rem; + align-items: flex-start; +} + +.card-title { + margin: 0; + font-size: 0.95rem; +} + +.card-desc { + margin: 0.5rem 0; + font-size: 0.85rem; + color: #c7d3df; +} + +.meta { + margin: 0.2rem 0; + color: var(--muted); + font-size: 0.78rem; +} + +.badge { + border-radius: 999px; + font-size: 0.72rem; + padding: 0.18rem 0.45rem; + border: 1px solid transparent; + white-space: nowrap; +} + +.priority.low { + color: var(--low); + border-color: var(--low); +} + +.priority.medium { + color: var(--medium); + border-color: var(--medium); +} + +.priority.high { + color: var(--high); + border-color: var(--high); +} + +.priority.critical { + color: var(--critical); + border-color: var(--critical); +} + +.status-select { + width: 100%; + margin-top: 0.2rem; +} + +@media (max-width: 980px) { + .board { + grid-template-columns: repeat(2, minmax(240px, 1fr)); + } +} + +@media (max-width: 620px) { + .board { + grid-template-columns: 1fr; + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..ce2fc6f --- /dev/null +++ b/server.js @@ -0,0 +1,246 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const http = require('http'); +const sqlite3 = require('sqlite3').verbose(); +const { WebSocketServer } = require('ws'); + +const PORT = process.env.PORT || 8395; +const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'tasks.db'); +const WIKI_DIR = process.env.WIKI_DIR || '/home/bear/.openclaw/workspace/wiki'; + +const VALID_STATUSES = ['Backlog', 'Todo', 'In Progress', 'Review', 'Done']; +const VALID_PRIORITIES = ['Low', 'Medium', 'High', 'Critical']; + +fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); +fs.mkdirSync(WIKI_DIR, { recursive: true }); + +const db = new sqlite3.Database(DB_PATH); + +db.serialize(() => { + db.run(` + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT DEFAULT '', + assignee TEXT DEFAULT '', + priority TEXT NOT NULL DEFAULT 'Medium', + status TEXT NOT NULL DEFAULT 'Backlog', + tags TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT + ) + `); +}); + +const app = express(); +const server = http.createServer(app); +const wss = new WebSocketServer({ server }); + +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); + +function normalizeTask(row) { + return { + ...row, + tags: (() => { + try { + return JSON.parse(row.tags || '[]'); + } catch { + return []; + } + })(), + }; +} + +function writeWiki(task) { + const safeTitle = task.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || `task-${task.id}`; + + const fileName = `${new Date().toISOString().slice(0, 10)}-task-${task.id}-${safeTitle}.md`; + const filePath = path.join(WIKI_DIR, fileName); + + const md = `# ${task.title}\n\n` + + `- Task ID: ${task.id}\n` + + `- Assignee: ${task.assignee || 'Unassigned'}\n` + + `- Priority: ${task.priority}\n` + + `- Status: ${task.status}\n` + + `- Tags: ${task.tags.length ? task.tags.join(', ') : 'None'}\n` + + `- Created: ${task.created_at}\n` + + `- Completed: ${task.completed_at || new Date().toISOString()}\n\n` + + `## Description\n\n${task.description || 'No description provided.'}\n`; + + fs.writeFileSync(filePath, md, 'utf8'); +} + +function broadcast(type, payload) { + const data = JSON.stringify({ type, payload }); + for (const client of wss.clients) { + if (client.readyState === 1) { + client.send(data); + } + } +} + +function validatePayload(body, partial = false) { + const errors = []; + + if (!partial || body.title !== undefined) { + if (typeof body.title !== 'string' || body.title.trim().length === 0) { + errors.push('title is required'); + } + } + + if (body.status !== undefined && !VALID_STATUSES.includes(body.status)) { + errors.push(`status must be one of: ${VALID_STATUSES.join(', ')}`); + } + + if (body.priority !== undefined && !VALID_PRIORITIES.includes(body.priority)) { + errors.push(`priority must be one of: ${VALID_PRIORITIES.join(', ')}`); + } + + if (body.tags !== undefined && !Array.isArray(body.tags)) { + errors.push('tags must be an array of strings'); + } + + return errors; +} + +app.get('/api/tasks', (req, res) => { + db.all('SELECT * FROM tasks ORDER BY id DESC', (err, rows) => { + if (err) { + return res.status(500).json({ error: 'failed_to_fetch_tasks' }); + } + + return res.json(rows.map(normalizeTask)); + }); +}); + +app.post('/api/tasks', (req, res) => { + const errors = validatePayload(req.body, false); + if (errors.length) { + return res.status(400).json({ error: 'validation_error', details: errors }); + } + + const title = req.body.title.trim(); + const description = typeof req.body.description === 'string' ? req.body.description : ''; + const assignee = typeof req.body.assignee === 'string' ? req.body.assignee : ''; + const priority = req.body.priority || 'Medium'; + const status = req.body.status || 'Backlog'; + const tags = Array.isArray(req.body.tags) ? req.body.tags.filter((t) => typeof t === 'string') : []; + + db.run( + `INSERT INTO tasks (title, description, assignee, priority, status, tags) + VALUES (?, ?, ?, ?, ?, ?)`, + [title, description, assignee, priority, status, JSON.stringify(tags)], + function onInsert(err) { + if (err) { + return res.status(500).json({ error: 'failed_to_create_task' }); + } + + db.get('SELECT * FROM tasks WHERE id = ?', [this.lastID], (fetchErr, row) => { + if (fetchErr || !row) { + return res.status(500).json({ error: 'failed_to_fetch_created_task' }); + } + + const task = normalizeTask(row); + broadcast('task_created', task); + return res.status(201).json(task); + }); + } + ); +}); + +app.patch('/api/tasks/:id', (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'invalid_task_id' }); + } + + const errors = validatePayload(req.body, true); + if (errors.length) { + return res.status(400).json({ error: 'validation_error', details: errors }); + } + + db.get('SELECT * FROM tasks WHERE id = ?', [id], (err, existing) => { + if (err) { + return res.status(500).json({ error: 'failed_to_find_task' }); + } + if (!existing) { + return res.status(404).json({ error: 'task_not_found' }); + } + + const existingTask = normalizeTask(existing); + const next = { + title: req.body.title !== undefined ? req.body.title.trim() : existingTask.title, + description: req.body.description !== undefined ? String(req.body.description) : existingTask.description, + assignee: req.body.assignee !== undefined ? String(req.body.assignee) : existingTask.assignee, + priority: req.body.priority !== undefined ? req.body.priority : existingTask.priority, + status: req.body.status !== undefined ? req.body.status : existingTask.status, + tags: req.body.tags !== undefined + ? req.body.tags.filter((t) => typeof t === 'string') + : existingTask.tags, + }; + + const nowDone = next.status === 'Done'; + const wasDone = existingTask.status === 'Done'; + const completedAt = nowDone && !wasDone + ? new Date().toISOString() + : nowDone + ? existing.completed_at + : null; + + db.run( + `UPDATE tasks + SET title = ?, description = ?, assignee = ?, priority = ?, status = ?, tags = ?, + completed_at = ?, updated_at = datetime('now') + WHERE id = ?`, + [ + next.title, + next.description, + next.assignee, + next.priority, + next.status, + JSON.stringify(next.tags), + completedAt, + id, + ], + (updateErr) => { + if (updateErr) { + return res.status(500).json({ error: 'failed_to_update_task' }); + } + + db.get('SELECT * FROM tasks WHERE id = ?', [id], (fetchErr, row) => { + if (fetchErr || !row) { + return res.status(500).json({ error: 'failed_to_fetch_updated_task' }); + } + + const task = normalizeTask(row); + + if (nowDone && !wasDone) { + try { + writeWiki(task); + } catch (wikiErr) { + console.error('wiki_creation_error', wikiErr); + } + } + + broadcast('task_updated', task); + return res.json(task); + }); + } + ); + }); +}); + +wss.on('connection', (socket) => { + socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } })); +}); + +server.listen(PORT, '0.0.0.0', () => { + console.log(`openclaw-taskboard listening on ${PORT}`); +}); diff --git a/wiki/.gitkeep b/wiki/.gitkeep new file mode 100644 index 0000000..e69de29