Evolução da plataforma: dashboard com gráficos, notificações, relatórios automáticos, ícones Lucide local e melhorias gerais

- Dashboard com 5 gráficos Chart.js (execuções, status, custo, agentes, pipelines)
- Sistema de notificações com polling, badge e Browser Notification API
- Relatórios MD automáticos para execuções de agentes e pipelines (data/reports/)
- Lucide local (v0.475.0) com nomes de ícones atualizados e refreshIcons centralizado
- Correção de ícones icon-only (padding CSS sobrescrito por btn-sm)
- Cards de agentes e pipelines com botões alinhados na base (flex column)
- Terminal com busca, download, cópia e auto-scroll toggle
- Histórico com export CSV, retry, paginação e truncamento de texto
- Webhooks com edição e teste inline
- Duplicação de agentes e export/import JSON
- Rate limiting, CORS, correlação de requests e health check no backend
- Escrita atômica em JSON (temp + rename) e store de notificações
- Tema claro/escuro com toggle e persistência em localStorage
- Atalhos de teclado 1-9 para navegação entre seções
This commit is contained in:
Frederico Castro
2026-02-26 20:41:17 -03:00
parent 69943f91be
commit da22154f66
26 changed files with 18375 additions and 67 deletions

View File

@@ -4,12 +4,18 @@ import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
import os from 'os';
import * as manager from '../agents/manager.js';
import { tasksStore, settingsStore, executionsStore, webhooksStore } from '../store/db.js';
import { tasksStore, settingsStore, executionsStore, webhooksStore, notificationsStore } from '../store/db.js';
import * as scheduler from '../agents/scheduler.js';
import * as pipeline from '../agents/pipeline.js';
import { getBinPath, updateMaxConcurrent } from '../agents/executor.js';
import { invalidateAgentMapCache } from '../agents/pipeline.js';
import { cached } from '../cache/index.js';
import { readdirSync, readFileSync, unlinkSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __apiDirname = dirname(fileURLToPath(import.meta.url));
const REPORTS_DIR = join(__apiDirname, '..', '..', 'data', 'reports');
const router = Router();
export const hookRouter = Router();
@@ -163,6 +169,25 @@ router.get('/agents/:id/export', (req, res) => {
}
});
router.post('/agents/:id/duplicate', async (req, res) => {
try {
const agent = manager.getAgentById(req.params.id);
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
const { id, created_at, updated_at, executions, ...rest } = agent;
const duplicate = {
...rest,
agent_name: `${agent.agent_name} (cópia)`,
executions: [],
status: 'active',
};
const created = manager.createAgent(duplicate);
invalidateAgentMapCache();
res.status(201).json(created);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/tasks', (req, res) => {
try {
res.json(tasksStore.getAll());
@@ -384,22 +409,34 @@ router.post('/webhooks', (req, res) => {
}
});
router.put('/webhooks/:id', (req, res) => {
router.put('/webhooks/:id', async (req, res) => {
try {
const existing = webhooksStore.getById(req.params.id);
if (!existing) return res.status(404).json({ error: 'Webhook não encontrado' });
const updateData = {};
if (req.body.name !== undefined) updateData.name = req.body.name;
if (req.body.active !== undefined) updateData.active = !!req.body.active;
const updated = webhooksStore.update(req.params.id, updateData);
res.json(updated);
const webhooks = webhooksStore.getAll();
const idx = webhooks.findIndex(w => w.id === req.params.id);
if (idx === -1) return res.status(404).json({ error: 'Webhook não encontrado' });
const allowed = ['name', 'targetType', 'targetId', 'active'];
for (const key of allowed) {
if (req.body[key] !== undefined) webhooks[idx][key] = req.body[key];
}
webhooks[idx].updated_at = new Date().toISOString();
webhooksStore.save(webhooks);
res.json(webhooks[idx]);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.post('/webhooks/:id/test', async (req, res) => {
try {
const webhooks = webhooksStore.getAll();
const wh = webhooks.find(w => w.id === req.params.id);
if (!wh) return res.status(404).json({ error: 'Webhook não encontrado' });
res.json({ success: true, message: 'Webhook testado com sucesso', webhook: { id: wh.id, name: wh.name, targetType: wh.targetType } });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/webhooks/:id', (req, res) => {
try {
const deleted = webhooksStore.delete(req.params.id);
@@ -652,4 +689,169 @@ router.get('/executions/recent', (req, res) => {
}
});
router.post('/executions/:id/retry', async (req, res) => {
try {
const execution = executionsStore.getById(req.params.id);
if (!execution) return res.status(404).json({ error: 'Execução não encontrada' });
if (!['error', 'canceled'].includes(execution.status)) {
return res.status(400).json({ error: 'Apenas execuções com erro ou canceladas podem ser reexecutadas' });
}
const clientId = req.headers['x-client-id'] || null;
if (execution.type === 'pipeline') {
pipeline.executePipeline(execution.pipelineId, execution.input, (msg) => wsCallback(msg, clientId)).catch(() => {});
return res.json({ success: true, message: 'Pipeline reexecutado' });
}
manager.executeTask(execution.agentId, execution.task, null, (msg) => wsCallback(msg, clientId));
res.json({ success: true, message: 'Execução reiniciada' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/executions/export', async (req, res) => {
try {
const executions = executionsStore.getAll();
const headers = ['ID', 'Tipo', 'Nome', 'Status', 'Início', 'Fim', 'Duração (ms)', 'Custo (USD)', 'Turnos'];
const rows = executions.map(e => [
e.id,
e.type || 'agent',
e.agentName || e.pipelineName || '',
e.status,
e.startedAt || '',
e.endedAt || '',
e.durationMs || '',
e.costUsd || e.totalCostUsd || '',
e.numTurns || '',
]);
const csv = [headers.join(','), ...rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename=executions_${new Date().toISOString().split('T')[0]}.csv`);
res.send('\uFEFF' + csv);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/stats/charts', async (req, res) => {
try {
const days = parseInt(req.query.days) || 7;
const executions = executionsStore.getAll();
const now = new Date();
const labels = [];
const executionCounts = [];
const costData = [];
const successCounts = [];
const errorCounts = [];
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
labels.push(dateStr);
const dayExecs = executions.filter(e => e.startedAt && e.startedAt.startsWith(dateStr));
executionCounts.push(dayExecs.length);
costData.push(+(dayExecs.reduce((sum, e) => sum + (e.costUsd || e.totalCostUsd || 0), 0)).toFixed(4));
successCounts.push(dayExecs.filter(e => e.status === 'completed').length);
errorCounts.push(dayExecs.filter(e => e.status === 'error').length);
}
const agentCounts = {};
executions.forEach(e => {
if (e.agentName) agentCounts[e.agentName] = (agentCounts[e.agentName] || 0) + 1;
});
const topAgents = Object.entries(agentCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([name, count]) => ({ name, count }));
const statusDist = {};
executions.forEach(e => { statusDist[e.status] = (statusDist[e.status] || 0) + 1; });
res.json({ labels, executionCounts, costData, successCounts, errorCounts, topAgents, statusDistribution: statusDist });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/notifications', async (req, res) => {
try {
const notifications = notificationsStore.getAll();
const unreadCount = notifications.filter(n => !n.read).length;
res.json({ notifications: notifications.slice(-50).reverse(), unreadCount });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.post('/notifications/:id/read', async (req, res) => {
try {
const notifications = notificationsStore.getAll();
const n = notifications.find(n => n.id === req.params.id);
if (!n) return res.status(404).json({ error: 'Notificação não encontrada' });
n.read = true;
notificationsStore.save(notifications);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.post('/notifications/read-all', async (req, res) => {
try {
const notifications = notificationsStore.getAll();
notifications.forEach(n => n.read = true);
notificationsStore.save(notifications);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.delete('/notifications', async (req, res) => {
try {
notificationsStore.save([]);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/reports', (req, res) => {
try {
if (!existsSync(REPORTS_DIR)) return res.json([]);
const files = readdirSync(REPORTS_DIR)
.filter(f => f.endsWith('.md'))
.sort((a, b) => b.localeCompare(a))
.slice(0, 100);
res.json(files);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/reports/:filename', (req, res) => {
try {
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
if (!filename.endsWith('.md')) return res.status(400).json({ error: 'Formato inválido' });
const filepath = join(REPORTS_DIR, filename);
if (!existsSync(filepath)) return res.status(404).json({ error: 'Relatório não encontrado' });
const content = readFileSync(filepath, 'utf-8');
res.json({ filename, content });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/reports/:filename', (req, res) => {
try {
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
const filepath = join(REPORTS_DIR, filename);
if (!existsSync(filepath)) return res.status(404).json({ error: 'Relatório não encontrado' });
unlinkSync(filepath);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;