From e05dfef5aba5d541c16e0c4f9a8c9a0aec99bb56 Mon Sep 17 00:00:00 2001 From: Christopher Mayor Date: Tue, 3 Mar 2026 22:48:34 -0800 Subject: [PATCH] feat: refactor dashboard with enhanced wiki, agents, and usage views - Added comprehensive wiki backend with CRUD operations (GET, POST, PUT, DELETE) - Enhanced agents backend with workload tracking, task history, and capabilities - Implemented usage tracking with SQLite database and statistics endpoints - Added wiki frontend with markdown editor, page management, and search - Enhanced agents frontend with search, filtering, task assignment, and detailed modals - Enhanced usage frontend with charts (Chart.js), date filtering, and export functionality - Updated styles for all new components with responsive design - Added new API endpoints: /api/wiki/*, /api/usage/stats, /api/usage/agents, /api/usage/export - Enhanced /api/agents with workload and task history data - Maintained backwards compatibility with existing task management features The refactoring includes: - Wiki: Full CRUD operations with markdown support - Agents: Search, workload tracking, task assignment modals - Usage: Charts, statistics, date range filtering, CSV/JSON export - Styles: Responsive design with dark theme support --- public/app.js | 969 ++++++++++++++++++++++--------- public/app.js.backup | 279 +++++++++ public/index.html | 353 +++++++----- public/index.html.backup | 97 ++++ public/styles.css | 1159 ++++++++++++++++++++++++++------------ public/styles.css.backup | 451 +++++++++++++++ server.js | 537 +++++++++++++++++- server.js.backup | 374 ++++++++++++ 8 files changed, 3440 insertions(+), 779 deletions(-) create mode 100644 public/app.js.backup create mode 100644 public/index.html.backup create mode 100644 public/styles.css.backup create mode 100644 server.js.backup diff --git a/public/app.js b/public/app.js index fd111f6..c942107 100644 --- a/public/app.js +++ b/public/app.js @@ -1,301 +1,730 @@ -// Navigation +// ============ 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'); - } + 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(); }); - - // Load page data - if (targetPage === 'wiki') loadWiki(); - if (targetPage === 'agents') loadAgents(); - if (targetPage === 'usage') loadUsage(); - }); }); -// Task Dashboard +// ============ 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: [] } + '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(); + 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'; + const board = document.getElementById('board'); + board.innerHTML = ''; - columnEl.innerHTML = ` -
-

${column.title}

- ${column.tasks.length} -
-
- `; - - const cardsEl = columnEl.querySelector('.cards'); - - column.tasks.forEach(task => { - const cardEl = document.createElement('div'); - cardEl.className = 'card'; - cardEl.innerHTML = ` -
-

${escapeHtml(task.title)}

- ${task.priority} -
-

${escapeHtml(task.description || '')}

-

${task.assignee || 'Unassigned'}

-

${task.tags.map(t => `${escapeHtml(t)}`).join(' ')}

- - `; - - // 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' }) + Object.entries(COLUMNS).forEach(([status, column]) => { + const columnEl = document.createElement('div'); + columnEl.className = 'column'; + + columnEl.innerHTML = ` +
+

${column.title}

+ ${column.tasks.length} +
+
+ `; + + const cardsEl = columnEl.querySelector('.cards'); + + column.tasks.forEach(task => { + const cardEl = document.createElement('div'); + cardEl.className = 'card'; + cardEl.innerHTML = ` +
+

${escapeHtml(task.title)}

+ ${task.priority} +
+

${escapeHtml(task.description || '')}

+

${task.assignee || 'Unassigned'}

+

${task.tags.map(t => `${escapeHtml(t)}`).join(' ')}

+ + `; + + // 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); }); - loadTasks(); - }); - - cardsEl.appendChild(cardEl); + + board.appendChild(columnEl); }); - - 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(); -}); - -// 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 = ` -

${escapeHtml(page.filename.replace('.md', ''))}

-

${new Date(page.created).toLocaleDateString()}

- `; + e.preventDefault(); - 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 = `
${escapeHtml(contentData.content)}
`; + 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') || 'Backlog', + 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) }); - 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 = ` -
-

${escapeHtml(agent.name)}

- ${agent.status} -
-
-
-

📋 Current Task

-

${agent.currentTask || 'No active task'}

-
-
-

🛠️ Tools

-
- ${agent.tools.map(tool => `${escapeHtml(tool)}`).join('')} -
-
-
-

📄 Workspace Files

-
- ${agent.files.map(file => `${escapeHtml(file)}`).join('')} -
-
-
- `; - - 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 = '

No provider data available

'; - return; - } - - usage.providers.forEach(provider => { - const cardEl = document.createElement('div'); - cardEl.className = 'usage-card'; - - cardEl.innerHTML = ` -

${escapeHtml(provider.name)}

-
- ${provider.models.map(model => ` -
-
${escapeHtml(model.name)}
-
Type: ${model.type} | Context: ${model.contextWindow}
-
- `).join('')} -
-
-
Quota & Limits
-
- Requests - ${provider.quota.requests} / ${provider.quota.limit} -
-
- Tokens - ${provider.quota.tokens} -
-
-
-
-
- `; - - 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') { + 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; - - // Clear existing options except the first placeholder - select.innerHTML = ''; - - // 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); - } + 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 ============ +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 = ` +

${escapeHtml(page.title)}

+

${new Date(page.modified).toLocaleDateString()}

+ `; + + 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 = `
${escapeHtml(currentWikiPage.content)}
`; + } + + // 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 = '

📚 Select a wiki page from the sidebar or create a new one.

'; + 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 = ` +
+

${escapeHtml(agent.name)}

+ ${agent.status} +
+
+ 📋 ${agent.workload} active task${agent.workload !== 1 ? 's' : ''} +
+
+
+

📋 Current Task

+

${agent.currentTask || 'No active task'}

+
+
+

🛠️ Tools

+
+ ${agent.tools.length ? agent.tools.slice(0, 5).map(tool => `${escapeHtml(tool)}`).join('') : 'No tools'} + ${agent.tools.length > 5 ? `+${agent.tools.length - 5} more` : ''} +
+
+
+

📄 Recent Files

+
+ ${agent.files.length ? agent.files.slice(0, 5).map(file => `${escapeHtml(file)}`).join('') : 'No files'} +
+
+
+
+ + +
+ `; + + // 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 = ` +
+

Status

+

${agent.status}

+
+
+

Workload

+

${agent.workload} active task${agent.workload !== 1 ? 's' : ''}

+
+
+

Current Task

+

${agent.currentTask || 'No active task'}

+
+
+

Active Tasks

+ +
+
+

Recently Completed

+ +
+
+

Tools

+
${agent.tools.length ? agent.tools.map(t => `${escapeHtml(t)}`).join('') : 'No tools'}
+
+
+

Capabilities

+
${agent.capabilities.length ? agent.capabilities.map(c => `${escapeHtml(c)}`).join('') : 'No capabilities defined'}
+
+ `; + + 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 = ''; + + 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() { + // 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: '#e0e0e0' } + } + } + } + }); + + // 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: '#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'); + 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 = ` +

${escapeHtml(provider.name)}

+
+
+ Requests + ${providerUsage.requests.toLocaleString()} +
+
+ Tokens + ${providerUsage.tokens.toLocaleString()} +
+
+ Cost + $${providerUsage.cost.toFixed(2)} +
+
+
+
Models
+ ${provider.models.map(model => ` +
+ ${escapeHtml(model.name)} + ${escapeHtml(model.type)} +
+ `).join('')} +
+ `; + + 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 = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, m => map[m]); +} + +// ============ INITIALIZATION ============ +document.addEventListener('DOMContentLoaded', () => { + 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'); + } + }); }); diff --git a/public/app.js.backup b/public/app.js.backup new file mode 100644 index 0000000..6403a25 --- /dev/null +++ b/public/app.js.backup @@ -0,0 +1,279 @@ +// Navigation +const navLinks = document.querySelectorAll('.nav-link'); +const pages = document.querySelectorAll('.page'); + +navLinks.forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const targetPage = link.dataset.page; + + // Update active nav link + navLinks.forEach(l => l.classList.remove('active')); + link.classList.add('active'); + + // Show target page + pages.forEach(page => { + page.classList.remove('active'); + if (page.id === `page-${targetPage}`) { + page.classList.add('active'); + } + }); + + // Load page data + if (targetPage === 'wiki') loadWiki(); + if (targetPage === 'agents') loadAgents(); + if (targetPage === 'usage') loadUsage(); + }); +}); + +// Task Dashboard +const COLUMNS = { + 'Backlog': { title: '📋 Backlog', tasks: [] }, + 'Todo': { title: '📝 Todo', tasks: [] }, + 'In Progress': { title: '🔄 In Progress', tasks: [] }, + 'Review': { title: '👀 Review', tasks: [] }, + 'Done': { title: '✅ Done', tasks: [] } +}; + +async function loadTasks() { + const res = await fetch('/api/tasks'); + const tasks = await res.json(); + + // Reset columns + Object.keys(COLUMNS).forEach(status => { + COLUMNS[status].tasks = []; + }); + + // Group tasks by status + tasks.forEach(task => { + if (COLUMNS[task.status]) { + COLUMNS[task.status].tasks.push(task); + } + }); + + renderBoard(); +} + +function renderBoard() { + const board = document.getElementById('board'); + board.innerHTML = ''; + + Object.entries(COLUMNS).forEach(([status, column]) => { + const columnEl = document.createElement('div'); + columnEl.className = 'column'; + + columnEl.innerHTML = ` +
+

${column.title}

+ ${column.tasks.length} +
+
+ `; + + const cardsEl = columnEl.querySelector('.cards'); + + column.tasks.forEach(task => { + const cardEl = document.createElement('div'); + cardEl.className = 'card'; + cardEl.innerHTML = ` +
+

${escapeHtml(task.title)}

+ ${task.priority} +
+

${escapeHtml(task.description || '')}

+

${task.assignee || 'Unassigned'}

+

${task.tags.map(t => `${escapeHtml(t)}`).join(' ')}

+ + `; + + // 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 = ` +

${escapeHtml(page.filename.replace('.md', ''))}

+

${new Date(page.created).toLocaleDateString()}

+ `; + + 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 = `
${escapeHtml(contentData.content)}
`; + }); + + 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 = ` +
+

${escapeHtml(agent.name)}

+ ${agent.status} +
+
+
+

📋 Current Task

+

${agent.currentTask || 'No active task'}

+
+
+

🛠️ Tools

+
+ ${agent.tools.map(tool => `${escapeHtml(tool)}`).join('')} +
+
+
+

📄 Workspace Files

+
+ ${agent.files.map(file => `${escapeHtml(file)}`).join('')} +
+
+
+ `; + + 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 = ` +

📊 Provider Usage

+
+ ${usage.providers.map(provider => ` +
+

${escapeHtml(provider.name)}

+
+ ${provider.models.map(model => ` +
+ ${escapeHtml(model.name)} + ${escapeHtml(model.type)} + ${escapeHtml(model.contextWindow)} +
+ `).join('')} +
+
+ `).join('')} +
+ `; +} + +// Helper function to escape HTML +function escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, m => map[m]); +} + +// Initial load +loadTasks(); diff --git a/public/index.html b/public/index.html index a44d276..cce2854 100644 --- a/public/index.html +++ b/public/index.html @@ -1,146 +1,221 @@ - + - - - + + + OpenClaw Agent Fleet Dashboard - - - - + + + + + + + +
+
+

🦞 OpenClaw Agent Fleet Dashboard

+ +
- -
-
-

Task Dashboard

-

Real-time task coordination board

-
+
+ +
+
+

Create Task

+
+ + + + + + +
+
-
-

Create Task

-
- - - - - - - -
-
+
+
+
+

📋 Backlog

+ 0 +
+
+
+
+
+

📝 Todo

+ 0 +
+
+
+
+
+

🔄 In Progress

+ 0 +
+
+
+
+
+

👀 Review

+ 0 +
+
+
+
+
+

✅ Done

+ 0 +
+
+
+
+
-
+ +
+
+
+
+ +
+ +
+
+
+
+
Select a page
+ +
+
+
+

📚 Select a wiki page from the sidebar or create a new one.

+
+
+ +
+
+
+ + +
+
+

Agent Fleet

+
+ + +
+
+
+ + + + + + +
+ + +
+
+

API Usage & Statistics

+
+
+ + + + + +
+
+ + +
+
+
+ +
+
+

Total Requests

+
0
+
+
+

Total Tokens

+
0
+
+
+

Estimated Cost

+
$0.00
+
+
+ +
+
+

Usage by Provider

+ +
+
+

Usage by Agent

+ +
+
+ +
+

Provider Details

+
+
+
+
- - -
-
-

📚 Wiki

-

Task documentation and implementation details

-
-
-
-
-

Select a wiki page to view documentation

-
-
-
- - -
-
-

🤖 Agents

-

Fleet agent workspace and configuration

-
-
-
- - -
-
-

📊 Usage & Quotas

-

Provider models, quotas, and limits

-
-
-
- - - - - - - - - - - + + diff --git a/public/index.html.backup b/public/index.html.backup new file mode 100644 index 0000000..599e418 --- /dev/null +++ b/public/index.html.backup @@ -0,0 +1,97 @@ + + + + + + OpenClaw Agent Fleet Dashboard + + + +
+
+

🦞 OpenClaw Agent Fleet Dashboard

+ +
+ +
+
+
+

Create Task

+
+ + + + + + +
+
+ +
+
+
+

📋 Backlog

+ 0 +
+
+
+
+
+

📝 Todo

+ 0 +
+
+
+
+
+

🔄 In Progress

+ 0 +
+
+
+
+
+

👀 Review

+ 0 +
+
+
+
+
+

✅ Done

+ 0 +
+
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+
+ + + diff --git a/public/styles.css b/public/styles.css index e5512dd..6fe3a33 100644 --- a/public/styles.css +++ b/public/styles.css @@ -1,558 +1,999 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; +: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; + --modal-bg: rgba(0, 0, 0, 0.7); } -:root { - --bg-primary: #0f1419; - --bg-secondary: #1a1f2e; - --bg-card: #242b3d; - --text-primary: #e6edf3; - --text-secondary: #8b949e; - --accent: #f78166; - --border: #30363d; - --priority-high: #f85149; - --priority-medium: #d29922; - --priority-low: #3fb950; - --priority-critical: #ff6b6b; +* { + margin: 0; + padding: 0; + box-sizing: border-box; } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--bg-primary); - color: var(--text-primary); - min-height: 100vh; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: var(--bg); + color: var(--fg); + line-height: 1.6; } -/* Navigation */ -.navbar { - background: var(--bg-secondary); - padding: 1rem 2rem; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid var(--border); - position: sticky; - top: 0; - z-index: 1000; +.container { + max-width: 1400px; + margin: 0 auto; + padding: 20px; } -.nav-brand h1 { - font-size: 1.5rem; - color: var(--text-primary); +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); } -.nav-links { - display: flex; - gap: 1rem; +header h1 { + color: var(--primary); + margin-bottom: 15px; +} + +nav { + display: flex; + gap: 15px; } .nav-link { - color: var(--text-secondary); - text-decoration: none; - padding: 0.5rem 1rem; - border-radius: 6px; - transition: all 0.2s; + color: var(--fg); + text-decoration: none; + padding: 8px 16px; + border-radius: 6px; + transition: background-color 0.3s ease; } .nav-link:hover { - background: var(--bg-card); - color: var(--text-primary); + background-color: var(--border); } .nav-link.active { - background: var(--accent); - color: white; + background-color: var(--primary); + color: white; } -/* Pages */ -.page { - display: none; +/* Buttons */ +.btn-primary { + background-color: var(--primary); + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.3s ease; } -.page.active { - display: block; +.btn-primary:hover { + background-color: #2980b9; } -/* Dashboard */ -.topbar { - padding: 1.5rem 2rem; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border); +.btn-secondary { + background-color: var(--border); + color: var(--fg); + border: none; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.3s ease; } -.topbar h2 { - font-size: 1.5rem; - margin-bottom: 0.5rem; +.btn-secondary:hover { + background-color: #555; } -.topbar p { - color: var(--text-secondary); +.btn-danger { + background-color: var(--danger); + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.3s ease; } +.btn-danger:hover { + background-color: #c0392b; +} + +/* ============ TASKS PAGE ============ */ .composer { - padding: 1.5rem 2rem; - background: var(--bg-secondary); - margin: 1rem; - border-radius: 8px; - border: 1px solid var(--border); + 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 h3 { - margin-bottom: 1rem; +.composer h2 { + color: var(--primary); + margin-bottom: 15px; } -.composer form { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; +#task-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; } -.composer input, -.composer select, -.composer textarea { - padding: 0.5rem; - background: var(--bg-card); - border: 1px solid var(--border); - color: var(--text-primary); - border-radius: 4px; +#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; } -.composer textarea { - grid-column: 1 / -1; - min-height: 80px; +#task-form textarea { + grid-column: span 2; + resize: vertical; + min-height: 100px; } -.composer button { - padding: 0.5rem 1.5rem; - background: var(--accent); - color: white; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 0.9rem; +#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; } -.composer button:hover { - opacity: 0.9; +#task-form button:hover { + background-color: #2980b9; } -/* Board */ -.board { - display: flex; - gap: 1rem; - padding: 1rem; - overflow-x: auto; - min-height: calc(100vh - 400px); +#board { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; } .column { - flex: 0 0 280px; - background: var(--bg-secondary); - border-radius: 8px; - display: flex; - flex-direction: column; - max-height: calc(100vh - 250px); + background-color: var(--card-bg); + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + overflow: hidden; } .column-header { - padding: 1rem; - border-bottom: 1px solid var(--border); - display: flex; - justify-content: space-between; - align-items: center; + padding: 15px; + background-color: var(--dark); + display: flex; + justify-content: space-between; + align-items: center; } .column-header h3 { - font-size: 0.9rem; - text-transform: uppercase; + color: var(--fg); } .column-count { - background: var(--bg-card); - padding: 0.25rem 0.5rem; - border-radius: 12px; - font-size: 0.8rem; + background-color: var(--border); + color: var(--fg); + padding: 4px 8px; + border-radius: 4px; + font-size: 0.9rem; } .cards { - flex: 1; - overflow-y: auto; - padding: 0.5rem; + padding: 15px; + min-height: 200px; } .card { - background: var(--bg-card); - border-radius: 6px; - padding: 0.75rem; - margin-bottom: 0.5rem; - cursor: pointer; - border: 1px solid transparent; + 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 { - border-color: var(--accent); + 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: 0.5rem; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; } .card-title { - font-weight: 500; - font-size: 0.95rem; + color: var(--card-fg); + font-size: 1.1rem; } .badge { - padding: 0.15rem 0.5rem; - border-radius: 10px; - font-size: 0.75rem; - font-weight: 600; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: bold; } -.priority-High { - background: var(--priority-high); - color: white; +.badge.priority-Low { + background-color: var(--secondary); + color: white; } -.priority-Medium { - background: var(--priority-medium); - color: white; +.badge.priority-Medium { + background-color: var(--warning); + color: white; } -.priority-Low { - background: var(--priority-low); - color: white; +.badge.priority-High { + background-color: var(--danger); + color: white; } -.priority-Critical { - background: var(--priority-critical); - color: white; +.badge.priority-Critical { + background-color: #9c27b0; + color: white; } .card-desc { - color: var(--text-secondary); - font-size: 0.85rem; - margin-bottom: 0.5rem; + color: var(--fg); + margin-bottom: 10px; } .meta { - font-size: 0.75rem; - color: var(--text-secondary); - margin-bottom: 0.25rem; + font-size: 0.9rem; + color: var(--border); + margin-bottom: 5px; } -.assignee { - color: var(--accent); -} - -.tags { - display: flex; - gap: 0.25rem; - flex-wrap: wrap; +.meta.assignee { + font-weight: bold; } .tag { - background: var(--bg-primary); - padding: 0.15rem 0.5rem; - border-radius: 4px; - font-size: 0.7rem; + display: inline-block; + background-color: var(--border); + color: var(--fg); + padding: 2px 6px; + border-radius: 4px; + margin-right: 5px; + margin-bottom: 5px; } -.card label { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.75rem; - color: var(--text-secondary); - margin-top: 0.5rem; +.card-check { + margin-right: 10px; +} + +/* ============ WIKI PAGE ============ */ +#page-wiki { + background-color: var(--card-bg); + padding: 20px; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } -/* Wiki */ .wiki-container { - display: grid; - grid-template-columns: 300px 1fr; - gap: 1rem; - padding: 1rem; - min-height: calc(100vh - 200px); + display: grid; + grid-template-columns: 280px 1fr; + gap: 20px; + min-height: 600px; +} + +.wiki-sidebar { + display: flex; + flex-direction: column; + gap: 15px; +} + +.wiki-actions { + display: flex; + gap: 10px; +} + +.wiki-search input { + width: 100%; + padding: 10px; + border: 1px solid var(--border); + border-radius: 6px; + background-color: var(--bg); + color: var(--fg); } .wiki-list { - background: var(--bg-secondary); - border-radius: 8px; - padding: 1rem; - overflow-y: auto; - max-height: calc(100vh - 220px); + flex: 1; + overflow-y: auto; + max-height: 500px; } .wiki-item { - padding: 0.75rem; - border-radius: 6px; - cursor: pointer; - margin-bottom: 0.5rem; - background: var(--bg-card); + background-color: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + margin-bottom: 10px; + cursor: pointer; + transition: all 0.2s ease; } .wiki-item:hover { - background: var(--accent); + background-color: var(--dark); + border-color: var(--primary); } .wiki-item.active { - background: var(--accent); + background-color: var(--primary); + border-color: var(--primary); +} + +.wiki-item.active .wiki-title, +.wiki-item.active .wiki-date { + color: white; } .wiki-title { - font-size: 0.9rem; - margin-bottom: 0.25rem; + color: var(--card-fg); + margin-bottom: 5px; + font-size: 0.95rem; } .wiki-date { - font-size: 0.75rem; - color: var(--text-secondary); + color: var(--border); + font-size: 0.8rem; +} + +.wiki-main { + display: flex; + flex-direction: column; + gap: 15px; +} + +.wiki-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 15px; + border-bottom: 1px solid var(--border); +} + +.wiki-page-title { + font-size: 1.3rem; + font-weight: bold; + color: var(--primary); +} + +.wiki-page-actions { + display: flex; + gap: 10px; } .wiki-content { - background: var(--bg-secondary); - border-radius: 8px; - padding: 2rem; - overflow-y: auto; - max-height: calc(100vh - 220px); + background-color: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 20px; + overflow-y: auto; + flex: 1; } -.wiki-content h1 { - margin-bottom: 1rem; +.wiki-content h1, .wiki-content h2, .wiki-content h3 { + color: var(--primary); + margin-top: 20px; + margin-bottom: 10px; } -.wiki-content h2 { - margin-top: 1.5rem; - margin-bottom: 0.5rem; +.wiki-content h1:first-child { + margin-top: 0; } .wiki-content p { - margin-bottom: 1rem; + margin-bottom: 15px; +} + +.wiki-content ul, .wiki-content ol { + margin-left: 20px; + margin-bottom: 15px; } .wiki-content code { - background: var(--bg-card); - padding: 0.2rem 0.4rem; - border-radius: 3px; - font-family: monospace; + background-color: var(--dark); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Consolas', monospace; } .wiki-content pre { - background: var(--bg-card); - padding: 1rem; - border-radius: 6px; - overflow-x: auto; - margin-bottom: 1rem; + background-color: var(--dark); + padding: 15px; + border-radius: 8px; + overflow-x: auto; + margin-bottom: 15px; +} + +.wiki-content pre code { + padding: 0; +} + +.wiki-content blockquote { + border-left: 3px solid var(--primary); + padding-left: 15px; + margin-left: 0; + color: #aaa; +} + +.wiki-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 300px; + color: var(--border); + font-size: 1.1rem; +} + +.wiki-editor { + display: flex; + flex-direction: column; + gap: 15px; + flex: 1; +} + +.wiki-editor input { + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background-color: var(--bg); + color: var(--fg); + font-size: 1.1rem; +} + +.wiki-editor textarea { + flex: 1; + min-height: 400px; + padding: 15px; + border: 1px solid var(--border); + border-radius: 8px; + background-color: var(--bg); + color: var(--fg); + font-family: 'Consolas', monospace; + font-size: 0.95rem; + resize: vertical; +} + +.editor-actions { + display: flex; + gap: 10px; +} + +/* ============ AGENTS PAGE ============ */ +#page-agents { + background-color: var(--card-bg); + padding: 20px; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.agents-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.agents-header h2 { + color: var(--primary); +} + +.agents-controls { + display: flex; + gap: 15px; +} + +.agents-controls input, +.agents-controls select { + padding: 10px 15px; + border: 1px solid var(--border); + border-radius: 6px; + background-color: var(--bg); + color: var(--fg); } -/* Agents */ .agents-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); - gap: 1rem; - padding: 1rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 20px; } .agent-card { - background: var(--bg-secondary); - border-radius: 8px; - border: 1px solid var(--border); - overflow: hidden; + background-color: var(--bg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 15px; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.agent-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } .agent-header { - background: var(--bg-card); - padding: 1rem; - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; } .agent-name { - font-size: 1.1rem; + color: var(--card-fg); + font-size: 1.2rem; } .agent-status { - padding: 0.25rem 0.75rem; - border-radius: 12px; - font-size: 0.75rem; - background: var(--priority-low); - color: white; + padding: 4px 10px; + border-radius: 20px; + font-size: 0.8rem; + font-weight: bold; } -.agent-body { - padding: 1rem; +.agent-status.status-active { + background-color: var(--secondary); + color: white; +} + +.agent-status.status-busy { + background-color: var(--warning); + color: white; +} + +.agent-status.status-idle { + background-color: var(--border); + color: var(--fg); +} + +.agent-workload { + margin-bottom: 15px; +} + +.workload-badge { + background-color: var(--primary); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.85rem; } .agent-section { - margin-bottom: 1rem; + margin-bottom: 15px; } .agent-section h4 { - font-size: 0.85rem; - color: var(--text-secondary); - margin-bottom: 0.5rem; + color: var(--primary); + margin-bottom: 8px; + font-size: 0.95rem; } .agent-task { - color: var(--text-primary); - font-size: 0.9rem; + color: var(--fg); } .agent-tools, .agent-files { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; + display: flex; + flex-wrap: wrap; + gap: 5px; } .tool-tag, .file-tag { - background: var(--bg-card); - padding: 0.25rem 0.5rem; - border-radius: 4px; - font-size: 0.75rem; - color: var(--text-secondary); + background-color: var(--border); + color: var(--fg); + padding: 2px 8px; + border-radius: 4px; + font-size: 0.8rem; } -/* Usage */ -.usage-container { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); - gap: 1rem; - padding: 1rem; +.capability-tag { + background-color: var(--secondary); + color: white; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.8rem; } -.usage-card { - background: var(--bg-secondary); - border-radius: 8px; - border: 1px solid var(--border); - padding: 1.5rem; +.more-tag { + background-color: var(--dark); + color: var(--fg); + padding: 2px 8px; + border-radius: 4px; + font-size: 0.8rem; } -.provider-name { - font-size: 1.2rem; - margin-bottom: 1rem; - color: var(--accent); +.no-data { + color: var(--border); + font-style: italic; } -.provider-models { - margin-bottom: 1rem; +.agent-actions { + display: flex; + gap: 10px; + padding-top: 15px; + border-top: 1px solid var(--border); +} + +.agent-actions button { + flex: 1; +} + +/* ============ MODALS ============ */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--modal-bg); + z-index: 1000; + justify-content: center; + align-items: center; +} + +.modal.active { + display: flex; +} + +.modal-content { + background-color: var(--card-bg); + border-radius: 12px; + max-width: 600px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid var(--border); +} + +.modal-header h3 { + color: var(--primary); +} + +.modal-close { + background: none; + border: none; + color: var(--fg); + font-size: 1.5rem; + cursor: pointer; + padding: 0 5px; +} + +.modal-close:hover { + color: var(--danger); +} + +.modal-body { + padding: 20px; +} + +.detail-section { + margin-bottom: 20px; +} + +.detail-section h4 { + color: var(--primary); + margin-bottom: 10px; + font-size: 1rem; +} + +.task-list { + list-style: none; + padding: 0; +} + +.task-list li { + padding: 8px 12px; + background-color: var(--bg); + border-radius: 6px; + margin-bottom: 5px; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +#assign-task-select { + width: 100%; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background-color: var(--bg); + color: var(--fg); + margin-bottom: 15px; +} + +#confirm-assign-btn { + width: 100%; +} + +/* ============ USAGE PAGE ============ */ +#page-usage { + background-color: var(--card-bg); + padding: 20px; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.usage-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 15px; +} + +.usage-header h2 { + color: var(--primary); +} + +.usage-controls { + display: flex; + gap: 20px; + flex-wrap: wrap; +} + +.date-range { + display: flex; + align-items: center; + gap: 10px; +} + +.date-range label { + font-size: 0.9rem; +} + +.date-range input { + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background-color: var(--bg); + color: var(--fg); +} + +.export-actions { + display: flex; + gap: 10px; +} + +.usage-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 30px; +} + +.stat-card { + background-color: var(--bg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + text-align: center; +} + +.stat-card h4 { + color: var(--border); + font-size: 0.9rem; + margin-bottom: 10px; +} + +.stat-card .stat-value { + color: var(--primary); + font-size: 2rem; + font-weight: bold; +} + +.usage-charts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: 20px; + margin-bottom: 30px; +} + +.chart-container { + background-color: var(--bg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; +} + +.chart-container h4 { + color: var(--fg); + margin-bottom: 15px; +} + +.usage-details h3 { + color: var(--primary); + margin-bottom: 20px; +} + +.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: 12px; + padding: 20px; +} + +.provider-card h4 { + color: var(--card-fg); + margin-bottom: 15px; + font-size: 1.1rem; +} + +.provider-stats { + display: flex; + gap: 20px; + margin-bottom: 15px; +} + +.provider-stat { + display: flex; + flex-direction: column; +} + +.provider-stat .stat-label { + color: var(--border); + font-size: 0.8rem; +} + +.provider-stat .stat-value { + color: var(--fg); + font-size: 1.1rem; + font-weight: bold; +} + +.model-list h5 { + color: var(--primary); + margin-bottom: 10px; + font-size: 0.9rem; } .model-item { - padding: 0.5rem; - background: var(--bg-card); - border-radius: 4px; - margin-bottom: 0.5rem; + display: flex; + justify-content: space-between; + background-color: var(--dark); + padding: 10px; + border-radius: 6px; + margin-bottom: 5px; } .model-name { - font-weight: 500; - margin-bottom: 0.25rem; + color: var(--primary); + font-weight: bold; } -.model-meta { - font-size: 0.75rem; - color: var(--text-secondary); +.model-type { + color: var(--fg); + font-size: 0.9rem; } -.provider-quota { - padding: 1rem; - background: var(--bg-card); - border-radius: 6px; +/* ============ RESPONSIVE ============ */ +@media (max-width: 900px) { + .wiki-container { + grid-template-columns: 1fr; + } + + .wiki-sidebar { + max-height: 200px; + } + + .wiki-list { + max-height: 150px; + } } -.quota-title { - font-weight: 600; - margin-bottom: 0.5rem; -} - -.quota-item { - display: flex; - justify-content: space-between; - margin-bottom: 0.5rem; - font-size: 0.9rem; -} - -.quota-label { - color: var(--text-secondary); -} - -.quota-value { - font-weight: 500; -} - -.quota-bar { - height: 8px; - background: var(--bg-primary); - border-radius: 4px; - overflow: hidden; - margin-top: 0.5rem; -} - -.quota-fill { - height: 100%; - background: var(--accent); - transition: width 0.3s; -} - -/* Responsive */ @media (max-width: 768px) { - .navbar { - flex-direction: column; - gap: 1rem; - } - - .nav-links { - width: 100%; - justify-content: space-around; - } - - .wiki-container { - grid-template-columns: 1fr; - } - - .board { - flex-direction: column; - } - - .column { - flex: 0 0 auto; - max-height: none; - } + #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; + } + + .agents-grid { + grid-template-columns: 1fr; + } + + .agents-header { + flex-direction: column; + align-items: flex-start; + } + + .agents-controls { + width: 100%; + } + + .agents-controls input { + flex: 1; + } + + .usage-header { + flex-direction: column; + } + + .usage-controls { + width: 100%; + flex-direction: column; + } + + .date-range { + flex-wrap: wrap; + } + + .usage-charts { + grid-template-columns: 1fr; + } } -/* 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; +/* ============ SCROLLBAR ============ */ +::-webkit-scrollbar { + width: 8px; + height: 8px; } -#task-form select:hover { - border-color: var(--primary); +::-webkit-scrollbar-track { + background: var(--dark); } -#task-form select:focus { - outline: none; - border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; } -#task-form select option { - padding: 0.5rem; +::-webkit-scrollbar-thumb:hover { + background: #555; } diff --git a/public/styles.css.backup b/public/styles.css.backup new file mode 100644 index 0000000..330c089 --- /dev/null +++ b/public/styles.css.backup @@ -0,0 +1,451 @@ +:root { + --bg: #1a1a1a; + --fg: #e0e0e0; + --border: #444; + --primary: #3498db; + --secondary: #2ecc71; + --danger: #e74c3c; + --warning: #f39c12; + --dark: #121212; + --light: #f0f0f0; + --card-bg: #2a2a2a; + --card-fg: #e0e0e0; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: var(--bg); + color: var(--fg); + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +header { + background-color: var(--card-bg); + padding: 20px; + border-radius: 12px; + margin-bottom: 20px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +header h1 { + color: var(--primary); + margin-bottom: 15px; +} + +nav { + display: flex; + gap: 15px; +} + +.nav-link { + color: var(--fg); + text-decoration: none; + padding: 8px 16px; + border-radius: 6px; + transition: background-color 0.3s ease; +} + +.nav-link:hover { + background-color: var(--border); +} + +.nav-link.active { + background-color: var(--primary); + color: white; +} + +.composer { + background-color: var(--card-bg); + padding: 20px; + border-radius: 12px; + margin-bottom: 20px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.composer h2 { + color: var(--primary); + margin-bottom: 15px; +} + +#task-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; +} + +#task-form input, +#task-form textarea, +#task-form select { + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background-color: var(--bg); + color: var(--fg); + font-size: 1rem; +} + +#task-form textarea { + grid-column: span 2; + resize: vertical; + min-height: 100px; +} + +#task-form button { + grid-column: span 2; + padding: 12px 24px; + background-color: var(--primary); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.3s ease; +} + +#task-form button:hover { + background-color: #2980b9; +} + +#board { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; +} + +.column { + background-color: var(--card-bg); + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +.column-header { + padding: 15px; + background-color: var(--dark); + display: flex; + justify-content: space-between; + align-items: center; +} + +.column-header h3 { + color: var(--fg); +} + +.column-count { + background-color: var(--border); + color: var(--fg); + padding: 4px 8px; + border-radius: 4px; + font-size: 0.9rem; +} + +.cards { + padding: 15px; + min-height: 200px; +} + +.card { + background-color: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 15px; + margin-bottom: 15px; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +.card-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.card-title { + color: var(--card-fg); + font-size: 1.1rem; +} + +.badge { + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: bold; +} + +.badge.priority-Low { + background-color: var(--secondary); + color: white; +} + +.badge.priority-Medium { + background-color: var(--warning); + color: white; +} + +.badge.priority-High { + background-color: var(--danger); + color: white; +} + +.badge.priority-Critical { + background-color: #9c27b0; + color: white; +} + +.card-desc { + color: var(--fg); + margin-bottom: 10px; +} + +.meta { + font-size: 0.9rem; + color: var(--border); + margin-bottom: 5px; +} + +.meta.assignee { + font-weight: bold; +} + +.tag { + display: inline-block; + background-color: var(--border); + color: var(--fg); + padding: 2px 6px; + border-radius: 4px; + margin-right: 5px; + margin-bottom: 5px; +} + +.card-check { + margin-right: 10px; +} + +/* Wiki */ +#page-wiki { + background-color: var(--card-bg); + padding: 20px; + border-radius: 12px; + margin-bottom: 20px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.wiki-item { + background-color: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 15px; + margin-bottom: 10px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.wiki-item:hover { + background-color: var(--dark); +} + +.wiki-item.active { + background-color: var(--primary); + color: white; +} + +.wiki-title { + color: var(--card-fg); + margin-bottom: 5px; +} + +.wiki-date { + color: var(--border); + font-size: 0.9rem; +} + +#wiki-content { + background-color: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 15px; + margin-top: 20px; + white-space: pre-wrap; + color: var(--fg); +} + +/* Agents */ +#page-agents { + background-color: var(--card-bg); + padding: 20px; + border-radius: 12px; + margin-bottom: 20px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.agent-card { + background-color: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 15px; + margin-bottom: 15px; +} + +.agent-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.agent-name { + color: var(--card-fg); + font-size: 1.1rem; +} + +.agent-status { + background-color: var(--secondary); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8rem; +} + +.agent-section { + margin-bottom: 15px; +} + +.agent-section h4 { + color: var(--primary); + margin-bottom: 8px; +} + +.agent-task { + color: var(--fg); +} + +.agent-tools, +.agent-files { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.tool-tag, +.file-tag { + background-color: var(--border); + color: var(--fg); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.8rem; +} + +/* Usage */ +#page-usage { + background-color: var(--card-bg); + padding: 20px; + border-radius: 12px; + margin-bottom: 20px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.usage-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; +} + +.provider-card { + background-color: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 15px; +} + +.provider-card h4 { + color: var(--card-fg); + margin-bottom: 10px; +} + +.model-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.model-item { + display: flex; + justify-content: space-between; + background-color: var(--dark); + padding: 8px; + border-radius: 4px; +} + +.model-name { + color: var(--primary); + font-weight: bold; +} + +.model-type, +.model-context { + color: var(--fg); + font-size: 0.9rem; +} + +/* Agent Dropdown Styling */ +#task-form select { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 1rem; + background: var(--bg); + color: var(--fg); + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +#task-form select:hover { + border-color: var(--primary); +} + +#task-form select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); +} + +#task-form select option { + padding: 0.5rem; +} + +/* Responsive Design */ +@media (max-width: 768px) { + #task-form { + grid-template-columns: 1fr; + } + + #task-form textarea { + grid-column: span 1; + } + + #task-form button { + grid-column: span 1; + } + + .usage-grid { + grid-template-columns: 1fr; + } +} diff --git a/server.js b/server.js index 0d4e0de..acd9686 100644 --- a/server.js +++ b/server.js @@ -34,6 +34,20 @@ db.serialize(() => { completed_at TEXT ) `); + + // Usage tracking table + db.run(` + CREATE TABLE IF NOT EXISTS usage_tracking ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + request_type TEXT DEFAULT 'chat', + tokens_used INTEGER DEFAULT 0, + cost_estimate REAL DEFAULT 0, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); }); const app = express(); @@ -112,6 +126,8 @@ function validatePayload(body, partial = false) { return errors; } +// ============ TASKS API ============ + app.get('/api/tasks', (req, res) => { db.all('SELECT * FROM tasks ORDER BY id DESC', (err, rows) => { if (err) { @@ -238,6 +254,207 @@ app.patch('/api/tasks/:id', (req, res) => { }); }); +// ============ WIKI API ============ + +// Helper to extract frontmatter metadata from markdown +function extractMetadata(content) { + const metadata = { + title: '', + created: null, + modified: null, + tags: [] + }; + + // Check for YAML-like frontmatter + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (frontmatterMatch) { + const frontmatter = frontmatterMatch[1]; + const titleMatch = frontmatter.match(/title:\s*(.+)/i); + const createdMatch = frontmatter.match(/created:\s*(.+)/i); + const modifiedMatch = frontmatter.match(/modified:\s*(.+)/i); + const tagsMatch = frontmatter.match(/tags:\s*\[(.+)\]/i); + + if (titleMatch) metadata.title = titleMatch[1].trim(); + if (createdMatch) metadata.created = createdMatch[1].trim(); + if (modifiedMatch) metadata.modified = modifiedMatch[1].trim(); + if (tagsMatch) metadata.tags = tagsMatch[1].split(',').map(t => t.trim()); + } + + // Extract title from first heading if not in frontmatter + if (!metadata.title) { + const headingMatch = content.match(/^#\s+(.+)$/m); + if (headingMatch) { + metadata.title = headingMatch[1].trim(); + } + } + + return metadata; +} + +// GET /api/wiki - List all wiki pages +app.get('/api/wiki', (req, res) => { + try { + if (!fs.existsSync(WIKI_DIR)) { + fs.mkdirSync(WIKI_DIR, { recursive: true }); + return res.json([]); + } + + const files = fs.readdirSync(WIKI_DIR) + .filter(f => f.endsWith('.md')) + .map(filename => { + const filePath = path.join(WIKI_DIR, filename); + const stats = fs.statSync(filePath); + const content = fs.readFileSync(filePath, 'utf8'); + const metadata = extractMetadata(content); + + return { + filename, + title: metadata.title || filename.replace('.md', '').replace(/-/g, ' '), + created: stats.birthtime.toISOString(), + modified: stats.mtime.toISOString(), + tags: metadata.tags + }; + }) + .sort((a, b) => new Date(b.modified) - new Date(a.modified)); + + res.json(files); + } catch (err) { + console.error('Error listing wiki pages:', err); + res.status(500).json({ error: 'failed_to_list_wiki_pages' }); + } +}); + +// GET /api/wiki/:filename - Get specific wiki page content +app.get('/api/wiki/:filename', (req, res) => { + try { + const filename = req.params.filename; + // Security: prevent path traversal + if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { + return res.status(400).json({ error: 'invalid_filename' }); + } + + const filePath = path.join(WIKI_DIR, filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: 'wiki_page_not_found' }); + } + + const content = fs.readFileSync(filePath, 'utf8'); + const stats = fs.statSync(filePath); + const metadata = extractMetadata(content); + + res.json({ + filename, + content, + metadata: { + ...metadata, + created: stats.birthtime.toISOString(), + modified: stats.mtime.toISOString() + } + }); + } catch (err) { + console.error('Error reading wiki page:', err); + res.status(500).json({ error: 'failed_to_read_wiki_page' }); + } +}); + +// POST /api/wiki - Create new wiki page +app.post('/api/wiki', (req, res) => { + try { + const { title, content } = req.body; + + if (!title || typeof title !== 'string' || title.trim().length === 0) { + return res.status(400).json({ error: 'title_is_required' }); + } + + const safeTitle = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + + const timestamp = new Date().toISOString().slice(0, 10); + let filename = `${timestamp}-${safeTitle}.md`; + + // Ensure unique filename + let counter = 1; + while (fs.existsSync(path.join(WIKI_DIR, filename))) { + filename = `${timestamp}-${safeTitle}-${counter}.md`; + counter++; + } + + const filePath = path.join(WIKI_DIR, filename); + const pageContent = content || `# ${title}\n\n## Description\n\nEnter description here.\n\n## Implementation Status\n\n- [ ] Not started\n\n## Technical Details\n\nAdd technical notes here.\n`; + + fs.writeFileSync(filePath, pageContent, 'utf8'); + + broadcast('wiki_created', { filename, title }); + res.status(201).json({ filename, success: true, title }); + } catch (err) { + console.error('Error creating wiki page:', err); + res.status(500).json({ error: 'failed_to_create_wiki_page' }); + } +}); + +// PUT /api/wiki/:filename - Update wiki page +app.put('/api/wiki/:filename', (req, res) => { + try { + const filename = req.params.filename; + const { content } = req.body; + + // Security: prevent path traversal + if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { + return res.status(400).json({ error: 'invalid_filename' }); + } + + if (typeof content !== 'string') { + return res.status(400).json({ error: 'content_is_required' }); + } + + const filePath = path.join(WIKI_DIR, filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: 'wiki_page_not_found' }); + } + + fs.writeFileSync(filePath, content, 'utf8'); + + broadcast('wiki_updated', { filename }); + res.json({ success: true }); + } catch (err) { + console.error('Error updating wiki page:', err); + res.status(500).json({ error: 'failed_to_update_wiki_page' }); + } +}); + +// DELETE /api/wiki/:filename - Delete wiki page +app.delete('/api/wiki/:filename', (req, res) => { + try { + const filename = req.params.filename; + + // Security: prevent path traversal + if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { + return res.status(400).json({ error: 'invalid_filename' }); + } + + const filePath = path.join(WIKI_DIR, filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: 'wiki_page_not_found' }); + } + + fs.unlinkSync(filePath); + + broadcast('wiki_deleted', { filename }); + res.json({ success: true }); + } catch (err) { + console.error('Error deleting wiki page:', err); + res.status(500).json({ error: 'failed_to_delete_wiki_page' }); + } +}); + +// ============ AGENTS API (Enhanced) ============ + app.get('/api/agents', (req, res) => { try { const agents = []; @@ -247,7 +464,47 @@ app.get('/api/agents', (req, res) => { return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory(); }); - agentDirs.forEach(agentName => { + // Get task counts per agent (workload) and completed tasks (history) + const getAgentTaskData = (agentName) => { + return new Promise((resolve) => { + const result = { + workload: 0, + activeTasks: [], + completedTasks: [] + }; + + // Get workload (tasks in Todo, In Progress, Review) + db.all( + `SELECT * FROM tasks + WHERE assignee = ? AND status IN ('Todo', 'In Progress', 'Review') + ORDER BY priority DESC, created_at ASC`, + [agentName], + (err, activeRows) => { + if (!err && activeRows) { + result.workload = activeRows.length; + result.activeTasks = activeRows.map(normalizeTask); + } + + // Get last 5 completed tasks + db.all( + `SELECT * FROM tasks + WHERE assignee = ? AND status = 'Done' + ORDER BY completed_at DESC + LIMIT 5`, + [agentName], + (err2, completedRows) => { + if (!err2 && completedRows) { + result.completedTasks = completedRows.map(normalizeTask); + } + resolve(result); + } + ); + } + ); + }); + }; + + const agentPromises = agentDirs.map(async (agentName) => { const agentPath = path.join(AGENTS_DIR, agentName); const workspacePath = path.join(agentPath, 'workspace'); @@ -257,7 +514,11 @@ app.get('/api/agents', (req, res) => { currentTask: null, tools: [], files: [], - permissions: [] + permissions: [], + workload: 0, + activeTasks: [], + completedTasks: [], + capabilities: [] }; if (fs.existsSync(workspacePath)) { @@ -267,36 +528,104 @@ app.get('/api/agents', (req, res) => { const memoryPath = path.join(workspacePath, 'MEMORY.md'); if (fs.existsSync(memoryPath)) { const memory = fs.readFileSync(memoryPath, 'utf8'); - const toolMatches = memory.match(/##\\s+Tools([\\s\\S]*?)(?=##|$)/i); + const toolMatches = memory.match(/##\s+Tools([\s\S]*?)(?=##|$)/i); if (toolMatches) { - agent.tools = toolMatches[1].split('\\n') + agent.tools = toolMatches[1].split('\n') .filter(line => line.trim().startsWith('-')) - .map(line => line.replace(/^-\\s*/, '').trim()); + .map(line => line.replace(/^-\s*/, '').trim()); + } + + // Extract capabilities/skills + const skillsMatch = memory.match(/##\s+Skills([\s\S]*?)(?=##|$)/i); + if (skillsMatch) { + agent.capabilities = skillsMatch[1].split('\n') + .filter(line => line.trim().startsWith('-')) + .map(line => line.replace(/^-\s*/, '').trim()); } } const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md'); if (fs.existsSync(heartbeatPath)) { const heartbeat = fs.readFileSync(heartbeatPath, 'utf8'); - const taskMatch = heartbeat.match(/Current Task:\\s*(.+)/i); + const taskMatch = heartbeat.match(/Current Task:\s*(.+)/i); if (taskMatch) { agent.currentTask = taskMatch[1].trim(); } + + // Check last heartbeat time for status + const timeMatch = heartbeat.match(/Last Heartbeat:\s*(.+)/i); + if (timeMatch) { + const lastBeat = new Date(timeMatch[1]); + const now = new Date(); + const minutesAgo = (now - lastBeat) / 1000 / 60; + + if (minutesAgo > 30) { + agent.status = 'idle'; + } else if (minutesAgo > 10) { + agent.status = 'busy'; + } + } } } + + // Get task data from database + const taskData = await getAgentTaskData(agentName); + agent.workload = taskData.workload; + agent.activeTasks = taskData.activeTasks; + agent.completedTasks = taskData.completedTasks; - agents.push(agent); + return agent; }); - } - res.json(agents); + Promise.all(agentPromises).then(results => { + res.json(results); + }); + } else { + res.json([]); + } } catch (err) { console.error('Error reading agents:', err); res.status(500).json({ error: 'failed_to_fetch_agents' }); } }); -// Usage endpoint +// POST /api/agents/:name/assign - Assign task to agent +app.post('/api/agents/:name/assign', (req, res) => { + const agentName = req.params.name; + const { taskId } = req.body; + + if (!taskId) { + return res.status(400).json({ error: 'taskId_is_required' }); + } + + db.run( + 'UPDATE tasks SET assignee = ?, updated_at = datetime("now") WHERE id = ?', + [agentName, taskId], + function(err) { + if (err) { + return res.status(500).json({ error: 'failed_to_assign_task' }); + } + + if (this.changes === 0) { + return res.status(404).json({ error: 'task_not_found' }); + } + + db.get('SELECT * FROM tasks WHERE id = ?', [taskId], (fetchErr, row) => { + if (fetchErr || !row) { + return res.status(500).json({ error: 'failed_to_fetch_task' }); + } + + const task = normalizeTask(row); + broadcast('task_assigned', { agent: agentName, task }); + res.json({ success: true, task }); + }); + } + ); +}); + +// ============ USAGE API (Enhanced) ============ + +// GET /api/usage - Basic usage info (existing) app.get('/api/usage', (req, res) => { try { const usage = { @@ -343,7 +672,191 @@ app.get('/api/usage', (req, res) => { } }); -// Heartbeat endpoint for agents +// GET /api/usage/stats - Usage statistics with date range +app.get('/api/usage/stats', (req, res) => { + const { from, to } = req.query; + + let query = 'SELECT * FROM usage_tracking'; + const params = []; + const conditions = []; + + if (from) { + conditions.push('timestamp >= ?'); + params.push(from); + } + + if (to) { + conditions.push('timestamp <= ?'); + params.push(to); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY timestamp DESC'; + + db.all(query, params, (err, rows) => { + if (err) { + console.error('Error fetching usage stats:', err); + return res.status(500).json({ error: 'failed_to_fetch_usage_stats' }); + } + + // Aggregate stats + const stats = { + totalRequests: rows.length, + totalTokens: rows.reduce((sum, r) => sum + (r.tokens_used || 0), 0), + totalCost: rows.reduce((sum, r) => sum + (r.cost_estimate || 0), 0), + byProvider: {}, + byAgent: {}, + byModel: {}, + records: rows + }; + + rows.forEach(record => { + // By provider + if (!stats.byProvider[record.provider]) { + stats.byProvider[record.provider] = { requests: 0, tokens: 0, cost: 0 }; + } + stats.byProvider[record.provider].requests++; + stats.byProvider[record.provider].tokens += record.tokens_used || 0; + stats.byProvider[record.provider].cost += record.cost_estimate || 0; + + // By agent + if (!stats.byAgent[record.agent]) { + stats.byAgent[record.agent] = { requests: 0, tokens: 0, cost: 0 }; + } + stats.byAgent[record.agent].requests++; + stats.byAgent[record.agent].tokens += record.tokens_used || 0; + stats.byAgent[record.agent].cost += record.cost_estimate || 0; + + // By model + if (!stats.byModel[record.model]) { + stats.byModel[record.model] = { requests: 0, tokens: 0, cost: 0 }; + } + stats.byModel[record.model].requests++; + stats.byModel[record.model].tokens += record.tokens_used || 0; + stats.byModel[record.model].cost += record.cost_estimate || 0; + }); + + res.json(stats); + }); +}); + +// GET /api/usage/agents - Usage breakdown by agent +app.get('/api/usage/agents', (req, res) => { + const { from, to } = req.query; + + let query = ` + SELECT agent, + COUNT(*) as requests, + SUM(tokens_used) as tokens, + SUM(cost_estimate) as cost, + provider, + model + FROM usage_tracking + `; + const params = []; + const conditions = []; + + if (from) { + conditions.push('timestamp >= ?'); + params.push(from); + } + + if (to) { + conditions.push('timestamp <= ?'); + params.push(to); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' GROUP BY agent ORDER BY requests DESC'; + + db.all(query, params, (err, rows) => { + if (err) { + console.error('Error fetching agent usage:', err); + return res.status(500).json({ error: 'failed_to_fetch_agent_usage' }); + } + + res.json(rows); + }); +}); + +// POST /api/usage/track - Track usage (for external callers) +app.post('/api/usage/track', (req, res) => { + const { agent, provider, model, requestType, tokensUsed, costEstimate } = req.body; + + if (!agent || !provider || !model) { + return res.status(400).json({ error: 'agent, provider, and model are required' }); + } + + db.run( + `INSERT INTO usage_tracking (agent, provider, model, request_type, tokens_used, cost_estimate) + VALUES (?, ?, ?, ?, ?, ?)`, + [agent, provider, model, requestType || 'chat', tokensUsed || 0, costEstimate || 0], + function(err) { + if (err) { + console.error('Error tracking usage:', err); + return res.status(500).json({ error: 'failed_to_track_usage' }); + } + + res.status(201).json({ success: true, id: this.lastID }); + } + ); +}); + +// GET /api/usage/export - Export usage data +app.get('/api/usage/export', (req, res) => { + const { format = 'json', from, to } = req.query; + + let query = 'SELECT * FROM usage_tracking'; + const params = []; + const conditions = []; + + if (from) { + conditions.push('timestamp >= ?'); + params.push(from); + } + + if (to) { + conditions.push('timestamp <= ?'); + params.push(to); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY timestamp DESC'; + + db.all(query, params, (err, rows) => { + if (err) { + console.error('Error exporting usage:', err); + return res.status(500).json({ error: 'failed_to_export_usage' }); + } + + if (format === 'csv') { + const csv = [ + 'id,agent,provider,model,request_type,tokens_used,cost_estimate,timestamp', + ...rows.map(r => `${r.id},${r.agent},${r.provider},${r.model},${r.request_type},${r.tokens_used},${r.cost_estimate},${r.timestamp}`) + ].join('\n'); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename="usage-export.csv"'); + res.send(csv); + } else { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', 'attachment; filename="usage-export.json"'); + res.json(rows); + } + }); +}); + +// ============ HEARTBEAT ============ + app.get('/api/heartbeat/:agent', (req, res) => { const agent = req.params.agent; @@ -365,6 +878,8 @@ app.get('/api/heartbeat/:agent', (req, res) => { ); }); +// ============ WEBSOCKET ============ + wss.on('connection', (socket) => { socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } })); }); diff --git a/server.js.backup b/server.js.backup new file mode 100644 index 0000000..0d4e0de --- /dev/null +++ b/server.js.backup @@ -0,0 +1,374 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const http = require('http'); +const sqlite3 = require('sqlite3').verbose(); +const { WebSocketServer } = require('ws'); + +const PORT = process.env.PORT || 8395; +const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'tasks.db'); +const WIKI_DIR = process.env.WIKI_DIR || '/home/bear/.openclaw/workspace/wiki'; + +const AGENTS_DIR = process.env.AGENTS_DIR || '/home/bear/.openclaw/agents'; +const OPENCLAW_CONFIG = process.env.OPENCLAW_CONFIG || '/home/bear/.openclaw/openclaw.json'; +const VALID_STATUSES = ['Backlog', 'Todo', 'In Progress', 'Review', 'Done']; +const VALID_PRIORITIES = ['Low', 'Medium', 'High', 'Critical']; + +fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); +fs.mkdirSync(WIKI_DIR, { recursive: true }); + +const db = new sqlite3.Database(DB_PATH); + +db.serialize(() => { + db.run(` + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT DEFAULT '', + assignee TEXT DEFAULT '', + priority TEXT NOT NULL DEFAULT 'Medium', + status TEXT NOT NULL DEFAULT 'Backlog', + tags TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT + ) + `); +}); + +const app = express(); +const server = http.createServer(app); +const wss = new WebSocketServer({ server }); + +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); + +function normalizeTask(row) { + return { + ...row, + tags: (() => { + try { + return JSON.parse(row.tags || '[]'); + } catch { + return []; + } + })(), + }; +} + +function writeWiki(task) { + const safeTitle = task.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) || `task-${task.id}`; + + const fileName = `${new Date().toISOString().slice(0, 10)}-task-${task.id}-${safeTitle}.md`; + const filePath = path.join(WIKI_DIR, fileName); + + const md = `# ${task.title}\n\n` + + `- Task ID: ${task.id}\n` + + `- Assignee: ${task.assignee || 'Unassigned'}\n` + + `- Priority: ${task.priority}\n` + + `- Status: ${task.status}\n` + + `- Tags: ${task.tags.length ? task.tags.join(', ') : 'None'}\n` + + `- Created: ${task.created_at}\n` + + `- Completed: ${task.completed_at || new Date().toISOString()}\n\n` + + `## Description\n\n${task.description || 'No description provided.'}\n`; + + fs.writeFileSync(filePath, md, 'utf8'); +} + +function broadcast(type, payload) { + const data = JSON.stringify({ type, payload }); + for (const client of wss.clients) { + if (client.readyState === 1) { + client.send(data); + } + } +} + +function validatePayload(body, partial = false) { + const errors = []; + + if (!partial || body.title !== undefined) { + if (typeof body.title !== 'string' || body.title.trim().length === 0) { + errors.push('title is required'); + } + } + + if (body.status !== undefined && !VALID_STATUSES.includes(body.status)) { + errors.push(`status must be one of: ${VALID_STATUSES.join(', ')}`); + } + + if (body.priority !== undefined && !VALID_PRIORITIES.includes(body.priority)) { + errors.push(`priority must be one of: ${VALID_PRIORITIES.join(', ')}`); + } + + if (body.tags !== undefined && !Array.isArray(body.tags)) { + errors.push('tags must be an array of strings'); + } + + return errors; +} + +app.get('/api/tasks', (req, res) => { + db.all('SELECT * FROM tasks ORDER BY id DESC', (err, rows) => { + if (err) { + return res.status(500).json({ error: 'failed_to_fetch_tasks' }); + } + + return res.json(rows.map(normalizeTask)); + }); +}); + +app.post('/api/tasks', (req, res) => { + const errors = validatePayload(req.body, false); + if (errors.length) { + return res.status(400).json({ error: 'validation_error', details: errors }); + } + + const title = req.body.title.trim(); + const description = typeof req.body.description === 'string' ? req.body.description : ''; + const assignee = typeof req.body.assignee === 'string' ? req.body.assignee : ''; + const priority = req.body.priority || 'Medium'; + const status = req.body.status || 'Backlog'; + const tags = Array.isArray(req.body.tags) ? req.body.tags.filter((t) => typeof t === 'string') : []; + + db.run( + `INSERT INTO tasks (title, description, assignee, priority, status, tags) + VALUES (?, ?, ?, ?, ?, ?)`, + [title, description, assignee, priority, status, JSON.stringify(tags)], + function onInsert(err) { + if (err) { + return res.status(500).json({ error: 'failed_to_create_task' }); + } + + db.get('SELECT * FROM tasks WHERE id = ?', [this.lastID], (fetchErr, row) => { + if (fetchErr || !row) { + return res.status(500).json({ error: 'failed_to_fetch_created_task' }); + } + + const task = normalizeTask(row); + broadcast('task_created', task); + return res.status(201).json(task); + }); + } + ); +}); + +app.patch('/api/tasks/:id', (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'invalid_task_id' }); + } + + const errors = validatePayload(req.body, true); + if (errors.length) { + return res.status(400).json({ error: 'validation_error', details: errors }); + } + + db.get('SELECT * FROM tasks WHERE id = ?', [id], (err, existing) => { + if (err) { + return res.status(500).json({ error: 'failed_to_find_task' }); + } + if (!existing) { + return res.status(404).json({ error: 'task_not_found' }); + } + + const existingTask = normalizeTask(existing); + const next = { + title: req.body.title !== undefined ? req.body.title.trim() : existingTask.title, + description: req.body.description !== undefined ? String(req.body.description) : existingTask.description, + assignee: req.body.assignee !== undefined ? String(req.body.assignee) : existingTask.assignee, + priority: req.body.priority !== undefined ? req.body.priority : existingTask.priority, + status: req.body.status !== undefined ? req.body.status : existingTask.status, + tags: req.body.tags !== undefined + ? req.body.tags.filter((t) => typeof t === 'string') + : existingTask.tags, + }; + + const nowDone = next.status === 'Done'; + const wasDone = existingTask.status === 'Done'; + const completedAt = nowDone && !wasDone + ? new Date().toISOString() + : nowDone + ? existing.completed_at + : null; + + db.run( + `UPDATE tasks + SET title = ?, description = ?, assignee = ?, priority = ?, status = ?, tags = ?, + completed_at = ?, updated_at = datetime('now') + WHERE id = ?`, + [ + next.title, + next.description, + next.assignee, + next.priority, + next.status, + JSON.stringify(next.tags), + completedAt, + id, + ], + (updateErr) => { + if (updateErr) { + return res.status(500).json({ error: 'failed_to_update_task' }); + } + + db.get('SELECT * FROM tasks WHERE id = ?', [id], (fetchErr, row) => { + if (fetchErr || !row) { + return res.status(500).json({ error: 'failed_to_fetch_updated_task' }); + } + + const task = normalizeTask(row); + + if (nowDone && !wasDone) { + try { + writeWiki(task); + } catch (wikiErr) { + console.error('wiki_creation_error', wikiErr); + } + } + + broadcast('task_updated', task); + return res.json(task); + }); + }); + }); + }); + +app.get('/api/agents', (req, res) => { + try { + const agents = []; + + if (fs.existsSync(AGENTS_DIR)) { + const agentDirs = fs.readdirSync(AGENTS_DIR).filter(d => { + return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory(); + }); + + agentDirs.forEach(agentName => { + const agentPath = path.join(AGENTS_DIR, agentName); + const workspacePath = path.join(agentPath, 'workspace'); + + const agent = { + name: agentName, + status: 'active', + currentTask: null, + tools: [], + files: [], + permissions: [] + }; + + if (fs.existsSync(workspacePath)) { + const files = fs.readdirSync(workspacePath); + agent.files = files.filter(f => f.endsWith('.md')); + + const memoryPath = path.join(workspacePath, 'MEMORY.md'); + if (fs.existsSync(memoryPath)) { + const memory = fs.readFileSync(memoryPath, 'utf8'); + const toolMatches = memory.match(/##\\s+Tools([\\s\\S]*?)(?=##|$)/i); + if (toolMatches) { + agent.tools = toolMatches[1].split('\\n') + .filter(line => line.trim().startsWith('-')) + .map(line => line.replace(/^-\\s*/, '').trim()); + } + } + + const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md'); + if (fs.existsSync(heartbeatPath)) { + const heartbeat = fs.readFileSync(heartbeatPath, 'utf8'); + const taskMatch = heartbeat.match(/Current Task:\\s*(.+)/i); + if (taskMatch) { + agent.currentTask = taskMatch[1].trim(); + } + } + } + + agents.push(agent); + }); + } + + res.json(agents); + } catch (err) { + console.error('Error reading agents:', err); + res.status(500).json({ error: 'failed_to_fetch_agents' }); + } +}); + +// Usage endpoint +app.get('/api/usage', (req, res) => { + try { + const usage = { + providers: [], + lastUpdated: new Date().toISOString() + }; + + if (fs.existsSync(OPENCLAW_CONFIG)) { + const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8')); + + if (config.models) { + const providerMap = {}; + + Object.entries(config.models).forEach(([modelName, modelConfig]) => { + const provider = modelConfig.provider || 'unknown'; + + if (!providerMap[provider]) { + providerMap[provider] = { + name: provider, + models: [], + quota: { + requests: 0, + tokens: 0, + limit: 'unlimited' + } + }; + } + + providerMap[provider].models.push({ + name: modelName, + type: modelConfig.type || 'chat', + contextWindow: modelConfig.context_window || 'unknown' + }); + }); + + usage.providers = Object.values(providerMap); + } + } + + res.json(usage); + } catch (err) { + console.error('Error reading usage:', err); + res.status(500).json({ error: 'failed_to_fetch_usage' }); + } +}); + +// Heartbeat endpoint for agents +app.get('/api/heartbeat/:agent', (req, res) => { + const agent = req.params.agent; + + db.all( + 'SELECT * FROM tasks WHERE assignee = ? AND status IN (?, ?, ?) ORDER BY priority DESC, created_at ASC', + [agent, 'Todo', 'In Progress', 'Review'], + (err, rows) => { + if (err) { + return res.status(500).json({ error: 'failed_to_fetch_tasks' }); + } + + const tasks = rows.map(normalizeTask); + res.json({ + agent, + pending_tasks: tasks.length, + tasks + }); + } + ); +}); + +wss.on('connection', (socket) => { + socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } })); +}); + +server.listen(PORT, '0.0.0.0', () => { + console.log(`openclaw-taskboard listening on ${PORT}`); +});