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
This commit is contained in:
2026-03-03 15:02:01 -08:00
commit 8a859e2e92
11 changed files with 830 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
data/*.db
data/*.db-wal
data/*.db-shm
.env
.DS_Store
*.log
wiki/*.md
!wiki/.gitkeep

15
Dockerfile Normal file
View File

@@ -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"]

138
README.md Normal file
View File

@@ -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

0
data/.gitkeep Normal file
View File

20
docker-compose.yml Normal file
View File

@@ -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

14
package.json Normal file
View File

@@ -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"
}
}

141
public/app.js Normal file
View File

@@ -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();

65
public/index.html Normal file
View File

@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenClaw Fleet Taskboard</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header class="topbar">
<h1>OpenClaw Agent Fleet Dashboard</h1>
<p>Real-time task coordination board</p>
</header>
<section class="composer">
<h2>Create Task</h2>
<form id="task-form">
<input id="title" name="title" placeholder="Task title" required />
<input id="assignee" name="assignee" placeholder="Assignee agent" />
<select id="priority" name="priority">
<option>Low</option>
<option selected>Medium</option>
<option>High</option>
<option>Critical</option>
</select>
<select id="status" name="status">
<option selected>Backlog</option>
<option>Todo</option>
<option>In Progress</option>
<option>Review</option>
<option>Done</option>
</select>
<input id="tags" name="tags" placeholder="tags, comma, separated" />
<textarea id="description" name="description" placeholder="Description"></textarea>
<button type="submit">Add Task</button>
</form>
</section>
<main id="board" class="board"></main>
<template id="task-template">
<article class="card">
<div class="card-head">
<h3 class="card-title"></h3>
<span class="badge priority"></span>
</div>
<p class="card-desc"></p>
<p class="meta assignee"></p>
<p class="meta tags"></p>
<label>
Status
<select class="status-select">
<option>Backlog</option>
<option>Todo</option>
<option>In Progress</option>
<option>Review</option>
<option>Done</option>
</select>
</label>
</article>
</template>
<script src="/app.js"></script>
</body>
</html>

182
public/styles.css Normal file
View File

@@ -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;
}
}

246
server.js Normal file
View File

@@ -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}`);
});

0
wiki/.gitkeep Normal file
View File