From 03ee61aab3c5ad6803116872788190789a6b97ab Mon Sep 17 00:00:00 2001 From: Christopher Mayor Date: Wed, 4 Mar 2026 11:31:41 -0800 Subject: [PATCH] feat: separate routes v2 --- public/app.js | 1411 +++++++++++++++++++++++---------------------- server.js | 55 +- views/agents.html | 40 ++ views/layout.html | 30 + views/tasks.html | 58 ++ views/usage.html | 49 ++ views/wiki.html | 35 ++ 7 files changed, 987 insertions(+), 691 deletions(-) create mode 100644 views/agents.html create mode 100644 views/layout.html create mode 100644 views/tasks.html create mode 100644 views/usage.html create mode 100644 views/wiki.html diff --git a/public/app.js b/public/app.js index c942107..a7322c0 100644 --- a/public/app.js +++ b/public/app.js @@ -1,730 +1,761 @@ -// ============ NAVIGATION ============ -const navLinks = document.querySelectorAll('.nav-link'); -const pages = document.querySelectorAll('.page'); +// ============ STATE ============ +const CURRENT_PAGE = document.body?.dataset?.page || 'tasks'; -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: [] } + 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') || '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) - }); - - 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); - } -} - -// ============ 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); +// ============ TASK DASHBOARD ============ +async function loadTasks() { + const res = await fetch('/api/tasks'); + const tasks = await res.json(); + + Object.keys(COLUMNS).forEach((status) => { + COLUMNS[status].tasks = []; + }); + + tasks.forEach((task) => { + if (COLUMNS[task.status]) { + COLUMNS[task.status].tasks.push(task); } + }); + + renderBoard(); +} + +function renderBoard() { + const board = document.getElementById('board'); + if (!board) return; + + 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(' ')}

+ + `; + + 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); + }); +} + +async function populateAgentDropdown() { + try { + const res = await fetch('/api/agents'); + const agents = await res.json(); + + const select = document.getElementById('assignee'); + if (!select) return; + + const firstOption = select.options[0]; + select.innerHTML = ''; + select.appendChild(firstOption); + + agents.forEach((agent) => { + const option = document.createElement('option'); + option.value = agent.name; + option.textContent = agent.name; + select.appendChild(option); + }); + } catch (err) { + console.error('Failed to load agents for dropdown:', err); + } +} + +function initTasksPage() { + const taskForm = document.getElementById('task-form'); + if (!taskForm) return; + + taskForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const formData = new FormData(e.target); + const tagsValue = formData.get('tags'); + const task = { + title: formData.get('title'), + description: formData.get('description'), + assignee: formData.get('assignee'), + priority: formData.get('priority'), + status: formData.get('status') || 'Backlog', + tags: (tagsValue || '').split(',').map((t) => t.trim()).filter((t) => t), + }; + + await fetch('/api/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(task), + }); + + e.target.reset(); + loadTasks(); + }); + + populateAgentDropdown(); + loadTasks(); +} + +// ============ WIKI ============ +async function loadWiki() { + try { + const res = await fetch('/api/wiki'); + wikiPages = await res.json(); + renderWikiList(); + } catch (err) { + console.error('Failed to load wiki:', err); + } +} + +function renderWikiList(filter = '') { + const wikiList = document.getElementById('wiki-list'); + if (!wikiList) return; + + wikiList.innerHTML = ''; + + const filtered = filter + ? wikiPages.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase()) || p.filename.toLowerCase().includes(filter.toLowerCase())) + : wikiPages; + + filtered.forEach((page) => { + 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); + }); +} + +async function selectWikiPage(filename) { + try { + const res = await fetch(`/api/wiki/${filename}`); + if (!res.ok) throw new Error('Page not found'); + + currentWikiPage = await res.json(); + + const titleEl = document.getElementById('wiki-page-title'); + const actionsEl = document.getElementById('wiki-page-actions'); + const contentEl = document.getElementById('wiki-content'); + const editorEl = document.getElementById('wiki-editor'); + const searchEl = document.getElementById('wiki-search'); + + if (!titleEl || !actionsEl || !contentEl || !editorEl || !searchEl) return; + + titleEl.textContent = currentWikiPage.metadata.title || filename; + actionsEl.style.display = 'flex'; + + contentEl.style.display = 'block'; + editorEl.style.display = 'none'; + + if (typeof marked !== 'undefined') { + contentEl.innerHTML = marked.parse(currentWikiPage.content); + } else { + contentEl.innerHTML = `
${escapeHtml(currentWikiPage.content)}
`; + } + + renderWikiList(searchEl.value); + } catch (err) { + console.error('Failed to load wiki page:', err); + } +} + +function initWikiPage() { + const searchInput = document.getElementById('wiki-search'); + const newBtn = document.getElementById('wiki-new-btn'); + const editBtn = document.getElementById('wiki-edit-btn'); + const saveBtn = document.getElementById('wiki-save-btn'); + const cancelBtn = document.getElementById('wiki-cancel-btn'); + const deleteBtn = document.getElementById('wiki-delete-btn'); + + if (!searchInput || !newBtn || !editBtn || !saveBtn || !cancelBtn || !deleteBtn) return; + + searchInput.addEventListener('input', (e) => { + renderWikiList(e.target.value); + }); + + newBtn.addEventListener('click', async () => { + const title = prompt('Enter page title:'); + if (!title) return; + + try { + const res = await fetch('/api/wiki', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }); + + if (res.ok) { + const data = await res.json(); + await loadWiki(); + selectWikiPage(data.filename); + } + } catch (err) { + console.error('Failed to create wiki page:', err); + } + }); + + editBtn.addEventListener('click', () => { + if (!currentWikiPage) return; + + const contentEl = document.getElementById('wiki-content'); + const editorEl = document.getElementById('wiki-editor'); + const titleEl = document.getElementById('wiki-edit-title'); + const editContentEl = document.getElementById('wiki-edit-content'); + if (!contentEl || !editorEl || !titleEl || !editContentEl) return; + + contentEl.style.display = 'none'; + editorEl.style.display = 'block'; + titleEl.value = currentWikiPage.metadata.title || ''; + editContentEl.value = currentWikiPage.content; + }); + + saveBtn.addEventListener('click', async () => { + if (!currentWikiPage) return; + + const editContentEl = document.getElementById('wiki-edit-content'); + if (!editContentEl) return; + + try { + const res = await fetch(`/api/wiki/${currentWikiPage.filename}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: editContentEl.value }), + }); + + if (res.ok) { + await selectWikiPage(currentWikiPage.filename); + } + } catch (err) { + console.error('Failed to save wiki page:', err); + } + }); + + cancelBtn.addEventListener('click', () => { + const editorEl = document.getElementById('wiki-editor'); + const contentEl = document.getElementById('wiki-content'); + if (!editorEl || !contentEl) return; + + editorEl.style.display = 'none'; + contentEl.style.display = 'block'; + }); + + deleteBtn.addEventListener('click', async () => { + if (!currentWikiPage) return; + + if (!confirm(`Delete "${currentWikiPage.metadata.title || currentWikiPage.filename}"?`)) return; + + try { + const res = await fetch(`/api/wiki/${currentWikiPage.filename}`, { + method: 'DELETE', + }); + + if (res.ok) { + currentWikiPage = null; + + const pageTitle = document.getElementById('wiki-page-title'); + const pageActions = document.getElementById('wiki-page-actions'); + const wikiContent = document.getElementById('wiki-content'); + + if (pageTitle) pageTitle.textContent = 'Select a page'; + if (pageActions) pageActions.style.display = 'none'; + if (wikiContent) { + wikiContent.innerHTML = '

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

'; + } + + await loadWiki(); + } + } catch (err) { + console.error('Failed to delete wiki page:', err); + } + }); + + loadWiki(); +} + +// ============ AGENTS ============ +async function loadAgents() { + try { + const res = await fetch('/api/agents'); + allAgents = await res.json(); + renderAgents(); + } catch (err) { + console.error('Failed to load agents:', err); + } +} + +function renderAgents(filter = '', statusFilter = '') { + const grid = document.getElementById('agents-grid'); + if (!grid) return; + + grid.innerHTML = ''; + + let filtered = allAgents; + + if (filter) { + filtered = filtered.filter((a) => + a.name.toLowerCase().includes(filter.toLowerCase()) || + (a.currentTask && a.currentTask.toLowerCase().includes(filter.toLowerCase())) + ); + } + + if (statusFilter) { + filtered = filtered.filter((a) => a.status === statusFilter); + } + + filtered.forEach((agent) => { + 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'} +
+
+
+
+ + +
+ `; + + cardEl.querySelector('.agent-details-btn').addEventListener('click', () => showAgentDetails(agent)); + cardEl.querySelector('.agent-assign-btn').addEventListener('click', () => showAssignModal(agent.name)); + + grid.appendChild(cardEl); + }); +} + +function showAgentDetails(agent) { + const modal = document.getElementById('agent-modal'); + const body = document.getElementById('modal-agent-body'); + const title = document.getElementById('modal-agent-name'); + if (!modal || !body || !title) return; + + title.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'); +} + +async function showAssignModal(agentName) { + const modal = document.getElementById('assign-modal'); + const agentNameEl = document.getElementById('assign-agent-name'); + const select = document.getElementById('assign-task-select'); + if (!modal || !agentNameEl || !select) return; + + agentNameEl.textContent = agentName; + + try { + const res = await fetch('/api/tasks'); + const tasks = await res.json(); + const unassignedTasks = tasks.filter((t) => t.status !== 'Done' && (!t.assignee || t.assignee === '')); + + select.innerHTML = ''; + + unassignedTasks.forEach((task) => { + const option = document.createElement('option'); + option.value = task.id; + option.textContent = `${task.title} (${task.priority})`; + select.appendChild(option); + }); + + select.dataset.agent = agentName; + modal.classList.add('active'); + } catch (err) { + console.error('Failed to load tasks for assignment:', err); + } +} + +function initAgentsPage() { + const searchInput = document.getElementById('agent-search'); + const statusFilter = document.getElementById('agent-status-filter'); + const closeBtn = document.getElementById('modal-close'); + const assignCloseBtn = document.getElementById('assign-modal-close'); + const confirmAssignBtn = document.getElementById('confirm-assign-btn'); + + if (!searchInput || !statusFilter || !closeBtn || !assignCloseBtn || !confirmAssignBtn) return; + + searchInput.addEventListener('input', (e) => { + renderAgents(e.target.value, statusFilter.value); + }); + + statusFilter.addEventListener('change', (e) => { + renderAgents(searchInput.value, e.target.value); + }); + + closeBtn.addEventListener('click', () => { + const modal = document.getElementById('agent-modal'); + if (modal) modal.classList.remove('active'); + }); + + assignCloseBtn.addEventListener('click', () => { + const modal = document.getElementById('assign-modal'); + if (modal) modal.classList.remove('active'); + }); + + confirmAssignBtn.addEventListener('click', async () => { + const select = document.getElementById('assign-task-select'); + if (!select) return; + + const taskId = select.value; + const agentName = select.dataset.agent; + + if (!taskId) { + alert('Please select a task'); + return; + } + + try { + const res = await fetch(`/api/agents/${agentName}/assign`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ taskId: Number.parseInt(taskId, 10) }), + }); + + if (res.ok) { + const modal = document.getElementById('assign-modal'); + if (modal) modal.classList.remove('active'); + await loadAgents(); + } + } catch (err) { + console.error('Failed to assign task:', err); + } + }); + + loadAgents(); +} + +// ============ USAGE ============ +async function loadUsage() { + const from = document.getElementById('usage-from')?.value; + const to = document.getElementById('usage-to')?.value; + + let statsUrl = '/api/usage/stats'; + const params = []; + if (from) params.push(`from=${from}`); + if (to) params.push(`to=${to}`); + if (params.length) statsUrl += `?${params.join('&')}`; + + try { + const statsRes = await fetch(statsUrl); + usageStats = await statsRes.json(); + + const usageRes = await fetch('/api/usage'); + const usageData = await usageRes.json(); + + renderUsageStats(); + renderUsageCharts(); + renderProviderDetails(usageData); + } catch (err) { + console.error('Failed to load usage:', err); + } } function renderUsageStats() { - 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); + const requestsEl = document.getElementById('stat-requests'); + const tokensEl = document.getElementById('stat-tokens'); + const costEl = document.getElementById('stat-cost'); + + if (!requestsEl || !tokensEl || !costEl || !usageStats) return; + + requestsEl.textContent = usageStats.totalRequests.toLocaleString(); + tokensEl.textContent = usageStats.totalTokens.toLocaleString(); + costEl.textContent = `$${usageStats.totalCost.toFixed(2)}`; } function renderUsageCharts() { - // 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' - ] - }] + if (!usageStats || typeof Chart === 'undefined') return; + + const providerCanvas = document.getElementById('chart-provider'); + const agentCanvas = document.getElementById('chart-agent'); + if (!providerCanvas || !agentCanvas) return; + + const providerCtx = providerCanvas.getContext('2d'); + const agentCtx = agentCanvas.getContext('2d'); + + if (!providerCtx || !agentCtx) return; + + if (providerChart) providerChart.destroy(); + + const providerLabels = Object.keys(usageStats.byProvider); + const providerData = providerLabels.map((p) => usageStats.byProvider[p].requests); + + providerChart = new Chart(providerCtx, { + type: 'doughnut', + data: { + labels: providerLabels, + datasets: [{ + data: providerData, + backgroundColor: ['#3498db', '#2ecc71', '#e74c3c', '#f39c12', '#9c27b0', '#00bcd4'], + }], + }, + options: { + responsive: true, + plugins: { + legend: { + position: 'bottom', + labels: { color: '#e0e0e0' }, }, - 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' - }] + }, + }, + }); + + 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' }, }, - options: { - responsive: true, - plugins: { - legend: { - display: false - } - }, - scales: { - y: { - beginAtZero: true, - ticks: { color: '#e0e0e0' }, - grid: { color: '#444' } - }, - x: { - 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); - }); + const grid = document.getElementById('provider-grid'); + if (!grid || !usageData || !usageStats) return; + + grid.innerHTML = ''; + + usageData.providers.forEach((provider) => { + const providerEl = document.createElement('div'); + providerEl.className = 'provider-card'; + + const providerUsage = usageStats.byProvider[provider.name] || { requests: 0, tokens: 0, cost: 0 }; + + providerEl.innerHTML = ` +

${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); +function initUsagePage() { + const fromInput = document.getElementById('usage-from'); + const toInput = document.getElementById('usage-to'); + const applyBtn = document.getElementById('usage-apply-filter'); + const exportJsonBtn = document.getElementById('export-json'); + const exportCsvBtn = document.getElementById('export-csv'); -// Export JSON -document.getElementById('export-json').addEventListener('click', () => { - const from = document.getElementById('usage-from').value; - const to = document.getElementById('usage-to').value; + if (!fromInput || !toInput || !applyBtn || !exportJsonBtn || !exportCsvBtn) return; + + const to = new Date(); + const from = new Date(); + from.setDate(from.getDate() - 30); + + fromInput.value = from.toISOString().split('T')[0]; + toInput.value = to.toISOString().split('T')[0]; + + applyBtn.addEventListener('click', loadUsage); + + exportJsonBtn.addEventListener('click', () => { + const fromValue = fromInput.value; + const toValue = toInput.value; let url = '/api/usage/export?format=json'; - if (from) url += `&from=${from}`; - if (to) url += `&to=${to}`; + if (fromValue) url += `&from=${fromValue}`; + if (toValue) url += `&to=${toValue}`; 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; + exportCsvBtn.addEventListener('click', () => { + const fromValue = fromInput.value; + const toValue = toInput.value; let url = '/api/usage/export?format=csv'; - if (from) url += `&from=${from}`; - if (to) url += `&to=${to}`; + if (fromValue) url += `&from=${fromValue}`; + if (toValue) url += `&to=${toValue}`; window.open(url, '_blank'); -}); + }); + + loadUsage(); +} // ============ HELPERS ============ function escapeHtml(text) { - if (typeof text !== 'string') return ''; - const map = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }; - return text.replace(/[&<>"']/g, m => map[m]); + if (typeof text !== 'string') return ''; + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return text.replace(/[&<>"']/g, (m) => map[m]); +} + +function setupModalBackdropClose() { + document.querySelectorAll('.modal').forEach((modal) => { + modal.addEventListener('click', (e) => { + if (e.target === modal) { + modal.classList.remove('active'); + } + }); + }); } // ============ INITIALIZATION ============ document.addEventListener('DOMContentLoaded', () => { - 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]; -}); + setupModalBackdropClose(); -// Close modals on outside click -document.querySelectorAll('.modal').forEach(modal => { - modal.addEventListener('click', (e) => { - if (e.target === modal) { - modal.classList.remove('active'); - } - }); + if (CURRENT_PAGE === 'tasks') initTasksPage(); + if (CURRENT_PAGE === 'wiki') initWikiPage(); + if (CURRENT_PAGE === 'agents') initAgentsPage(); + if (CURRENT_PAGE === 'usage') initUsagePage(); }); diff --git a/server.js b/server.js index acd9686..56d6950 100644 --- a/server.js +++ b/server.js @@ -53,9 +53,62 @@ db.serialize(() => { const app = express(); const server = http.createServer(app); const wss = new WebSocketServer({ server }); +const VIEWS_DIR = path.join(__dirname, 'views'); app.use(express.json()); -app.use(express.static(path.join(__dirname, 'public'))); +app.use(express.static(path.join(__dirname, 'public'), { index: false })); + +function renderTemplate(template, vars = {}) { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => { + const value = vars[key]; + return value === undefined || value === null ? '' : String(value); + }); +} + +function renderPage(viewName, activeTab, pageTitle) { + const layoutPath = path.join(VIEWS_DIR, 'layout.html'); + const viewPath = path.join(VIEWS_DIR, `${viewName}.html`); + const layout = fs.readFileSync(layoutPath, 'utf8'); + const content = fs.readFileSync(viewPath, 'utf8'); + + return renderTemplate(layout, { + pageTitle, + pageName: viewName, + content, + tasksActive: activeTab === 'tasks' ? 'active' : '', + wikiActive: activeTab === 'wiki' ? 'active' : '', + agentsActive: activeTab === 'agents' ? 'active' : '', + usageActive: activeTab === 'usage' ? 'active' : '', + markedScript: viewName === 'wiki' + ? '' + : '', + chartScript: viewName === 'usage' + ? '' + : '', + }); +} + +// ============ SERVER-RENDERED PAGES ============ + +app.get('/', (req, res) => { + res.redirect('/tasks'); +}); + +app.get('/tasks', (req, res) => { + res.send(renderPage('tasks', 'tasks', 'OpenClaw Agent Fleet Dashboard - Tasks')); +}); + +app.get('/wiki', (req, res) => { + res.send(renderPage('wiki', 'wiki', 'OpenClaw Agent Fleet Dashboard - Wiki')); +}); + +app.get('/agents', (req, res) => { + res.send(renderPage('agents', 'agents', 'OpenClaw Agent Fleet Dashboard - Agents')); +}); + +app.get('/usage', (req, res) => { + res.send(renderPage('usage', 'usage', 'OpenClaw Agent Fleet Dashboard - Usage')); +}); function normalizeTask(row) { return { diff --git a/views/agents.html b/views/agents.html new file mode 100644 index 0000000..7d2fb2f --- /dev/null +++ b/views/agents.html @@ -0,0 +1,40 @@ +
+
+

Agent Fleet

+
+ + +
+
+
+ + + + +
diff --git a/views/layout.html b/views/layout.html new file mode 100644 index 0000000..b27b2b0 --- /dev/null +++ b/views/layout.html @@ -0,0 +1,30 @@ + + + + + + {{pageTitle}} + + {{markedScript}} + {{chartScript}} + + +
+
+

🦞 OpenClaw Agent Fleet Dashboard

+ +
+ +
+ {{content}} +
+
+ + + + diff --git a/views/tasks.html b/views/tasks.html new file mode 100644 index 0000000..ecf88fe --- /dev/null +++ b/views/tasks.html @@ -0,0 +1,58 @@ +
+
+

Create Task

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

📋 Backlog

+ 0 +
+
+
+
+
+

📝 Todo

+ 0 +
+
+
+
+
+

🔄 In Progress

+ 0 +
+
+
+
+
+

👀 Review

+ 0 +
+
+
+
+
+

✅ Done

+ 0 +
+
+
+
+
diff --git a/views/usage.html b/views/usage.html new file mode 100644 index 0000000..2cf2af5 --- /dev/null +++ b/views/usage.html @@ -0,0 +1,49 @@ +
+
+

API Usage & Statistics

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

Total Requests

+
0
+
+
+

Total Tokens

+
0
+
+
+

Estimated Cost

+
$0.00
+
+
+ +
+
+

Usage by Provider

+ +
+
+

Usage by Agent

+ +
+
+ +
+

Provider Details

+
+
+
diff --git a/views/wiki.html b/views/wiki.html new file mode 100644 index 0000000..d6115dc --- /dev/null +++ b/views/wiki.html @@ -0,0 +1,35 @@ +
+
+
+
+ +
+ +
+
+
+
+
Select a page
+ +
+
+
+

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

+
+
+ +
+
+