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
This commit is contained in:
2026-03-03 22:48:34 -08:00
parent d4bbe42f3e
commit e05dfef5ab
8 changed files with 3440 additions and 779 deletions

537
server.js
View File

@@ -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 } }));
});