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