1 Commits

Author SHA1 Message Date
b4cfcbf6e5 feat: add dark mode support 2026-03-03 21:07:51 -08:00
8 changed files with 839 additions and 3527 deletions

View File

@@ -1,64 +1,37 @@
// ============ THEME ============ function getPreferredTheme() {
const THEME_STORAGE_KEY = 'agentdash-theme'; const savedTheme = localStorage.getItem('theme');
const themeToggleBtn = document.getElementById('theme-toggle'); if (savedTheme) {
const systemThemeMedia = window.matchMedia('(prefers-color-scheme: dark)'); return savedTheme;
function getSystemTheme() {
return systemThemeMedia.matches ? 'dark' : 'light';
}
function getSavedTheme() {
try {
const saved = localStorage.getItem(THEME_STORAGE_KEY);
return saved === 'light' || saved === 'dark' ? saved : null;
} catch {
return null;
} }
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} }
function setSavedTheme(theme) { function setTheme(theme) {
try { if (theme === 'dark') {
localStorage.setItem(THEME_STORAGE_KEY, theme); document.documentElement.setAttribute('data-theme', 'dark');
} catch { document.getElementById('theme-toggle').textContent = '☀️';
// Ignore localStorage errors (privacy mode, quota, etc.). } else {
document.documentElement.removeAttribute('data-theme');
document.getElementById('theme-toggle').textContent = '🌙';
} }
localStorage.setItem('theme', theme);
} }
function updateThemeToggleLabel() { setTheme(getPreferredTheme());
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.getElementById('theme-toggle').addEventListener('click', () => {
document.documentElement.setAttribute('data-theme', theme); const currentTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
if (persist) setSavedTheme(theme); const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
updateThemeToggleLabel(); setTheme(newTheme);
if (usageStats) renderUsageCharts(); });
}
function initTheme() { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
const savedTheme = getSavedTheme(); if (!localStorage.getItem('theme')) {
applyTheme(savedTheme || getSystemTheme()); setTheme(e.matches ? 'dark' : 'light');
if (themeToggleBtn) {
themeToggleBtn.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme') || getSystemTheme();
const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
applyTheme(nextTheme, { persist: true });
});
} }
});
systemThemeMedia.addEventListener('change', (event) => { // Navigation
if (!getSavedTheme()) {
applyTheme(event.matches ? 'dark' : 'light');
}
});
}
// ============ NAVIGATION ============
const navLinks = document.querySelectorAll('.nav-link'); const navLinks = document.querySelectorAll('.nav-link');
const pages = document.querySelectorAll('.page'); const pages = document.querySelectorAll('.page');
@@ -86,7 +59,7 @@ navLinks.forEach(link => {
}); });
}); });
// ============ TASK DASHBOARD ============ // Task Dashboard
const COLUMNS = { const COLUMNS = {
'Backlog': { title: '📋 Backlog', tasks: [] }, 'Backlog': { title: '📋 Backlog', tasks: [] },
'Todo': { title: '📝 Todo', tasks: [] }, 'Todo': { title: '📝 Todo', tasks: [] },
@@ -177,7 +150,7 @@ document.getElementById('task-form').addEventListener('submit', async (e) => {
description: formData.get('description'), description: formData.get('description'),
assignee: formData.get('assignee'), assignee: formData.get('assignee'),
priority: formData.get('priority'), priority: formData.get('priority'),
status: formData.get('status') || 'Backlog', status: formData.get('status'),
tags: formData.get('tags').split(',').map(t => t.trim()).filter(t => t) tags: formData.get('tags').split(',').map(t => t.trim()).filter(t => t)
}; };
@@ -191,6 +164,146 @@ document.getElementById('task-form').addEventListener('submit', async (e) => {
loadTasks(); loadTasks();
}); });
// 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 container = document.getElementById('usage-container');
container.innerHTML = '';
if (usage.providers.length === 0) {
container.innerHTML = '<p style="padding: 2rem; color: var(--text-secondary);">No provider data available</p>';
return;
}
usage.providers.forEach(provider => {
const cardEl = document.createElement('div');
cardEl.className = 'usage-card';
cardEl.innerHTML = `
<h3 class="provider-name">${escapeHtml(provider.name)}</h3>
<div class="provider-models">
${provider.models.map(model => `
<div class="model-item">
<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>
`;
container.appendChild(cardEl);
});
}
// Utility functions
function escapeHtml(text) {
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();
}
};
// Populate agent dropdown // Populate agent dropdown
async function populateAgentDropdown() { async function populateAgentDropdown() {
try { try {
@@ -200,10 +313,8 @@ async function populateAgentDropdown() {
const select = document.getElementById('assignee'); const select = document.getElementById('assignee');
if (!select) return; if (!select) return;
// Keep the first option ("Select agent...") // Clear existing options except the first placeholder
const firstOption = select.options[0]; select.innerHTML = '<option value="">Select agent...</option>';
select.innerHTML = '';
select.appendChild(firstOption);
// Add agent options // Add agent options
agents.forEach(agent => { agents.forEach(agent => {
@@ -217,580 +328,7 @@ async function populateAgentDropdown() {
} }
} }
// ============ WIKI ============ // Populate dropdown on page load
let wikiPages = [];
let currentWikiPage = null;
let isEditingWiki = false;
async function loadWiki() {
try {
const res = await fetch('/api/wiki');
wikiPages = await res.json();
renderWikiList();
} catch (err) {
console.error('Failed to load wiki:', err);
}
}
function renderWikiList(filter = '') {
const wikiList = document.getElementById('wiki-list');
wikiList.innerHTML = '';
const filtered = filter
? wikiPages.filter(p => p.title.toLowerCase().includes(filter.toLowerCase()) || p.filename.toLowerCase().includes(filter.toLowerCase()))
: wikiPages;
filtered.forEach(page => {
const itemEl = document.createElement('div');
itemEl.className = 'wiki-item' + (currentWikiPage && currentWikiPage.filename === page.filename ? ' active' : '');
itemEl.innerHTML = `
<h4 class="wiki-title">${escapeHtml(page.title)}</h4>
<p class="wiki-date">${new Date(page.modified).toLocaleDateString()}</p>
`;
itemEl.addEventListener('click', () => selectWikiPage(page.filename));
wikiList.appendChild(itemEl);
});
}
// Wiki search
document.getElementById('wiki-search').addEventListener('input', (e) => {
renderWikiList(e.target.value);
});
// New wiki page
document.getElementById('wiki-new-btn').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);
}
});
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();
// Update UI
document.getElementById('wiki-page-title').textContent = currentWikiPage.metadata.title || filename;
document.getElementById('wiki-page-actions').style.display = 'flex';
// Render markdown
const contentEl = document.getElementById('wiki-content');
contentEl.style.display = 'block';
document.getElementById('wiki-editor').style.display = 'none';
if (typeof marked !== 'undefined') {
contentEl.innerHTML = marked.parse(currentWikiPage.content);
} else {
contentEl.innerHTML = `<pre>${escapeHtml(currentWikiPage.content)}</pre>`;
}
// Update list selection
renderWikiList(document.getElementById('wiki-search').value);
} catch (err) {
console.error('Failed to load wiki page:', err);
}
}
// Edit wiki page
document.getElementById('wiki-edit-btn').addEventListener('click', () => {
if (!currentWikiPage) return;
isEditingWiki = true;
document.getElementById('wiki-content').style.display = 'none';
document.getElementById('wiki-editor').style.display = 'block';
document.getElementById('wiki-edit-title').value = currentWikiPage.metadata.title || '';
document.getElementById('wiki-edit-content').value = currentWikiPage.content;
});
// Save wiki page
document.getElementById('wiki-save-btn').addEventListener('click', async () => {
if (!currentWikiPage) return;
const content = document.getElementById('wiki-edit-content').value;
try {
const res = await fetch(`/api/wiki/${currentWikiPage.filename}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
if (res.ok) {
isEditingWiki = false;
await selectWikiPage(currentWikiPage.filename);
}
} catch (err) {
console.error('Failed to save wiki page:', err);
}
});
// Cancel edit
document.getElementById('wiki-cancel-btn').addEventListener('click', () => {
isEditingWiki = false;
document.getElementById('wiki-editor').style.display = 'none';
document.getElementById('wiki-content').style.display = 'block';
});
// Delete wiki page
document.getElementById('wiki-delete-btn').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;
document.getElementById('wiki-page-title').textContent = 'Select a page';
document.getElementById('wiki-page-actions').style.display = 'none';
document.getElementById('wiki-content').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);
}
});
// ============ AGENTS ============
let allAgents = [];
async function loadAgents() {
try {
const res = await fetch('/api/agents');
allAgents = await res.json();
renderAgents();
} catch (err) {
console.error('Failed to load agents:', err);
}
}
function renderAgents(filter = '', statusFilter = '') {
const grid = document.getElementById('agents-grid');
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 => {
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>
</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.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>` : ''}
</div>
</div>
<div class="agent-section">
<h4>📄 Recent 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>'}
</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>
`;
// Details button
cardEl.querySelector('.agent-details-btn').addEventListener('click', () => showAgentDetails(agent));
// Assign button
cardEl.querySelector('.agent-assign-btn').addEventListener('click', () => showAssignModal(agent.name));
grid.appendChild(cardEl);
});
}
// Agent search
document.getElementById('agent-search').addEventListener('input', (e) => {
const statusFilter = document.getElementById('agent-status-filter').value;
renderAgents(e.target.value, statusFilter);
});
// Agent status filter
document.getElementById('agent-status-filter').addEventListener('change', (e) => {
const searchFilter = document.getElementById('agent-search').value;
renderAgents(searchFilter, e.target.value);
});
// Agent details modal
function showAgentDetails(agent) {
const modal = document.getElementById('agent-modal');
const body = document.getElementById('modal-agent-body');
document.getElementById('modal-agent-name').textContent = agent.name;
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');
}
// Close agent modal
document.getElementById('modal-close').addEventListener('click', () => {
document.getElementById('agent-modal').classList.remove('active');
});
// Assign task modal
async function showAssignModal(agentName) {
const modal = document.getElementById('assign-modal');
document.getElementById('assign-agent-name').textContent = agentName;
// Load unassigned tasks
try {
const res = await fetch('/api/tasks');
const tasks = await res.json();
const unassignedTasks = tasks.filter(t => t.status !== 'Done' && (!t.assignee || t.assignee === ''));
const select = document.getElementById('assign-task-select');
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);
});
// Store agent name for assignment
select.dataset.agent = agentName;
modal.classList.add('active');
} catch (err) {
console.error('Failed to load tasks for assignment:', err);
}
}
// Close assign modal
document.getElementById('assign-modal-close').addEventListener('click', () => {
document.getElementById('assign-modal').classList.remove('active');
});
// Confirm assignment
document.getElementById('confirm-assign-btn').addEventListener('click', async () => {
const select = document.getElementById('assign-task-select');
const taskId = select.value;
const agentName = select.dataset.agent;
if (!taskId) {
alert('Please select a task');
return;
}
try {
const res = await fetch(`/api/agents/${agentName}/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId: parseInt(taskId) })
});
if (res.ok) {
document.getElementById('assign-modal').classList.remove('active');
await loadAgents();
await loadTasks();
}
} catch (err) {
console.error('Failed to assign task:', err);
}
});
// ============ USAGE ============
let usageStats = null;
let providerChart = null;
let agentChart = null;
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 {
// Load stats
const statsRes = await fetch(statsUrl);
usageStats = await statsRes.json();
// Load basic usage info
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() {
document.getElementById('stat-requests').textContent = usageStats.totalRequests.toLocaleString();
document.getElementById('stat-tokens').textContent = usageStats.totalTokens.toLocaleString();
document.getElementById('stat-cost').textContent = '$' + usageStats.totalCost.toFixed(2);
}
function renderUsageCharts() {
const rootStyles = getComputedStyle(document.documentElement);
const themeForeground = rootStyles.getPropertyValue('--fg').trim() || '#e0e0e0';
const themeBorder = rootStyles.getPropertyValue('--border').trim() || '#444';
const themePrimary = rootStyles.getPropertyValue('--primary').trim() || '#3498db';
// Provider chart
const providerCtx = document.getElementById('chart-provider').getContext('2d');
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: themeForeground }
}
}
}
});
// Agent chart
const agentCtx = document.getElementById('chart-agent').getContext('2d');
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: themePrimary
}]
},
options: {
responsive: true,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: { color: themeForeground },
grid: { color: themeBorder }
},
x: {
ticks: { color: themeForeground },
grid: { color: themeBorder }
}
}
}
});
}
function renderProviderDetails(usageData) {
const grid = document.getElementById('provider-grid');
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 => `
<div class="model-item">
<span class="model-name">${escapeHtml(model.name)}</span>
<span class="model-type">${escapeHtml(model.type)}</span>
</div>
`).join('')}
</div>
`;
grid.appendChild(providerEl);
});
}
// Apply date filter
document.getElementById('usage-apply-filter').addEventListener('click', loadUsage);
// Export JSON
document.getElementById('export-json').addEventListener('click', () => {
const from = document.getElementById('usage-from').value;
const to = document.getElementById('usage-to').value;
let url = '/api/usage/export?format=json';
if (from) url += `&from=${from}`;
if (to) url += `&to=${to}`;
window.open(url, '_blank');
});
// Export CSV
document.getElementById('export-csv').addEventListener('click', () => {
const from = document.getElementById('usage-from').value;
const to = document.getElementById('usage-to').value;
let url = '/api/usage/export?format=csv';
if (from) url += `&from=${from}`;
if (to) url += `&to=${to}`;
window.open(url, '_blank');
});
// ============ HELPERS ============
function escapeHtml(text) {
if (typeof text !== 'string') return '';
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
// ============ INITIALIZATION ============
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initTheme();
populateAgentDropdown(); populateAgentDropdown();
loadTasks();
// Set default date range (last 30 days)
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 30);
document.getElementById('usage-from').value = from.toISOString().split('T')[0];
document.getElementById('usage-to').value = to.toISOString().split('T')[0];
});
// Close modals on outside click
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.classList.remove('active');
}
});
}); });

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

537
server.js
View File

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