diff --git a/public/app.js b/public/app.js
index 8a0dd07..88b0c17 100644
--- a/public/app.js
+++ b/public/app.js
@@ -58,739 +58,763 @@ function initTheme() {
});
}
-// ============ 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 ============
+// ============ STATE ============
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 = `
-
-
- 📋 ${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
-
- ${agent.activeTasks.length
- ? agent.activeTasks.map(t => `- ${escapeHtml(t.title)} ${t.priority}
`).join('')
- : '- No active tasks
'}
-
-
-
-
Recently Completed
-
- ${agent.completedTasks.length
- ? agent.completedTasks.map(t => `- ${escapeHtml(t.title)}
`).join('')
- : '- No completed tasks
'}
-
-
-
-
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 = `
+
+
+ 📋 ${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
+
+ ${agent.activeTasks.length
+ ? agent.activeTasks.map((t) => `- ${escapeHtml(t.title)} ${t.priority}
`).join('')
+ : '- No active tasks
'}
+
+
+
+
Recently Completed
+
+ ${agent.completedTasks.length
+ ? agent.completedTasks.map((t) => `- ${escapeHtml(t.title)}
`).join('')
+ : '- No completed tasks
'}
+
+
+
+
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() {
- const rootStyles = getComputedStyle(document.documentElement);
- const themeForeground = rootStyles.getPropertyValue('--fg').trim() || '#e0e0e0';
- const themeBorder = rootStyles.getPropertyValue('--border').trim() || '#444';
- const themePrimary = rootStyles.getPropertyValue('--primary').trim() || '#3498db';
+ if (!usageStats || typeof Chart === 'undefined') return;
- // 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'
- ]
- }]
+ 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: themeForeground }
- }
- }
- }
- });
-
- // Agent chart
- const agentCtx = document.getElementById('chart-agent').getContext('2d');
-
- if (agentChart) agentChart.destroy();
-
- const agentLabels = Object.keys(usageStats.byAgent);
- const agentData = agentLabels.map(a => usageStats.byAgent[a].requests);
-
- agentChart = new Chart(agentCtx, {
- type: 'bar',
- data: {
- labels: agentLabels,
- datasets: [{
- label: 'Requests',
- data: agentData,
- backgroundColor: themePrimary
- }]
+ },
+ },
+ });
+
+ 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: themeForeground },
- grid: { color: themeBorder }
- },
- x: {
- ticks: { color: themeForeground },
- grid: { color: themeBorder }
- }
- }
- }
- });
+ 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', () => {
- initTheme();
- 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];
-});
+ initTheme();
+ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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}}
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
🔄 In Progress
+ 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 @@
+
+
+
+
+
+
+
+
Estimated Cost
+
$0.00
+
+
+
+
+
+
Usage by Provider
+
+
+
+
Usage by Agent
+
+
+
+
+
+
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 wiki page from the sidebar or create a new one.
+
+
+
+
+
+
+
+
+
+
+
+
+