1 Commits

Author SHA1 Message Date
4fab91af2f feat: add dark mode support 2026-03-03 16:54:23 -08:00
14 changed files with 853 additions and 4262 deletions

View File

@@ -1,89 +1,84 @@
// ============ THEME ============
const THEME_STORAGE_KEY = 'agentdash-theme';
const themeToggleBtn = document.getElementById('theme-toggle');
const systemThemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
function getSystemTheme() {
return systemThemeMedia.matches ? 'dark' : 'light';
function getPreferredTheme() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
return savedTheme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function getSavedTheme() {
try {
const saved = localStorage.getItem(THEME_STORAGE_KEY);
return saved === 'light' || saved === 'dark' ? saved : null;
} catch {
return null;
function setTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
document.getElementById('theme-toggle').textContent = '☀️';
} else {
document.documentElement.removeAttribute('data-theme');
document.getElementById('theme-toggle').textContent = '🌙';
}
localStorage.setItem('theme', theme);
}
function setSavedTheme(theme) {
try {
localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// Ignore localStorage errors (privacy mode, quota, etc.).
}
}
setTheme(getPreferredTheme());
function updateThemeToggleLabel() {
if (!themeToggleBtn) return;
const isDarkTheme = document.documentElement.getAttribute('data-theme') === 'dark';
const nextTheme = isDarkTheme ? 'light' : 'dark';
themeToggleBtn.textContent = isDarkTheme ? 'Light Mode' : 'Dark Mode';
themeToggleBtn.setAttribute('aria-label', `Switch to ${nextTheme} mode`);
}
function applyTheme(theme, { persist = false } = {}) {
document.documentElement.setAttribute('data-theme', theme);
if (persist) setSavedTheme(theme);
updateThemeToggleLabel();
if (usageStats) renderUsageCharts();
}
function initTheme() {
const savedTheme = getSavedTheme();
applyTheme(savedTheme || getSystemTheme());
if (themeToggleBtn) {
themeToggleBtn.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme') || getSystemTheme();
const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
applyTheme(nextTheme, { persist: true });
document.getElementById('theme-toggle').addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
});
}
systemThemeMedia.addEventListener('change', (event) => {
if (!getSavedTheme()) {
applyTheme(event.matches ? 'dark' : 'light');
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
});
}
// ============ STATE ============
// 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: [] },
'Backlog': { title: '📋 Backlog', tasks: [] },
'Todo': { title: '📝 Todo', tasks: [] },
'In Progress': { title: '🔄 In Progress', tasks: [] },
Review: { title: '👀 Review', tasks: [] },
Done: { title: '✅ Done', tasks: [] },
'Review': { title: '👀 Review', tasks: [] },
'Done': { title: '✅ Done', tasks: [] }
};
let wikiPages = [];
let currentWikiPage = null;
let allAgents = [];
let usageStats = null;
let providerChart = null;
let agentChart = null;
// ============ TASK DASHBOARD ============
async function loadTasks() {
const res = await fetch('/api/tasks');
const tasks = await res.json();
Object.keys(COLUMNS).forEach((status) => {
// Reset columns
Object.keys(COLUMNS).forEach(status => {
COLUMNS[status].tasks = [];
});
tasks.forEach((task) => {
// Group tasks by status
tasks.forEach(task => {
if (COLUMNS[task.status]) {
COLUMNS[task.status].tasks.push(task);
}
@@ -94,8 +89,6 @@ async function loadTasks() {
function renderBoard() {
const board = document.getElementById('board');
if (!board) return;
board.innerHTML = '';
Object.entries(COLUMNS).forEach(([status, column]) => {
@@ -112,7 +105,7 @@ function renderBoard() {
const cardsEl = columnEl.querySelector('.cards');
column.tasks.forEach((task) => {
column.tasks.forEach(task => {
const cardEl = document.createElement('div');
cardEl.className = 'card';
cardEl.innerHTML = `
@@ -122,19 +115,20 @@ function renderBoard() {
</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>
<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' }),
body: JSON.stringify({ status: 'Done' })
});
loadTasks();
});
@@ -146,284 +140,79 @@ function renderBoard() {
});
}
async function populateAgentDropdown() {
try {
const res = await fetch('/api/agents');
const agents = await res.json();
const select = document.getElementById('assignee');
if (!select) return;
const firstOption = select.options[0];
select.innerHTML = '';
select.appendChild(firstOption);
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);
}
}
function initTasksPage() {
const taskForm = document.getElementById('task-form');
if (!taskForm) return;
taskForm.addEventListener('submit', async (e) => {
// Task form
document.getElementById('task-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const tagsValue = formData.get('tags');
const task = {
title: formData.get('title'),
description: formData.get('description'),
assignee: formData.get('assignee'),
priority: formData.get('priority'),
status: formData.get('status') || 'Backlog',
tags: (tagsValue || '').split(',').map((t) => t.trim()).filter((t) => t),
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),
body: JSON.stringify(task)
});
e.target.reset();
loadTasks();
});
populateAgentDropdown();
loadTasks();
}
// ============ WIKI ============
// Wiki
async function loadWiki() {
try {
const res = await fetch('/api/wiki');
wikiPages = await res.json();
renderWikiList();
} catch (err) {
console.error('Failed to load wiki:', err);
}
}
const pages = await res.json();
function renderWikiList(filter = '') {
const wikiList = document.getElementById('wiki-list');
if (!wikiList) return;
wikiList.innerHTML = '';
const filtered = filter
? wikiPages.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase()) || p.filename.toLowerCase().includes(filter.toLowerCase()))
: wikiPages;
filtered.forEach((page) => {
pages.forEach(page => {
const itemEl = document.createElement('div');
itemEl.className = `wiki-item${currentWikiPage && currentWikiPage.filename === page.filename ? ' active' : ''}`;
itemEl.className = 'wiki-item';
itemEl.innerHTML = `
<h4 class="wiki-title">${escapeHtml(page.title)}</h4>
<p class="wiki-date">${new Date(page.modified).toLocaleDateString()}</p>
<h4 class="wiki-title">${escapeHtml(page.filename.replace('.md', ''))}</h4>
<p class="wiki-date">${new Date(page.created).toLocaleDateString()}</p>
`;
itemEl.addEventListener('click', () => selectWikiPage(page.filename));
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);
});
}
async function selectWikiPage(filename) {
try {
const res = await fetch(`/api/wiki/${filename}`);
if (!res.ok) throw new Error('Page not found');
currentWikiPage = await res.json();
const titleEl = document.getElementById('wiki-page-title');
const actionsEl = document.getElementById('wiki-page-actions');
const contentEl = document.getElementById('wiki-content');
const editorEl = document.getElementById('wiki-editor');
const searchEl = document.getElementById('wiki-search');
if (!titleEl || !actionsEl || !contentEl || !editorEl || !searchEl) return;
titleEl.textContent = currentWikiPage.metadata.title || filename;
actionsEl.style.display = 'flex';
contentEl.style.display = 'block';
editorEl.style.display = 'none';
if (typeof marked !== 'undefined') {
contentEl.innerHTML = marked.parse(currentWikiPage.content);
} else {
contentEl.innerHTML = `<pre>${escapeHtml(currentWikiPage.content)}</pre>`;
}
renderWikiList(searchEl.value);
} catch (err) {
console.error('Failed to load wiki page:', err);
}
}
function initWikiPage() {
const searchInput = document.getElementById('wiki-search');
const newBtn = document.getElementById('wiki-new-btn');
const editBtn = document.getElementById('wiki-edit-btn');
const saveBtn = document.getElementById('wiki-save-btn');
const cancelBtn = document.getElementById('wiki-cancel-btn');
const deleteBtn = document.getElementById('wiki-delete-btn');
if (!searchInput || !newBtn || !editBtn || !saveBtn || !cancelBtn || !deleteBtn) return;
searchInput.addEventListener('input', (e) => {
renderWikiList(e.target.value);
});
newBtn.addEventListener('click', async () => {
const title = prompt('Enter page title:');
if (!title) return;
try {
const res = await fetch('/api/wiki', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (res.ok) {
const data = await res.json();
await loadWiki();
selectWikiPage(data.filename);
}
} catch (err) {
console.error('Failed to create wiki page:', err);
}
});
editBtn.addEventListener('click', () => {
if (!currentWikiPage) return;
const contentEl = document.getElementById('wiki-content');
const editorEl = document.getElementById('wiki-editor');
const titleEl = document.getElementById('wiki-edit-title');
const editContentEl = document.getElementById('wiki-edit-content');
if (!contentEl || !editorEl || !titleEl || !editContentEl) return;
contentEl.style.display = 'none';
editorEl.style.display = 'block';
titleEl.value = currentWikiPage.metadata.title || '';
editContentEl.value = currentWikiPage.content;
});
saveBtn.addEventListener('click', async () => {
if (!currentWikiPage) return;
const editContentEl = document.getElementById('wiki-edit-content');
if (!editContentEl) return;
try {
const res = await fetch(`/api/wiki/${currentWikiPage.filename}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editContentEl.value }),
});
if (res.ok) {
await selectWikiPage(currentWikiPage.filename);
}
} catch (err) {
console.error('Failed to save wiki page:', err);
}
});
cancelBtn.addEventListener('click', () => {
const editorEl = document.getElementById('wiki-editor');
const contentEl = document.getElementById('wiki-content');
if (!editorEl || !contentEl) return;
editorEl.style.display = 'none';
contentEl.style.display = 'block';
});
deleteBtn.addEventListener('click', async () => {
if (!currentWikiPage) return;
if (!confirm(`Delete "${currentWikiPage.metadata.title || currentWikiPage.filename}"?`)) return;
try {
const res = await fetch(`/api/wiki/${currentWikiPage.filename}`, {
method: 'DELETE',
});
if (res.ok) {
currentWikiPage = null;
const pageTitle = document.getElementById('wiki-page-title');
const pageActions = document.getElementById('wiki-page-actions');
const wikiContent = document.getElementById('wiki-content');
if (pageTitle) pageTitle.textContent = 'Select a page';
if (pageActions) pageActions.style.display = 'none';
if (wikiContent) {
wikiContent.innerHTML = '<div class="wiki-placeholder"><p>📚 Select a wiki page from the sidebar or create a new one.</p></div>';
}
await loadWiki();
}
} catch (err) {
console.error('Failed to delete wiki page:', err);
}
});
loadWiki();
}
// ============ AGENTS ============
// Agents
async function loadAgents() {
try {
const res = await fetch('/api/agents');
allAgents = await res.json();
renderAgents();
} catch (err) {
console.error('Failed to load agents:', err);
}
}
const agents = await res.json();
function renderAgents(filter = '', statusFilter = '') {
const grid = document.getElementById('agents-grid');
if (!grid) return;
grid.innerHTML = '';
let filtered = allAgents;
if (filter) {
filtered = filtered.filter((a) =>
a.name.toLowerCase().includes(filter.toLowerCase()) ||
(a.currentTask && a.currentTask.toLowerCase().includes(filter.toLowerCase()))
);
}
if (statusFilter) {
filtered = filtered.filter((a) => a.status === statusFilter);
}
filtered.forEach((agent) => {
agents.forEach(agent => {
const cardEl = document.createElement('div');
cardEl.className = 'agent-card';
const statusClass = `status-${agent.status}`;
cardEl.innerHTML = `
<div class="agent-header">
<h3 class="agent-name">${escapeHtml(agent.name)}</h3>
<span class="agent-status ${statusClass}">${agent.status}</span>
</div>
<div class="agent-workload">
<span class="workload-badge">📋 ${agent.workload} active task${agent.workload !== 1 ? 's' : ''}</span>
<span class="agent-status">${agent.status}</span>
</div>
<div class="agent-body">
<div class="agent-section">
@@ -433,388 +222,84 @@ function renderAgents(filter = '', statusFilter = '') {
<div class="agent-section">
<h4>🛠️ Tools</h4>
<div class="agent-tools">
${agent.tools.length ? agent.tools.slice(0, 5).map((tool) => `<span class="tool-tag">${escapeHtml(tool)}</span>`).join('') : '<span class="no-data">No tools</span>'}
${agent.tools.length > 5 ? `<span class="more-tag">+${agent.tools.length - 5} more</span>` : ''}
${agent.tools.map(tool => `<span class="tool-tag">${escapeHtml(tool)}</span>`).join('')}
</div>
</div>
<div class="agent-section">
<h4>📄 Recent Files</h4>
<h4>📄 Workspace Files</h4>
<div class="agent-files">
${agent.files.length ? agent.files.slice(0, 5).map((file) => `<span class="file-tag">${escapeHtml(file)}</span>`).join('') : '<span class="no-data">No files</span>'}
${agent.files.map(file => `<span class="file-tag">${escapeHtml(file)}</span>`).join('')}
</div>
</div>
</div>
<div class="agent-actions">
<button class="btn-secondary agent-details-btn" data-agent="${escapeHtml(agent.name)}">Details</button>
<button class="btn-primary agent-assign-btn" data-agent="${escapeHtml(agent.name)}">Assign Task</button>
</div>
`;
cardEl.querySelector('.agent-details-btn').addEventListener('click', () => showAgentDetails(agent));
cardEl.querySelector('.agent-assign-btn').addEventListener('click', () => showAssignModal(agent.name));
grid.appendChild(cardEl);
});
}
function showAgentDetails(agent) {
const modal = document.getElementById('agent-modal');
const body = document.getElementById('modal-agent-body');
const title = document.getElementById('modal-agent-name');
if (!modal || !body || !title) return;
// Usage
async function loadUsage() {
const res = await fetch('/api/usage');
const usage = await res.json();
title.textContent = agent.name;
const container = document.getElementById('usage-container');
container.innerHTML = '';
body.innerHTML = `
<div class="detail-section">
<h4>Status</h4>
<p><span class="agent-status status-${agent.status}">${agent.status}</span></p>
</div>
<div class="detail-section">
<h4>Workload</h4>
<p>${agent.workload} active task${agent.workload !== 1 ? 's' : ''}</p>
</div>
<div class="detail-section">
<h4>Current Task</h4>
<p>${agent.currentTask || 'No active task'}</p>
</div>
<div class="detail-section">
<h4>Active Tasks</h4>
<ul class="task-list">
${agent.activeTasks.length
? agent.activeTasks.map((t) => `<li><strong>${escapeHtml(t.title)}</strong> <span class="badge priority-${t.priority}">${t.priority}</span></li>`).join('')
: '<li class="no-data">No active tasks</li>'}
</ul>
</div>
<div class="detail-section">
<h4>Recently Completed</h4>
<ul class="task-list">
${agent.completedTasks.length
? agent.completedTasks.map((t) => `<li>${escapeHtml(t.title)}</li>`).join('')
: '<li class="no-data">No completed tasks</li>'}
</ul>
</div>
<div class="detail-section">
<h4>Tools</h4>
<div class="tag-list">${agent.tools.length ? agent.tools.map((t) => `<span class="tool-tag">${escapeHtml(t)}</span>`).join('') : '<span class="no-data">No tools</span>'}</div>
</div>
<div class="detail-section">
<h4>Capabilities</h4>
<div class="tag-list">${agent.capabilities.length ? agent.capabilities.map((c) => `<span class="capability-tag">${escapeHtml(c)}</span>`).join('') : '<span class="no-data">No capabilities defined</span>'}</div>
</div>
`;
modal.classList.add('active');
}
async function showAssignModal(agentName) {
const modal = document.getElementById('assign-modal');
const agentNameEl = document.getElementById('assign-agent-name');
const select = document.getElementById('assign-task-select');
if (!modal || !agentNameEl || !select) return;
agentNameEl.textContent = agentName;
try {
const res = await fetch('/api/tasks');
const tasks = await res.json();
const unassignedTasks = tasks.filter((t) => t.status !== 'Done' && (!t.assignee || t.assignee === ''));
select.innerHTML = '<option value="">Select a task...</option>';
unassignedTasks.forEach((task) => {
const option = document.createElement('option');
option.value = task.id;
option.textContent = `${task.title} (${task.priority})`;
select.appendChild(option);
});
select.dataset.agent = agentName;
modal.classList.add('active');
} catch (err) {
console.error('Failed to load tasks for assignment:', err);
}
}
function initAgentsPage() {
const searchInput = document.getElementById('agent-search');
const statusFilter = document.getElementById('agent-status-filter');
const closeBtn = document.getElementById('modal-close');
const assignCloseBtn = document.getElementById('assign-modal-close');
const confirmAssignBtn = document.getElementById('confirm-assign-btn');
if (!searchInput || !statusFilter || !closeBtn || !assignCloseBtn || !confirmAssignBtn) return;
searchInput.addEventListener('input', (e) => {
renderAgents(e.target.value, statusFilter.value);
});
statusFilter.addEventListener('change', (e) => {
renderAgents(searchInput.value, e.target.value);
});
closeBtn.addEventListener('click', () => {
const modal = document.getElementById('agent-modal');
if (modal) modal.classList.remove('active');
});
assignCloseBtn.addEventListener('click', () => {
const modal = document.getElementById('assign-modal');
if (modal) modal.classList.remove('active');
});
confirmAssignBtn.addEventListener('click', async () => {
const select = document.getElementById('assign-task-select');
if (!select) return;
const taskId = select.value;
const agentName = select.dataset.agent;
if (!taskId) {
alert('Please select a task');
if (usage.providers.length === 0) {
container.innerHTML = '<p style="padding: 2rem; color: var(--text-secondary);">No provider data available</p>';
return;
}
try {
const res = await fetch(`/api/agents/${agentName}/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId: Number.parseInt(taskId, 10) }),
});
usage.providers.forEach(provider => {
const cardEl = document.createElement('div');
cardEl.className = 'usage-card';
if (res.ok) {
const modal = document.getElementById('assign-modal');
if (modal) modal.classList.remove('active');
await loadAgents();
}
} catch (err) {
console.error('Failed to assign task:', err);
}
});
loadAgents();
}
// ============ USAGE ============
async function loadUsage() {
const from = document.getElementById('usage-from')?.value;
const to = document.getElementById('usage-to')?.value;
let statsUrl = '/api/usage/stats';
const params = [];
if (from) params.push(`from=${from}`);
if (to) params.push(`to=${to}`);
if (params.length) statsUrl += `?${params.join('&')}`;
try {
const statsRes = await fetch(statsUrl);
usageStats = await statsRes.json();
const usageRes = await fetch('/api/usage');
const usageData = await usageRes.json();
renderUsageStats();
renderUsageCharts();
renderProviderDetails(usageData);
} catch (err) {
console.error('Failed to load usage:', err);
}
}
function renderUsageStats() {
const requestsEl = document.getElementById('stat-requests');
const tokensEl = document.getElementById('stat-tokens');
const costEl = document.getElementById('stat-cost');
if (!requestsEl || !tokensEl || !costEl || !usageStats) return;
requestsEl.textContent = usageStats.totalRequests.toLocaleString();
tokensEl.textContent = usageStats.totalTokens.toLocaleString();
costEl.textContent = `$${usageStats.totalCost.toFixed(2)}`;
}
function renderUsageCharts() {
if (!usageStats || typeof Chart === 'undefined') return;
const providerCanvas = document.getElementById('chart-provider');
const agentCanvas = document.getElementById('chart-agent');
if (!providerCanvas || !agentCanvas) return;
const providerCtx = providerCanvas.getContext('2d');
const agentCtx = agentCanvas.getContext('2d');
if (!providerCtx || !agentCtx) return;
if (providerChart) providerChart.destroy();
const providerLabels = Object.keys(usageStats.byProvider);
const providerData = providerLabels.map((p) => usageStats.byProvider[p].requests);
providerChart = new Chart(providerCtx, {
type: 'doughnut',
data: {
labels: providerLabels,
datasets: [{
data: providerData,
backgroundColor: ['#3498db', '#2ecc71', '#e74c3c', '#f39c12', '#9c27b0', '#00bcd4'],
}],
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: { color: '#e0e0e0' },
},
},
},
});
if (agentChart) agentChart.destroy();
const agentLabels = Object.keys(usageStats.byAgent);
const agentData = agentLabels.map((a) => usageStats.byAgent[a].requests);
agentChart = new Chart(agentCtx, {
type: 'bar',
data: {
labels: agentLabels,
datasets: [{
label: 'Requests',
data: agentData,
backgroundColor: '#3498db',
}],
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true,
ticks: { color: '#e0e0e0' },
grid: { color: '#444' },
},
x: {
ticks: { color: '#e0e0e0' },
grid: { color: '#444' },
},
},
},
});
}
function renderProviderDetails(usageData) {
const grid = document.getElementById('provider-grid');
if (!grid || !usageData || !usageStats) return;
grid.innerHTML = '';
usageData.providers.forEach((provider) => {
const providerEl = document.createElement('div');
providerEl.className = 'provider-card';
const providerUsage = usageStats.byProvider[provider.name] || { requests: 0, tokens: 0, cost: 0 };
providerEl.innerHTML = `
<h4>${escapeHtml(provider.name)}</h4>
<div class="provider-stats">
<div class="provider-stat">
<span class="stat-label">Requests</span>
<span class="stat-value">${providerUsage.requests.toLocaleString()}</span>
</div>
<div class="provider-stat">
<span class="stat-label">Tokens</span>
<span class="stat-value">${providerUsage.tokens.toLocaleString()}</span>
</div>
<div class="provider-stat">
<span class="stat-label">Cost</span>
<span class="stat-value">$${providerUsage.cost.toFixed(2)}</span>
</div>
</div>
<div class="model-list">
<h5>Models</h5>
${provider.models.map((model) => `
cardEl.innerHTML = `
<h3 class="provider-name">${escapeHtml(provider.name)}</h3>
<div class="provider-models">
${provider.models.map(model => `
<div class="model-item">
<span class="model-name">${escapeHtml(model.name)}</span>
<span class="model-type">${escapeHtml(model.type)}</span>
<div class="model-name">${escapeHtml(model.name)}</div>
<div class="model-meta">Type: ${model.type} | Context: ${model.contextWindow}</div>
</div>
`).join('')}
</div>
<div class="provider-quota">
<div class="quota-title">Quota & Limits</div>
<div class="quota-item">
<span class="quota-label">Requests</span>
<span class="quota-value">${provider.quota.requests} / ${provider.quota.limit}</span>
</div>
<div class="quota-item">
<span class="quota-label">Tokens</span>
<span class="quota-value">${provider.quota.tokens}</span>
</div>
<div class="quota-bar">
<div class="quota-fill" style="width: 0%"></div>
</div>
</div>
`;
grid.appendChild(providerEl);
container.appendChild(cardEl);
});
}
function initUsagePage() {
const fromInput = document.getElementById('usage-from');
const toInput = document.getElementById('usage-to');
const applyBtn = document.getElementById('usage-apply-filter');
const exportJsonBtn = document.getElementById('export-json');
const exportCsvBtn = document.getElementById('export-csv');
if (!fromInput || !toInput || !applyBtn || !exportJsonBtn || !exportCsvBtn) return;
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 30);
fromInput.value = from.toISOString().split('T')[0];
toInput.value = to.toISOString().split('T')[0];
applyBtn.addEventListener('click', loadUsage);
exportJsonBtn.addEventListener('click', () => {
const fromValue = fromInput.value;
const toValue = toInput.value;
let url = '/api/usage/export?format=json';
if (fromValue) url += `&from=${fromValue}`;
if (toValue) url += `&to=${toValue}`;
window.open(url, '_blank');
});
exportCsvBtn.addEventListener('click', () => {
const fromValue = fromInput.value;
const toValue = toInput.value;
let url = '/api/usage/export?format=csv';
if (fromValue) url += `&from=${fromValue}`;
if (toValue) url += `&to=${toValue}`;
window.open(url, '_blank');
});
loadUsage();
}
// ============ HELPERS ============
// Utility functions
function escapeHtml(text) {
if (typeof text !== 'string') return '';
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize
loadTasks();
// WebSocket for real-time updates
const ws = new WebSocket(`ws://${window.location.host}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'task_created' || data.type === 'task_updated') {
loadTasks();
}
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
function setupModalBackdropClose() {
document.querySelectorAll('.modal').forEach((modal) => {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.remove('active');
}
});
});
}
// ============ INITIALIZATION ============
document.addEventListener('DOMContentLoaded', () => {
initTheme();
setupModalBackdropClose();
if (CURRENT_PAGE === 'tasks') initTasksPage();
if (CURRENT_PAGE === 'wiki') initWikiPage();
if (CURRENT_PAGE === 'agents') initAgentsPage();
if (CURRENT_PAGE === 'usage') initUsagePage();
});

View File

@@ -1,279 +0,0 @@
// 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,222 +1,145 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<!-- 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>
<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>
<button id="theme-toggle" class="btn-secondary" type="button" aria-label="Toggle theme">Dark Mode</button>
<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>
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">🌙</button>
</div>
</nav>
<!-- 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>
<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>
<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>
<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>
<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>
</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>
<!-- WIKI PAGE -->
<section id="page-wiki" class="page">
<main id="board" class="board"></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-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 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>
<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>
<!-- 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>
<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>
<!-- 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>
<div id="usage-data" class="usage-details">
<h3>Provider Details</h3>
<div class="usage-grid" id="provider-grid"></div>
<template id="task-template">
<article class="card">
<div class="card-head">
<h3 class="card-title"></h3>
<span class="badge priority"></span>
</div>
</section>
</main>
<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>
<script src="app.js"></script>
</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>
</html>

View File

@@ -1,97 +0,0 @@
<!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

View File

@@ -1,451 +0,0 @@
: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;
}
}

745
server.js
View File

@@ -34,81 +34,14 @@ 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();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
const VIEWS_DIR = path.join(__dirname, 'views');
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public'), { index: false }));
function renderTemplate(template, vars = {}) {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
const value = vars[key];
return value === undefined || value === null ? '' : String(value);
});
}
function renderPage(viewName, activeTab, pageTitle) {
const layoutPath = path.join(VIEWS_DIR, 'layout.html');
const viewPath = path.join(VIEWS_DIR, `${viewName}.html`);
const layout = fs.readFileSync(layoutPath, 'utf8');
const content = fs.readFileSync(viewPath, 'utf8');
return renderTemplate(layout, {
pageTitle,
pageName: viewName,
content,
tasksActive: activeTab === 'tasks' ? 'active' : '',
wikiActive: activeTab === 'wiki' ? 'active' : '',
agentsActive: activeTab === 'agents' ? 'active' : '',
usageActive: activeTab === 'usage' ? 'active' : '',
markedScript: viewName === 'wiki'
? '<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>'
: '',
chartScript: viewName === 'usage'
? '<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>'
: '',
});
}
// ============ SERVER-RENDERED PAGES ============
app.get('/', (req, res) => {
res.redirect('/tasks');
});
app.get('/tasks', (req, res) => {
res.send(renderPage('tasks', 'tasks', 'OpenClaw Agent Fleet Dashboard - Tasks'));
});
app.get('/wiki', (req, res) => {
res.send(renderPage('wiki', 'wiki', 'OpenClaw Agent Fleet Dashboard - Wiki'));
});
app.get('/agents', (req, res) => {
res.send(renderPage('agents', 'agents', 'OpenClaw Agent Fleet Dashboard - Agents'));
});
app.get('/usage', (req, res) => {
res.send(renderPage('usage', 'usage', 'OpenClaw Agent Fleet Dashboard - Usage'));
});
app.use(express.static(path.join(__dirname, 'public')));
function normalizeTask(row) {
return {
@@ -179,8 +112,6 @@ 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) {
@@ -298,216 +229,8 @@ app.patch('/api/tasks/:id', (req, res) => {
} catch (wikiErr) {
console.error('wiki_creation_error', wikiErr);
}
}
broadcast('task_updated', task);
return res.json(task);
});
});
});
});
// ============ 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) ============
// Agents endpoint
app.get('/api/agents', (req, res) => {
try {
const agents = [];
@@ -517,47 +240,7 @@ app.get('/api/agents', (req, res) => {
return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory();
});
// 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) => {
agentDirs.forEach(agentName => {
const agentPath = path.join(AGENTS_DIR, agentName);
const workspacePath = path.join(agentPath, 'workspace');
@@ -567,11 +250,151 @@ app.get('/api/agents', (req, res) => {
currentTask: null,
tools: [],
files: [],
permissions: [],
workload: 0,
activeTasks: [],
completedTasks: [],
capabilities: []
permissions: []
};
// Read workspace files
if (fs.existsSync(workspacePath)) {
const files = fs.readdirSync(workspacePath);
agent.files = files.filter(f => f.endsWith('.md'));
// Read MEMORY.md for tools
const memoryPath = path.join(workspacePath, 'MEMORY.md');
if (fs.existsSync(memoryPath)) {
const memory = fs.readFileSync(memoryPath, 'utf8');
// Extract tools from memory
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());
}
}
// Read HEARTBEAT.md for current task
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()
};
// Read OpenClaw config
if (fs.existsSync(OPENCLAW_CONFIG)) {
const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8'));
// Extract provider information
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
});
}
);
});
}
broadcast('task_updated', task);
return res.json(task);
});
}
);
});
});
// Agents endpoint
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)) {
@@ -581,104 +404,36 @@ 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());
}
// 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());
.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;
return agent;
agents.push(agent);
});
Promise.all(agentPromises).then(results => {
res.json(results);
});
} else {
res.json([]);
}
res.json(agents);
} catch (err) {
console.error('Error reading agents:', err);
res.status(500).json({ error: 'failed_to_fetch_agents' });
}
});
// 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)
// Usage endpoint
app.get('/api/usage', (req, res) => {
try {
const usage = {
@@ -725,191 +480,7 @@ app.get('/api/usage', (req, res) => {
}
});
// 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 ============
// Heartbeat endpoint for agents
app.get('/api/heartbeat/:agent', (req, res) => {
const agent = req.params.agent;
@@ -931,8 +502,6 @@ app.get('/api/heartbeat/:agent', (req, res) => {
);
});
// ============ WEBSOCKET ============
wss.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } }));
});

View File

@@ -1,374 +0,0 @@
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}`);
});

View File

@@ -1,511 +0,0 @@
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);
}
// Agents endpoint
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: []
};
// Read workspace files
if (fs.existsSync(workspacePath)) {
const files = fs.readdirSync(workspacePath);
agent.files = files.filter(f => f.endsWith('.md'));
// Read MEMORY.md for tools
const memoryPath = path.join(workspacePath, 'MEMORY.md');
if (fs.existsSync(memoryPath)) {
const memory = fs.readFileSync(memoryPath, 'utf8');
// Extract tools from memory
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());
}
}
// Read HEARTBEAT.md for current task
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()
};
// Read OpenClaw config
if (fs.existsSync(OPENCLAW_CONFIG)) {
const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8'));
// Extract provider information
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
});
}
);
});
}
broadcast('task_updated', task);
return res.json(task);
});
}
);
});
});
// Agents endpoint
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}`);
});

View File

@@ -1,40 +0,0 @@
<section id="page-agents" class="page active">
<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>
<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>
<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>

View File

@@ -1,30 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{pageTitle}}</title>
<link rel="stylesheet" href="/styles.css" />
{{markedScript}}
{{chartScript}}
</head>
<body data-page="{{pageName}}">
<div class="container">
<header>
<h1>🦞 OpenClaw Agent Fleet Dashboard</h1>
<nav>
<a href="/tasks" class="nav-link {{tasksActive}}">Tasks</a>
<a href="/wiki" class="nav-link {{wikiActive}}">Wiki</a>
<a href="/agents" class="nav-link {{agentsActive}}">Agents</a>
<a href="/usage" class="nav-link {{usageActive}}">Usage</a>
</nav>
</header>
<main>
{{content}}
</main>
</div>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -1,58 +0,0 @@
<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>

View File

@@ -1,49 +0,0 @@
<section id="page-usage" class="page active">
<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>

View File

@@ -1,35 +0,0 @@
<section id="page-wiki" class="page active">
<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>