feat: refactor dashboard with enhanced wiki, agents, and usage views

- Added comprehensive wiki backend with CRUD operations (GET, POST, PUT, DELETE)
- Enhanced agents backend with workload tracking, task history, and capabilities
- Implemented usage tracking with SQLite database and statistics endpoints
- Added wiki frontend with markdown editor, page management, and search
- Enhanced agents frontend with search, filtering, task assignment, and detailed modals
- Enhanced usage frontend with charts (Chart.js), date filtering, and export functionality
- Updated styles for all new components with responsive design
- Added new API endpoints: /api/wiki/*, /api/usage/stats, /api/usage/agents, /api/usage/export
- Enhanced /api/agents with workload and task history data
- Maintained backwards compatibility with existing task management features

The refactoring includes:
- Wiki: Full CRUD operations with markdown support
- Agents: Search, workload tracking, task assignment modals
- Usage: Charts, statistics, date range filtering, CSV/JSON export
- Styles: Responsive design with dark theme support
This commit is contained in:
2026-03-03 22:48:34 -08:00
parent d4bbe42f3e
commit e05dfef5ab
8 changed files with 3440 additions and 779 deletions

File diff suppressed because it is too large Load Diff

279
public/app.js.backup Normal file
View File

@@ -0,0 +1,279 @@
// Navigation
const navLinks = document.querySelectorAll('.nav-link');
const pages = document.querySelectorAll('.page');
navLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetPage = link.dataset.page;
// Update active nav link
navLinks.forEach(l => l.classList.remove('active'));
link.classList.add('active');
// Show target page
pages.forEach(page => {
page.classList.remove('active');
if (page.id === `page-${targetPage}`) {
page.classList.add('active');
}
});
// Load page data
if (targetPage === 'wiki') loadWiki();
if (targetPage === 'agents') loadAgents();
if (targetPage === 'usage') loadUsage();
});
});
// Task Dashboard
const COLUMNS = {
'Backlog': { title: '📋 Backlog', tasks: [] },
'Todo': { title: '📝 Todo', tasks: [] },
'In Progress': { title: '🔄 In Progress', tasks: [] },
'Review': { title: '👀 Review', tasks: [] },
'Done': { title: '✅ Done', tasks: [] }
};
async function loadTasks() {
const res = await fetch('/api/tasks');
const tasks = await res.json();
// Reset columns
Object.keys(COLUMNS).forEach(status => {
COLUMNS[status].tasks = [];
});
// Group tasks by status
tasks.forEach(task => {
if (COLUMNS[task.status]) {
COLUMNS[task.status].tasks.push(task);
}
});
renderBoard();
}
function renderBoard() {
const board = document.getElementById('board');
board.innerHTML = '';
Object.entries(COLUMNS).forEach(([status, column]) => {
const columnEl = document.createElement('div');
columnEl.className = 'column';
columnEl.innerHTML = `
<div class="column-header">
<h3>${column.title}</h3>
<span class="column-count">${column.tasks.length}</span>
</div>
<div class="cards"></div>
`;
const cardsEl = columnEl.querySelector('.cards');
column.tasks.forEach(task => {
const cardEl = document.createElement('div');
cardEl.className = 'card';
cardEl.innerHTML = `
<div class="card-head">
<h3 class="card-title">${escapeHtml(task.title)}</h3>
<span class="badge priority-${task.priority}">${task.priority}</span>
</div>
<p class="card-desc">${escapeHtml(task.description || '')}</p>
<p class="meta assignee">${task.assignee || 'Unassigned'}</p>
<p class="meta tags">${task.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join(' ')}</p>
<label>
<input type="checkbox" class="card-check" ${task.status === 'Done' ? 'checked' : ''} />
Mark Complete
</label>
`;
// Checkbox handler
const checkbox = cardEl.querySelector('.card-check');
checkbox.addEventListener('change', async () => {
await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'Done' })
});
loadTasks();
});
cardsEl.appendChild(cardEl);
});
board.appendChild(columnEl);
});
}
// Task form
document.getElementById('task-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const task = {
title: formData.get('title'),
description: formData.get('description'),
assignee: formData.get('assignee'),
priority: formData.get('priority'),
status: formData.get('status'),
tags: formData.get('tags').split(',').map(t => t.trim()).filter(t => t)
};
await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(task)
});
e.target.reset();
loadTasks();
});
// Populate agent dropdown
async function populateAgentDropdown() {
try {
const res = await fetch('/api/agents');
const agents = await res.json();
const select = document.getElementById('assignee');
if (!select) return;
// Keep the first option ("Select agent...")
const firstOption = select.options[0];
select.innerHTML = '';
select.appendChild(firstOption);
// Add agent options
agents.forEach(agent => {
const option = document.createElement('option');
option.value = agent.name;
option.textContent = agent.name;
select.appendChild(option);
});
} catch (err) {
console.error('Failed to load agents for dropdown:', err);
}
}
// Populate dropdown on page load
document.addEventListener('DOMContentLoaded', () => {
populateAgentDropdown();
});
// Wiki
async function loadWiki() {
const res = await fetch('/api/wiki');
const pages = await res.json();
const wikiList = document.getElementById('wiki-list');
wikiList.innerHTML = '';
pages.forEach(page => {
const itemEl = document.createElement('div');
itemEl.className = 'wiki-item';
itemEl.innerHTML = `
<h4 class="wiki-title">${escapeHtml(page.filename.replace('.md', ''))}</h4>
<p class="wiki-date">${new Date(page.created).toLocaleDateString()}</p>
`;
itemEl.addEventListener('click', async () => {
// Mark active
wikiList.querySelectorAll('.wiki-item').forEach(i => i.classList.remove('active'));
itemEl.classList.add('active');
// Load content
const contentRes = await fetch(`/api/wiki/${page.filename}`);
const contentData = await contentRes.json();
const wikiContent = document.getElementById('wiki-content');
wikiContent.innerHTML = `<pre>${escapeHtml(contentData.content)}</pre>`;
});
wikiList.appendChild(itemEl);
});
}
// Agents
async function loadAgents() {
const res = await fetch('/api/agents');
const agents = await res.json();
const grid = document.getElementById('agents-grid');
grid.innerHTML = '';
agents.forEach(agent => {
const cardEl = document.createElement('div');
cardEl.className = 'agent-card';
cardEl.innerHTML = `
<div class="agent-header">
<h3 class="agent-name">${escapeHtml(agent.name)}</h3>
<span class="agent-status">${agent.status}</span>
</div>
<div class="agent-body">
<div class="agent-section">
<h4>📋 Current Task</h4>
<p class="agent-task">${agent.currentTask || 'No active task'}</p>
</div>
<div class="agent-section">
<h4>🛠️ Tools</h4>
<div class="agent-tools">
${agent.tools.map(tool => `<span class="tool-tag">${escapeHtml(tool)}</span>`).join('')}
</div>
</div>
<div class="agent-section">
<h4>📄 Workspace Files</h4>
<div class="agent-files">
${agent.files.map(file => `<span class="file-tag">${escapeHtml(file)}</span>`).join('')}
</div>
</div>
</div>
`;
grid.appendChild(cardEl);
});
}
// Usage
async function loadUsage() {
const res = await fetch('/api/usage');
const usage = await res.json();
const usageData = document.getElementById('usage-data');
usageData.innerHTML = `
<h3>📊 Provider Usage</h3>
<div class="usage-grid">
${usage.providers.map(provider => `
<div class="provider-card">
<h4>${escapeHtml(provider.name)}</h4>
<div class="model-list">
${provider.models.map(model => `
<div class="model-item">
<span class="model-name">${escapeHtml(model.name)}</span>
<span class="model-type">${escapeHtml(model.type)}</span>
<span class="model-context">${escapeHtml(model.contextWindow)}</span>
</div>
`).join('')}
</div>
</div>
`).join('')}
</div>
`;
}
// Helper function to escape HTML
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
// Initial load
loadTasks();

View File

@@ -1,146 +1,221 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenClaw Agent Fleet Dashboard</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<h1>🦞 OpenClaw Fleet Dashboard</h1>
</div>
<div class="nav-links">
<a href="#" class="nav-link active" data-page="dashboard">📋 Tasks</a>
<a href="#" class="nav-link" data-page="wiki">📚 Wiki</a>
<a href="#" class="nav-link" data-page="agents">🤖 Agents</a>
<a href="#" class="nav-link" data-page="usage">📊 Usage</a>
</div>
</nav>
<link rel="stylesheet" href="styles.css">
<!-- Marked.js for markdown rendering -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- Chart.js for usage charts -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
<div class="container">
<header>
<h1>🦞 OpenClaw Agent Fleet Dashboard</h1>
<nav>
<a href="#tasks" class="nav-link active" data-page="tasks">Tasks</a>
<a href="#wiki" class="nav-link" data-page="wiki">Wiki</a>
<a href="#agents" class="nav-link" data-page="agents">Agents</a>
<a href="#usage" class="nav-link" data-page="usage">Usage</a>
</nav>
</header>
<!-- Dashboard Page -->
<div id="page-dashboard" class="page active">
<header class="topbar">
<h2>Task Dashboard</h2>
<p>Real-time task coordination board</p>
</header>
<main>
<!-- TASKS PAGE -->
<section id="page-tasks" class="page active">
<div class="composer">
<h2>Create Task</h2>
<form id="task-form">
<input id="title" name="title" placeholder="Task title" required />
<select id="assignee" name="assignee">
<option value="">Select agent...</option>
</select>
<select id="priority" name="priority">
<option>Low</option>
<option selected>Medium</option>
<option>High</option>
<option>Critical</option>
</select>
<textarea id="description" name="description" placeholder="Task description" rows="3"></textarea>
<input id="tags" name="tags" placeholder="Tags (comma-separated)" />
<button type="submit">Create Task</button>
</form>
</div>
<section class="composer">
<h3>Create Task</h3>
<form id="task-form">
<input id="title" name="title" placeholder="Task title" required />
<select id="assignee" name="assignee">
<option value="">Select agent...</option>
</select>
<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>
<div id="board">
<div class="column" data-status="Backlog">
<div class="column-header">
<h3>📋 Backlog</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Todo">
<div class="column-header">
<h3>📝 Todo</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="In Progress">
<div class="column-header">
<h3>🔄 In Progress</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Review">
<div class="column-header">
<h3>👀 Review</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Done">
<div class="column-header">
<h3>✅ Done</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
</div>
</section>
<main id="board" class="board"></main>
<!-- WIKI PAGE -->
<section id="page-wiki" class="page">
<div class="wiki-container">
<div class="wiki-sidebar">
<div class="wiki-actions">
<button id="wiki-new-btn" class="btn-primary">+ New Page</button>
</div>
<div class="wiki-search">
<input type="text" id="wiki-search" placeholder="Search wiki...">
</div>
<div id="wiki-list" class="wiki-list"></div>
</div>
<div class="wiki-main">
<div class="wiki-toolbar">
<div class="wiki-page-title" id="wiki-page-title">Select a page</div>
<div class="wiki-page-actions" id="wiki-page-actions" style="display: none;">
<button id="wiki-edit-btn" class="btn-secondary">Edit</button>
<button id="wiki-delete-btn" class="btn-danger">Delete</button>
</div>
</div>
<div id="wiki-content" class="wiki-content">
<div class="wiki-placeholder">
<p>📚 Select a wiki page from the sidebar or create a new one.</p>
</div>
</div>
<div id="wiki-editor" class="wiki-editor" style="display: none;">
<input type="text" id="wiki-edit-title" placeholder="Page title">
<textarea id="wiki-edit-content" placeholder="Markdown content..."></textarea>
<div class="editor-actions">
<button id="wiki-save-btn" class="btn-primary">Save</button>
<button id="wiki-cancel-btn" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
</div>
</section>
<!-- AGENTS PAGE -->
<section id="page-agents" class="page">
<div class="agents-header">
<h2>Agent Fleet</h2>
<div class="agents-controls">
<input type="text" id="agent-search" placeholder="Search agents...">
<select id="agent-status-filter">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="busy">Busy</option>
<option value="idle">Idle</option>
</select>
</div>
</div>
<div id="agents-grid" class="agents-grid"></div>
<!-- Agent Details Modal -->
<div id="agent-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-agent-name">Agent Details</h3>
<button class="modal-close" id="modal-close">&times;</button>
</div>
<div class="modal-body" id="modal-agent-body"></div>
</div>
</div>
<!-- Assign Task Modal -->
<div id="assign-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Assign Task to <span id="assign-agent-name"></span></h3>
<button class="modal-close" id="assign-modal-close">&times;</button>
</div>
<div class="modal-body">
<select id="assign-task-select">
<option value="">Select a task...</option>
</select>
<button id="confirm-assign-btn" class="btn-primary">Assign Task</button>
</div>
</div>
</div>
</section>
<!-- USAGE PAGE -->
<section id="page-usage" class="page">
<div class="usage-header">
<h2>API Usage & Statistics</h2>
<div class="usage-controls">
<div class="date-range">
<label>From:</label>
<input type="date" id="usage-from">
<label>To:</label>
<input type="date" id="usage-to">
<button id="usage-apply-filter" class="btn-secondary">Apply</button>
</div>
<div class="export-actions">
<button id="export-json" class="btn-secondary">Export JSON</button>
<button id="export-csv" class="btn-secondary">Export CSV</button>
</div>
</div>
</div>
<div class="usage-stats">
<div class="stat-card">
<h4>Total Requests</h4>
<div class="stat-value" id="stat-requests">0</div>
</div>
<div class="stat-card">
<h4>Total Tokens</h4>
<div class="stat-value" id="stat-tokens">0</div>
</div>
<div class="stat-card">
<h4>Estimated Cost</h4>
<div class="stat-value" id="stat-cost">$0.00</div>
</div>
</div>
<div class="usage-charts">
<div class="chart-container">
<h4>Usage by Provider</h4>
<canvas id="chart-provider"></canvas>
</div>
<div class="chart-container">
<h4>Usage by Agent</h4>
<canvas id="chart-agent"></canvas>
</div>
</div>
<div id="usage-data" class="usage-details">
<h3>Provider Details</h3>
<div class="usage-grid" id="provider-grid"></div>
</div>
</section>
</main>
</div>
<!-- Wiki Page -->
<div id="page-wiki" class="page">
<header class="topbar">
<h2>📚 Wiki</h2>
<p>Task documentation and implementation details</p>
</header>
<div class="wiki-container">
<div class="wiki-list" id="wiki-list"></div>
<div class="wiki-content" id="wiki-content">
<p>Select a wiki page to view documentation</p>
</div>
</div>
</div>
<!-- Agents Page -->
<div id="page-agents" class="page">
<header class="topbar">
<h2>🤖 Agents</h2>
<p>Fleet agent workspace and configuration</p>
</header>
<div class="agents-grid" id="agents-grid"></div>
</div>
<!-- Usage Page -->
<div id="page-usage" class="page">
<header class="topbar">
<h2>📊 Usage & Quotas</h2>
<p>Provider models, quotas, and limits</p>
</header>
<div class="usage-container" id="usage-container"></div>
</div>
<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>
<input type="checkbox" class="card-check" />
Mark Complete
</label>
</article>
</template>
<template id="wiki-item-template">
<div class="wiki-item">
<h4 class="wiki-title"></h4>
<p class="wiki-date"></p>
</div>
</template>
<template id="agent-card-template">
<div class="agent-card">
<div class="agent-header">
<h3 class="agent-name"></h3>
<span class="agent-status"></span>
</div>
<div class="agent-body">
<div class="agent-section">
<h4>📋 Current Task</h4>
<p class="agent-task"></p>
</div>
<div class="agent-section">
<h4>🛠️ Tools</h4>
<div class="agent-tools"></div>
</div>
<div class="agent-section">
<h4>📄 Workspace Files</h4>
<div class="agent-files"></div>
</div>
</div>
</div>
</template>
<template id="usage-card-template">
<div class="usage-card">
<h3 class="provider-name"></h3>
<div class="provider-models"></div>
<div class="provider-quota"></div>
</div>
</template>
<script src="/app.js"></script>
</body>
<script src="app.js"></script>
</body>
</html>

97
public/index.html.backup Normal file
View File

@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenClaw Agent Fleet Dashboard</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>🦞 OpenClaw Agent Fleet Dashboard</h1>
<nav>
<a href="#tasks" class="nav-link active" data-page="tasks">Tasks</a>
<a href="#wiki" class="nav-link" data-page="wiki">Wiki</a>
<a href="#agents" class="nav-link" data-page="agents">Agents</a>
<a href="#usage" class="nav-link" data-page="usage">Usage</a>
</nav>
</header>
<main>
<section id="page-tasks" class="page active">
<div class="composer">
<h2>Create Task</h2>
<form id="task-form">
<input id="title" name="title" placeholder="Task title" required />
<select id="assignee" name="assignee">
<option value="">Select agent...</option>
</select>
<select id="priority" name="priority">
<option>Low</option>
<option selected>Medium</option>
<option>High</option>
<option>Critical</option>
</select>
<textarea id="description" name="description" placeholder="Task description" rows="3"></textarea>
<input id="tags" name="tags" placeholder="Tags (comma-separated)" />
<button type="submit">Create Task</button>
</form>
</div>
<div id="board">
<div class="column" data-status="Backlog">
<div class="column-header">
<h3>📋 Backlog</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Todo">
<div class="column-header">
<h3>📝 Todo</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="In Progress">
<div class="column-header">
<h3>🔄 In Progress</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Review">
<div class="column-header">
<h3>👀 Review</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Done">
<div class="column-header">
<h3>✅ Done</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
</div>
</section>
<section id="page-wiki" class="page">
<div id="wiki-list"></div>
<div id="wiki-content"></div>
</section>
<section id="page-agents" class="page">
<div id="agents-grid"></div>
</section>
<section id="page-usage" class="page">
<div id="usage-data"></div>
</section>
</main>
</div>
<script src="app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

451
public/styles.css.backup Normal file
View File

@@ -0,0 +1,451 @@
:root {
--bg: #1a1a1a;
--fg: #e0e0e0;
--border: #444;
--primary: #3498db;
--secondary: #2ecc71;
--danger: #e74c3c;
--warning: #f39c12;
--dark: #121212;
--light: #f0f0f0;
--card-bg: #2a2a2a;
--card-fg: #e0e0e0;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--bg);
color: var(--fg);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background-color: var(--card-bg);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
header h1 {
color: var(--primary);
margin-bottom: 15px;
}
nav {
display: flex;
gap: 15px;
}
.nav-link {
color: var(--fg);
text-decoration: none;
padding: 8px 16px;
border-radius: 6px;
transition: background-color 0.3s ease;
}
.nav-link:hover {
background-color: var(--border);
}
.nav-link.active {
background-color: var(--primary);
color: white;
}
.composer {
background-color: var(--card-bg);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.composer h2 {
color: var(--primary);
margin-bottom: 15px;
}
#task-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
#task-form input,
#task-form textarea,
#task-form select {
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background-color: var(--bg);
color: var(--fg);
font-size: 1rem;
}
#task-form textarea {
grid-column: span 2;
resize: vertical;
min-height: 100px;
}
#task-form button {
grid-column: span 2;
padding: 12px 24px;
background-color: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.3s ease;
}
#task-form button:hover {
background-color: #2980b9;
}
#board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.column {
background-color: var(--card-bg);
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.column-header {
padding: 15px;
background-color: var(--dark);
display: flex;
justify-content: space-between;
align-items: center;
}
.column-header h3 {
color: var(--fg);
}
.column-count {
background-color: var(--border);
color: var(--fg);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.9rem;
}
.cards {
padding: 15px;
min-height: 200px;
}
.card {
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.card-title {
color: var(--card-fg);
font-size: 1.1rem;
}
.badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: bold;
}
.badge.priority-Low {
background-color: var(--secondary);
color: white;
}
.badge.priority-Medium {
background-color: var(--warning);
color: white;
}
.badge.priority-High {
background-color: var(--danger);
color: white;
}
.badge.priority-Critical {
background-color: #9c27b0;
color: white;
}
.card-desc {
color: var(--fg);
margin-bottom: 10px;
}
.meta {
font-size: 0.9rem;
color: var(--border);
margin-bottom: 5px;
}
.meta.assignee {
font-weight: bold;
}
.tag {
display: inline-block;
background-color: var(--border);
color: var(--fg);
padding: 2px 6px;
border-radius: 4px;
margin-right: 5px;
margin-bottom: 5px;
}
.card-check {
margin-right: 10px;
}
/* Wiki */
#page-wiki {
background-color: var(--card-bg);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.wiki-item {
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 15px;
margin-bottom: 10px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.wiki-item:hover {
background-color: var(--dark);
}
.wiki-item.active {
background-color: var(--primary);
color: white;
}
.wiki-title {
color: var(--card-fg);
margin-bottom: 5px;
}
.wiki-date {
color: var(--border);
font-size: 0.9rem;
}
#wiki-content {
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 15px;
margin-top: 20px;
white-space: pre-wrap;
color: var(--fg);
}
/* Agents */
#page-agents {
background-color: var(--card-bg);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.agent-card {
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
}
.agent-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.agent-name {
color: var(--card-fg);
font-size: 1.1rem;
}
.agent-status {
background-color: var(--secondary);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
}
.agent-section {
margin-bottom: 15px;
}
.agent-section h4 {
color: var(--primary);
margin-bottom: 8px;
}
.agent-task {
color: var(--fg);
}
.agent-tools,
.agent-files {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.tool-tag,
.file-tag {
background-color: var(--border);
color: var(--fg);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8rem;
}
/* Usage */
#page-usage {
background-color: var(--card-bg);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.usage-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.provider-card {
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 15px;
}
.provider-card h4 {
color: var(--card-fg);
margin-bottom: 10px;
}
.model-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.model-item {
display: flex;
justify-content: space-between;
background-color: var(--dark);
padding: 8px;
border-radius: 4px;
}
.model-name {
color: var(--primary);
font-weight: bold;
}
.model-type,
.model-context {
color: var(--fg);
font-size: 0.9rem;
}
/* Agent Dropdown Styling */
#task-form select {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 1rem;
background: var(--bg);
color: var(--fg);
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
#task-form select:hover {
border-color: var(--primary);
}
#task-form select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
#task-form select option {
padding: 0.5rem;
}
/* Responsive Design */
@media (max-width: 768px) {
#task-form {
grid-template-columns: 1fr;
}
#task-form textarea {
grid-column: span 1;
}
#task-form button {
grid-column: span 1;
}
.usage-grid {
grid-template-columns: 1fr;
}
}

537
server.js
View File

@@ -34,6 +34,20 @@ db.serialize(() => {
completed_at TEXT
)
`);
// Usage tracking table
db.run(`
CREATE TABLE IF NOT EXISTS usage_tracking (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
request_type TEXT DEFAULT 'chat',
tokens_used INTEGER DEFAULT 0,
cost_estimate REAL DEFAULT 0,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
});
const app = express();
@@ -112,6 +126,8 @@ function validatePayload(body, partial = false) {
return errors;
}
// ============ TASKS API ============
app.get('/api/tasks', (req, res) => {
db.all('SELECT * FROM tasks ORDER BY id DESC', (err, rows) => {
if (err) {
@@ -238,6 +254,207 @@ app.patch('/api/tasks/:id', (req, res) => {
});
});
// ============ WIKI API ============
// Helper to extract frontmatter metadata from markdown
function extractMetadata(content) {
const metadata = {
title: '',
created: null,
modified: null,
tags: []
};
// Check for YAML-like frontmatter
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const frontmatter = frontmatterMatch[1];
const titleMatch = frontmatter.match(/title:\s*(.+)/i);
const createdMatch = frontmatter.match(/created:\s*(.+)/i);
const modifiedMatch = frontmatter.match(/modified:\s*(.+)/i);
const tagsMatch = frontmatter.match(/tags:\s*\[(.+)\]/i);
if (titleMatch) metadata.title = titleMatch[1].trim();
if (createdMatch) metadata.created = createdMatch[1].trim();
if (modifiedMatch) metadata.modified = modifiedMatch[1].trim();
if (tagsMatch) metadata.tags = tagsMatch[1].split(',').map(t => t.trim());
}
// Extract title from first heading if not in frontmatter
if (!metadata.title) {
const headingMatch = content.match(/^#\s+(.+)$/m);
if (headingMatch) {
metadata.title = headingMatch[1].trim();
}
}
return metadata;
}
// GET /api/wiki - List all wiki pages
app.get('/api/wiki', (req, res) => {
try {
if (!fs.existsSync(WIKI_DIR)) {
fs.mkdirSync(WIKI_DIR, { recursive: true });
return res.json([]);
}
const files = fs.readdirSync(WIKI_DIR)
.filter(f => f.endsWith('.md'))
.map(filename => {
const filePath = path.join(WIKI_DIR, filename);
const stats = fs.statSync(filePath);
const content = fs.readFileSync(filePath, 'utf8');
const metadata = extractMetadata(content);
return {
filename,
title: metadata.title || filename.replace('.md', '').replace(/-/g, ' '),
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString(),
tags: metadata.tags
};
})
.sort((a, b) => new Date(b.modified) - new Date(a.modified));
res.json(files);
} catch (err) {
console.error('Error listing wiki pages:', err);
res.status(500).json({ error: 'failed_to_list_wiki_pages' });
}
});
// GET /api/wiki/:filename - Get specific wiki page content
app.get('/api/wiki/:filename', (req, res) => {
try {
const filename = req.params.filename;
// Security: prevent path traversal
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return res.status(400).json({ error: 'invalid_filename' });
}
const filePath = path.join(WIKI_DIR, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'wiki_page_not_found' });
}
const content = fs.readFileSync(filePath, 'utf8');
const stats = fs.statSync(filePath);
const metadata = extractMetadata(content);
res.json({
filename,
content,
metadata: {
...metadata,
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString()
}
});
} catch (err) {
console.error('Error reading wiki page:', err);
res.status(500).json({ error: 'failed_to_read_wiki_page' });
}
});
// POST /api/wiki - Create new wiki page
app.post('/api/wiki', (req, res) => {
try {
const { title, content } = req.body;
if (!title || typeof title !== 'string' || title.trim().length === 0) {
return res.status(400).json({ error: 'title_is_required' });
}
const safeTitle = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
const timestamp = new Date().toISOString().slice(0, 10);
let filename = `${timestamp}-${safeTitle}.md`;
// Ensure unique filename
let counter = 1;
while (fs.existsSync(path.join(WIKI_DIR, filename))) {
filename = `${timestamp}-${safeTitle}-${counter}.md`;
counter++;
}
const filePath = path.join(WIKI_DIR, filename);
const pageContent = content || `# ${title}\n\n## Description\n\nEnter description here.\n\n## Implementation Status\n\n- [ ] Not started\n\n## Technical Details\n\nAdd technical notes here.\n`;
fs.writeFileSync(filePath, pageContent, 'utf8');
broadcast('wiki_created', { filename, title });
res.status(201).json({ filename, success: true, title });
} catch (err) {
console.error('Error creating wiki page:', err);
res.status(500).json({ error: 'failed_to_create_wiki_page' });
}
});
// PUT /api/wiki/:filename - Update wiki page
app.put('/api/wiki/:filename', (req, res) => {
try {
const filename = req.params.filename;
const { content } = req.body;
// Security: prevent path traversal
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return res.status(400).json({ error: 'invalid_filename' });
}
if (typeof content !== 'string') {
return res.status(400).json({ error: 'content_is_required' });
}
const filePath = path.join(WIKI_DIR, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'wiki_page_not_found' });
}
fs.writeFileSync(filePath, content, 'utf8');
broadcast('wiki_updated', { filename });
res.json({ success: true });
} catch (err) {
console.error('Error updating wiki page:', err);
res.status(500).json({ error: 'failed_to_update_wiki_page' });
}
});
// DELETE /api/wiki/:filename - Delete wiki page
app.delete('/api/wiki/:filename', (req, res) => {
try {
const filename = req.params.filename;
// Security: prevent path traversal
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return res.status(400).json({ error: 'invalid_filename' });
}
const filePath = path.join(WIKI_DIR, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'wiki_page_not_found' });
}
fs.unlinkSync(filePath);
broadcast('wiki_deleted', { filename });
res.json({ success: true });
} catch (err) {
console.error('Error deleting wiki page:', err);
res.status(500).json({ error: 'failed_to_delete_wiki_page' });
}
});
// ============ AGENTS API (Enhanced) ============
app.get('/api/agents', (req, res) => {
try {
const agents = [];
@@ -247,7 +464,47 @@ app.get('/api/agents', (req, res) => {
return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory();
});
agentDirs.forEach(agentName => {
// Get task counts per agent (workload) and completed tasks (history)
const getAgentTaskData = (agentName) => {
return new Promise((resolve) => {
const result = {
workload: 0,
activeTasks: [],
completedTasks: []
};
// Get workload (tasks in Todo, In Progress, Review)
db.all(
`SELECT * FROM tasks
WHERE assignee = ? AND status IN ('Todo', 'In Progress', 'Review')
ORDER BY priority DESC, created_at ASC`,
[agentName],
(err, activeRows) => {
if (!err && activeRows) {
result.workload = activeRows.length;
result.activeTasks = activeRows.map(normalizeTask);
}
// Get last 5 completed tasks
db.all(
`SELECT * FROM tasks
WHERE assignee = ? AND status = 'Done'
ORDER BY completed_at DESC
LIMIT 5`,
[agentName],
(err2, completedRows) => {
if (!err2 && completedRows) {
result.completedTasks = completedRows.map(normalizeTask);
}
resolve(result);
}
);
}
);
});
};
const agentPromises = agentDirs.map(async (agentName) => {
const agentPath = path.join(AGENTS_DIR, agentName);
const workspacePath = path.join(agentPath, 'workspace');
@@ -257,7 +514,11 @@ app.get('/api/agents', (req, res) => {
currentTask: null,
tools: [],
files: [],
permissions: []
permissions: [],
workload: 0,
activeTasks: [],
completedTasks: [],
capabilities: []
};
if (fs.existsSync(workspacePath)) {
@@ -267,36 +528,104 @@ app.get('/api/agents', (req, res) => {
const memoryPath = path.join(workspacePath, 'MEMORY.md');
if (fs.existsSync(memoryPath)) {
const memory = fs.readFileSync(memoryPath, 'utf8');
const toolMatches = memory.match(/##\\s+Tools([\\s\\S]*?)(?=##|$)/i);
const toolMatches = memory.match(/##\s+Tools([\s\S]*?)(?=##|$)/i);
if (toolMatches) {
agent.tools = toolMatches[1].split('\\n')
agent.tools = toolMatches[1].split('\n')
.filter(line => line.trim().startsWith('-'))
.map(line => line.replace(/^-\\s*/, '').trim());
.map(line => line.replace(/^-\s*/, '').trim());
}
// Extract capabilities/skills
const skillsMatch = memory.match(/##\s+Skills([\s\S]*?)(?=##|$)/i);
if (skillsMatch) {
agent.capabilities = skillsMatch[1].split('\n')
.filter(line => line.trim().startsWith('-'))
.map(line => line.replace(/^-\s*/, '').trim());
}
}
const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md');
if (fs.existsSync(heartbeatPath)) {
const heartbeat = fs.readFileSync(heartbeatPath, 'utf8');
const taskMatch = heartbeat.match(/Current Task:\\s*(.+)/i);
const taskMatch = heartbeat.match(/Current Task:\s*(.+)/i);
if (taskMatch) {
agent.currentTask = taskMatch[1].trim();
}
// Check last heartbeat time for status
const timeMatch = heartbeat.match(/Last Heartbeat:\s*(.+)/i);
if (timeMatch) {
const lastBeat = new Date(timeMatch[1]);
const now = new Date();
const minutesAgo = (now - lastBeat) / 1000 / 60;
if (minutesAgo > 30) {
agent.status = 'idle';
} else if (minutesAgo > 10) {
agent.status = 'busy';
}
}
}
}
// Get task data from database
const taskData = await getAgentTaskData(agentName);
agent.workload = taskData.workload;
agent.activeTasks = taskData.activeTasks;
agent.completedTasks = taskData.completedTasks;
agents.push(agent);
return agent;
});
}
res.json(agents);
Promise.all(agentPromises).then(results => {
res.json(results);
});
} else {
res.json([]);
}
} catch (err) {
console.error('Error reading agents:', err);
res.status(500).json({ error: 'failed_to_fetch_agents' });
}
});
// Usage endpoint
// POST /api/agents/:name/assign - Assign task to agent
app.post('/api/agents/:name/assign', (req, res) => {
const agentName = req.params.name;
const { taskId } = req.body;
if (!taskId) {
return res.status(400).json({ error: 'taskId_is_required' });
}
db.run(
'UPDATE tasks SET assignee = ?, updated_at = datetime("now") WHERE id = ?',
[agentName, taskId],
function(err) {
if (err) {
return res.status(500).json({ error: 'failed_to_assign_task' });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'task_not_found' });
}
db.get('SELECT * FROM tasks WHERE id = ?', [taskId], (fetchErr, row) => {
if (fetchErr || !row) {
return res.status(500).json({ error: 'failed_to_fetch_task' });
}
const task = normalizeTask(row);
broadcast('task_assigned', { agent: agentName, task });
res.json({ success: true, task });
});
}
);
});
// ============ USAGE API (Enhanced) ============
// GET /api/usage - Basic usage info (existing)
app.get('/api/usage', (req, res) => {
try {
const usage = {
@@ -343,7 +672,191 @@ app.get('/api/usage', (req, res) => {
}
});
// Heartbeat endpoint for agents
// GET /api/usage/stats - Usage statistics with date range
app.get('/api/usage/stats', (req, res) => {
const { from, to } = req.query;
let query = 'SELECT * FROM usage_tracking';
const params = [];
const conditions = [];
if (from) {
conditions.push('timestamp >= ?');
params.push(from);
}
if (to) {
conditions.push('timestamp <= ?');
params.push(to);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY timestamp DESC';
db.all(query, params, (err, rows) => {
if (err) {
console.error('Error fetching usage stats:', err);
return res.status(500).json({ error: 'failed_to_fetch_usage_stats' });
}
// Aggregate stats
const stats = {
totalRequests: rows.length,
totalTokens: rows.reduce((sum, r) => sum + (r.tokens_used || 0), 0),
totalCost: rows.reduce((sum, r) => sum + (r.cost_estimate || 0), 0),
byProvider: {},
byAgent: {},
byModel: {},
records: rows
};
rows.forEach(record => {
// By provider
if (!stats.byProvider[record.provider]) {
stats.byProvider[record.provider] = { requests: 0, tokens: 0, cost: 0 };
}
stats.byProvider[record.provider].requests++;
stats.byProvider[record.provider].tokens += record.tokens_used || 0;
stats.byProvider[record.provider].cost += record.cost_estimate || 0;
// By agent
if (!stats.byAgent[record.agent]) {
stats.byAgent[record.agent] = { requests: 0, tokens: 0, cost: 0 };
}
stats.byAgent[record.agent].requests++;
stats.byAgent[record.agent].tokens += record.tokens_used || 0;
stats.byAgent[record.agent].cost += record.cost_estimate || 0;
// By model
if (!stats.byModel[record.model]) {
stats.byModel[record.model] = { requests: 0, tokens: 0, cost: 0 };
}
stats.byModel[record.model].requests++;
stats.byModel[record.model].tokens += record.tokens_used || 0;
stats.byModel[record.model].cost += record.cost_estimate || 0;
});
res.json(stats);
});
});
// GET /api/usage/agents - Usage breakdown by agent
app.get('/api/usage/agents', (req, res) => {
const { from, to } = req.query;
let query = `
SELECT agent,
COUNT(*) as requests,
SUM(tokens_used) as tokens,
SUM(cost_estimate) as cost,
provider,
model
FROM usage_tracking
`;
const params = [];
const conditions = [];
if (from) {
conditions.push('timestamp >= ?');
params.push(from);
}
if (to) {
conditions.push('timestamp <= ?');
params.push(to);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' GROUP BY agent ORDER BY requests DESC';
db.all(query, params, (err, rows) => {
if (err) {
console.error('Error fetching agent usage:', err);
return res.status(500).json({ error: 'failed_to_fetch_agent_usage' });
}
res.json(rows);
});
});
// POST /api/usage/track - Track usage (for external callers)
app.post('/api/usage/track', (req, res) => {
const { agent, provider, model, requestType, tokensUsed, costEstimate } = req.body;
if (!agent || !provider || !model) {
return res.status(400).json({ error: 'agent, provider, and model are required' });
}
db.run(
`INSERT INTO usage_tracking (agent, provider, model, request_type, tokens_used, cost_estimate)
VALUES (?, ?, ?, ?, ?, ?)`,
[agent, provider, model, requestType || 'chat', tokensUsed || 0, costEstimate || 0],
function(err) {
if (err) {
console.error('Error tracking usage:', err);
return res.status(500).json({ error: 'failed_to_track_usage' });
}
res.status(201).json({ success: true, id: this.lastID });
}
);
});
// GET /api/usage/export - Export usage data
app.get('/api/usage/export', (req, res) => {
const { format = 'json', from, to } = req.query;
let query = 'SELECT * FROM usage_tracking';
const params = [];
const conditions = [];
if (from) {
conditions.push('timestamp >= ?');
params.push(from);
}
if (to) {
conditions.push('timestamp <= ?');
params.push(to);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY timestamp DESC';
db.all(query, params, (err, rows) => {
if (err) {
console.error('Error exporting usage:', err);
return res.status(500).json({ error: 'failed_to_export_usage' });
}
if (format === 'csv') {
const csv = [
'id,agent,provider,model,request_type,tokens_used,cost_estimate,timestamp',
...rows.map(r => `${r.id},${r.agent},${r.provider},${r.model},${r.request_type},${r.tokens_used},${r.cost_estimate},${r.timestamp}`)
].join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="usage-export.csv"');
res.send(csv);
} else {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', 'attachment; filename="usage-export.json"');
res.json(rows);
}
});
});
// ============ HEARTBEAT ============
app.get('/api/heartbeat/:agent', (req, res) => {
const agent = req.params.agent;
@@ -365,6 +878,8 @@ app.get('/api/heartbeat/:agent', (req, res) => {
);
});
// ============ WEBSOCKET ============
wss.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } }));
});

374
server.js.backup Normal file
View File

@@ -0,0 +1,374 @@
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 AGENTS_DIR = process.env.AGENTS_DIR || '/home/bear/.openclaw/agents';
const OPENCLAW_CONFIG = process.env.OPENCLAW_CONFIG || '/home/bear/.openclaw/openclaw.json';
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);
});
});
});
});
app.get('/api/agents', (req, res) => {
try {
const agents = [];
if (fs.existsSync(AGENTS_DIR)) {
const agentDirs = fs.readdirSync(AGENTS_DIR).filter(d => {
return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory();
});
agentDirs.forEach(agentName => {
const agentPath = path.join(AGENTS_DIR, agentName);
const workspacePath = path.join(agentPath, 'workspace');
const agent = {
name: agentName,
status: 'active',
currentTask: null,
tools: [],
files: [],
permissions: []
};
if (fs.existsSync(workspacePath)) {
const files = fs.readdirSync(workspacePath);
agent.files = files.filter(f => f.endsWith('.md'));
const memoryPath = path.join(workspacePath, 'MEMORY.md');
if (fs.existsSync(memoryPath)) {
const memory = fs.readFileSync(memoryPath, 'utf8');
const toolMatches = memory.match(/##\\s+Tools([\\s\\S]*?)(?=##|$)/i);
if (toolMatches) {
agent.tools = toolMatches[1].split('\\n')
.filter(line => line.trim().startsWith('-'))
.map(line => line.replace(/^-\\s*/, '').trim());
}
}
const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md');
if (fs.existsSync(heartbeatPath)) {
const heartbeat = fs.readFileSync(heartbeatPath, 'utf8');
const taskMatch = heartbeat.match(/Current Task:\\s*(.+)/i);
if (taskMatch) {
agent.currentTask = taskMatch[1].trim();
}
}
}
agents.push(agent);
});
}
res.json(agents);
} catch (err) {
console.error('Error reading agents:', err);
res.status(500).json({ error: 'failed_to_fetch_agents' });
}
});
// Usage endpoint
app.get('/api/usage', (req, res) => {
try {
const usage = {
providers: [],
lastUpdated: new Date().toISOString()
};
if (fs.existsSync(OPENCLAW_CONFIG)) {
const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8'));
if (config.models) {
const providerMap = {};
Object.entries(config.models).forEach(([modelName, modelConfig]) => {
const provider = modelConfig.provider || 'unknown';
if (!providerMap[provider]) {
providerMap[provider] = {
name: provider,
models: [],
quota: {
requests: 0,
tokens: 0,
limit: 'unlimited'
}
};
}
providerMap[provider].models.push({
name: modelName,
type: modelConfig.type || 'chat',
contextWindow: modelConfig.context_window || 'unknown'
});
});
usage.providers = Object.values(providerMap);
}
}
res.json(usage);
} catch (err) {
console.error('Error reading usage:', err);
res.status(500).json({ error: 'failed_to_fetch_usage' });
}
});
// Heartbeat endpoint for agents
app.get('/api/heartbeat/:agent', (req, res) => {
const agent = req.params.agent;
db.all(
'SELECT * FROM tasks WHERE assignee = ? AND status IN (?, ?, ?) ORDER BY priority DESC, created_at ASC',
[agent, 'Todo', 'In Progress', 'Review'],
(err, rows) => {
if (err) {
return res.status(500).json({ error: 'failed_to_fetch_tasks' });
}
const tasks = rows.map(normalizeTask);
res.json({
agent,
pending_tasks: tasks.length,
tasks
});
}
);
});
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}`);
});