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:
@@ -2,6 +2,8 @@
|
||||
|
||||
Painel administrativo web para orquestração de agentes [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Crie, configure e execute múltiplos agentes com diferentes personalidades, modelos e diretórios de trabalho — tudo a partir de uma interface visual profissional.
|
||||
|
||||

|
||||
|
||||
## Funcionalidades
|
||||
|
||||
### Gerenciamento de Agentes
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "agents-orchestrator",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agents-orchestrator",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"uuid": "^10.0.0",
|
||||
"ws": "^8.18.0"
|
||||
@@ -279,6 +280,24 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -435,6 +454,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"ws": "^8.18.0",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"node-cron": "^3.0.3",
|
||||
"uuid": "^10.0.0"
|
||||
"uuid": "^10.0.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,6 +447,8 @@ textarea {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: fadeInUp 0.3s ease both;
|
||||
}
|
||||
|
||||
@@ -461,6 +463,7 @@ textarea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent-card-top {
|
||||
@@ -529,6 +532,7 @@ textarea {
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -664,7 +668,7 @@ textarea {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
.btn.btn-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
@@ -679,6 +683,7 @@ textarea {
|
||||
.btn-sm.btn-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@@ -1967,6 +1972,7 @@ tbody tr:hover td {
|
||||
flex: 1;
|
||||
padding: 24px 32px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.section {
|
||||
@@ -3395,6 +3401,10 @@ tbody tr:hover td {
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
#history-list {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
@@ -3406,6 +3416,8 @@ tbody tr:hover td {
|
||||
transition: all 0.2s;
|
||||
animation: fadeInUp 0.2s ease both;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-card:hover {
|
||||
@@ -3419,6 +3431,7 @@ tbody tr:hover td {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-card-identity {
|
||||
@@ -3435,6 +3448,7 @@ tbody tr:hover td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.history-card-task {
|
||||
@@ -3443,6 +3457,7 @@ tbody tr:hover td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.history-card-info {
|
||||
@@ -4022,3 +4037,210 @@ tbody tr:hover td {
|
||||
display: block;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f5f6f8;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #eef0f4;
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-hover: #f8f9fb;
|
||||
--bg-input: #eef0f4;
|
||||
--text-primary: #1a1d23;
|
||||
--text-secondary: #4a5068;
|
||||
--text-tertiary: #7c8298;
|
||||
--border-color: #dde0e9;
|
||||
--border-light: #e8ebf0;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
|
||||
--shadow-lg: 0 8px 24px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
[data-theme="light"] .sidebar { background: #ffffff; border-right: 1px solid var(--border-color); }
|
||||
[data-theme="light"] .sidebar .nav-item:hover,
|
||||
[data-theme="light"] .sidebar .nav-item.active { background: #eef0f4; }
|
||||
[data-theme="light"] .header { background: #ffffff; border-bottom: 1px solid var(--border-color); }
|
||||
[data-theme="light"] .card { background: #ffffff; border: 1px solid var(--border-color); }
|
||||
[data-theme="light"] .modal-content { background: #ffffff; border: 1px solid var(--border-color); }
|
||||
[data-theme="light"] .terminal-output { background: #1e1e2e; color: #cdd6f4; }
|
||||
[data-theme="light"] .input,
|
||||
[data-theme="light"] .select,
|
||||
[data-theme="light"] textarea { background: #eef0f4; border-color: #dde0e9; color: #1a1d23; }
|
||||
[data-theme="light"] .badge { opacity: 0.9; }
|
||||
[data-theme="light"] .table th { background: #eef0f4; }
|
||||
[data-theme="light"] .table td { border-color: #e8ebf0; }
|
||||
[data-theme="light"] .metrics-card { background: #ffffff; border: 1px solid var(--border-color); }
|
||||
|
||||
body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metrics-card {
|
||||
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-icon-dark { display: none; }
|
||||
.theme-icon-light { display: block; }
|
||||
[data-theme="light"] .theme-icon-dark { display: block; }
|
||||
[data-theme="light"] .theme-icon-light { display: none; }
|
||||
|
||||
.charts-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.charts-row--triple { grid-template-columns: repeat(3, 1fr); }
|
||||
.chart-container {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
.charts-row--triple .chart-container {
|
||||
height: auto;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.charts-row--triple .chart-container canvas {
|
||||
max-height: 200px;
|
||||
align-self: center;
|
||||
}
|
||||
.chart-container canvas {
|
||||
max-height: calc(100% - 2.5rem);
|
||||
}
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.chart-header h3 {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
.select-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.charts-row, .charts-row--triple { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.notification-wrapper { position: relative; }
|
||||
.notification-badge {
|
||||
position: absolute; top: -4px; right: -4px;
|
||||
background: var(--danger); color: white;
|
||||
font-size: 0.625rem; font-weight: 700;
|
||||
min-width: 18px; height: 18px; border-radius: 9px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0 4px; pointer-events: none;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
.notification-panel {
|
||||
position: absolute; top: calc(100% + 8px); right: 0;
|
||||
width: 380px; max-height: 480px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 1000;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.notification-panel-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.notification-panel-header h3 { font-size: 0.8125rem; font-weight: 600; margin: 0; }
|
||||
.notification-panel-actions { display: flex; gap: 0.25rem; }
|
||||
.notification-list { flex: 1; overflow-y: auto; max-height: 380px; }
|
||||
.notification-item {
|
||||
display: flex; gap: 0.75rem; padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
.notification-item:hover { background: var(--bg-tertiary); }
|
||||
.notification-item.unread { background: rgba(99, 102, 241, 0.05); }
|
||||
.notification-item-icon {
|
||||
flex-shrink: 0; width: 32px; height: 32px; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 14px;
|
||||
}
|
||||
.notification-item-icon.success { background: rgba(34, 197, 94, 0.1); color: var(--success); }
|
||||
.notification-item-icon.error { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
|
||||
.notification-item-icon.info { background: rgba(99, 102, 241, 0.1); color: var(--primary); }
|
||||
.notification-item-content { flex: 1; min-width: 0; }
|
||||
.notification-item-title { font-size: 0.8125rem; font-weight: 600; color: var(--text-primary); }
|
||||
.notification-item-message { font-size: 0.75rem; color: var(--text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.notification-item-time { font-size: 0.6875rem; color: var(--text-tertiary); margin-top: 2px; }
|
||||
.notification-empty { padding: 2rem; text-align: center; color: var(--text-tertiary); font-size: 0.8125rem; }
|
||||
|
||||
.terminal-action-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
.terminal-toolbar-left, .terminal-toolbar-right { display: flex; align-items: center; gap: 0.25rem; }
|
||||
.terminal-toggle-label {
|
||||
display: flex; align-items: center; gap: 0.375rem;
|
||||
font-size: 0.75rem; color: var(--text-secondary); cursor: pointer; user-select: none;
|
||||
}
|
||||
.terminal-toggle-label input[type="checkbox"] { width: 14px; height: 14px; accent-color: var(--primary); }
|
||||
.terminal-search-bar {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color); border-top: none;
|
||||
}
|
||||
.terminal-search-bar .input--sm { flex: 1; max-width: 300px; }
|
||||
.terminal-search-count { font-size: 0.75rem; color: var(--text-tertiary); min-width: 40px; text-align: center; }
|
||||
.terminal-search-highlight { background: rgba(250, 204, 21, 0.4); border-radius: 2px; }
|
||||
.terminal-search-highlight.active { background: rgba(250, 204, 21, 0.8); }
|
||||
|
||||
.skeleton-pulse {
|
||||
background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-card-hover) 50%, var(--bg-tertiary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
border-radius: 6px;
|
||||
}
|
||||
@keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.report-content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.report-markdown {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.7;
|
||||
color: var(--text-primary);
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.report-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.report-toast:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Agents Orchestrator</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="css/styles.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
@@ -104,13 +105,35 @@
|
||||
<span>Sistema</span>
|
||||
</button>
|
||||
<button class="btn btn--ghost btn--icon-text" id="import-agent-btn" type="button">
|
||||
<i data-lucide="upload"></i>
|
||||
<i data-lucide="file-down"></i>
|
||||
<span>Importar</span>
|
||||
</button>
|
||||
<button class="btn btn--primary btn--icon-text" id="new-agent-btn" type="button">
|
||||
<i data-lucide="plus"></i>
|
||||
<span>Novo Agente</span>
|
||||
</button>
|
||||
<button id="theme-toggle" class="btn btn--icon" title="Alternar tema">
|
||||
<i data-lucide="sun" class="theme-icon-light"></i>
|
||||
<i data-lucide="moon" class="theme-icon-dark"></i>
|
||||
</button>
|
||||
<div class="notification-wrapper">
|
||||
<button id="notification-bell" class="btn btn--icon" title="Notificações">
|
||||
<i data-lucide="bell"></i>
|
||||
<span id="notification-badge" class="notification-badge hidden">0</span>
|
||||
</button>
|
||||
<div id="notification-panel" class="notification-panel hidden">
|
||||
<div class="notification-panel-header">
|
||||
<h3>Notificações</h3>
|
||||
<div class="notification-panel-actions">
|
||||
<button id="mark-all-read" class="btn btn--ghost btn--sm">Marcar lidas</button>
|
||||
<button id="clear-notifications" class="btn btn--ghost btn--sm">Limpar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="notification-list" class="notification-list">
|
||||
<div class="notification-empty">Nenhuma notificação</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -138,7 +161,7 @@
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<div class="metric-card-icon metric-card-icon--purple">
|
||||
<i data-lucide="play-circle"></i>
|
||||
<i data-lucide="circle-play"></i>
|
||||
</div>
|
||||
<div class="metric-card-body">
|
||||
<span class="metric-card-label">Execuções Hoje</span>
|
||||
@@ -174,6 +197,46 @@
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="charts-row">
|
||||
<div class="chart-container">
|
||||
<div class="chart-header">
|
||||
<h3>Execuções</h3>
|
||||
<select id="chart-period" class="select-sm">
|
||||
<option value="7">7 dias</option>
|
||||
<option value="14">14 dias</option>
|
||||
<option value="30">30 dias</option>
|
||||
</select>
|
||||
</div>
|
||||
<canvas id="executions-chart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-header">
|
||||
<h3>Custo (USD)</h3>
|
||||
</div>
|
||||
<canvas id="cost-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="charts-row charts-row--triple">
|
||||
<div class="chart-container">
|
||||
<div class="chart-header">
|
||||
<h3>Status</h3>
|
||||
</div>
|
||||
<canvas id="status-chart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-header">
|
||||
<h3>Top Agentes</h3>
|
||||
</div>
|
||||
<canvas id="agents-chart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-header">
|
||||
<h3>Taxa de Sucesso</h3>
|
||||
</div>
|
||||
<canvas id="success-rate-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@@ -424,6 +487,32 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="terminal-action-toolbar">
|
||||
<div class="terminal-toolbar-left">
|
||||
<button id="terminal-search-toggle" class="btn btn--ghost btn--sm" title="Buscar (Ctrl+F)">
|
||||
<i data-lucide="search" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<button id="terminal-download" class="btn btn--ghost btn--sm" title="Baixar saída">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<button id="terminal-copy" class="btn btn--ghost btn--sm" title="Copiar saída">
|
||||
<i data-lucide="copy" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="terminal-toolbar-right">
|
||||
<label class="terminal-toggle-label">
|
||||
<input type="checkbox" id="terminal-autoscroll" checked>
|
||||
<span>Auto-scroll</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal-search-bar" class="terminal-search-bar hidden">
|
||||
<input type="text" id="terminal-search-input" placeholder="Buscar no terminal..." class="input input--sm">
|
||||
<span id="terminal-search-count" class="terminal-search-count">0/0</span>
|
||||
<button id="terminal-search-prev" class="btn btn--ghost btn--sm">▲</button>
|
||||
<button id="terminal-search-next" class="btn btn--ghost btn--sm">▼</button>
|
||||
<button id="terminal-search-close" class="btn btn--ghost btn--sm">✕</button>
|
||||
</div>
|
||||
<div class="approval-notification" id="approval-notification" hidden></div>
|
||||
<div class="terminal-output" id="terminal-output" role="log" aria-live="polite" aria-label="Saída do terminal">
|
||||
<div class="terminal-welcome">
|
||||
@@ -471,6 +560,10 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button id="history-export-csv" class="btn btn--ghost btn--sm" title="Exportar CSV">
|
||||
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i>
|
||||
Exportar
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm btn-danger" id="history-clear-btn" type="button">
|
||||
<i data-lucide="trash-2"></i>
|
||||
Limpar Histórico
|
||||
@@ -552,6 +645,64 @@
|
||||
<span class="system-info-label">Tempo Online</span>
|
||||
<span class="system-info-value font-mono" id="info-uptime">Carregando...</span>
|
||||
</li>
|
||||
<li class="system-info-item">
|
||||
<span class="system-info-label">Tema Atual</span>
|
||||
<span class="system-info-value" id="info-current-theme">Escuro</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Atalhos do Teclado</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="system-info-list">
|
||||
<li class="system-info-item">
|
||||
<span class="system-info-label">Fechar modal</span>
|
||||
<span class="system-info-value font-mono">Esc</span>
|
||||
</li>
|
||||
<li class="system-info-item">
|
||||
<span class="system-info-label">Novo agente (em Agentes)</span>
|
||||
<span class="system-info-value font-mono">N</span>
|
||||
</li>
|
||||
<li class="system-info-item">
|
||||
<span class="system-info-label">Dashboard</span>
|
||||
<span class="system-info-value font-mono">1</span>
|
||||
</li>
|
||||
<li class="system-info-item">
|
||||
<span class="system-info-label">Agentes</span>
|
||||
<span class="system-info-value font-mono">2</span>
|
||||
</li>
|
||||
<li class="system-info-item">
|
||||
<span class="system-info-label">Tarefas</span>
|
||||
<span class="system-info-value font-mono">3</span>
|
||||
</li>
|
||||
<li class="system-info-item">
|
||||
<span class="system-info-label">Agendamentos</span>
|
||||
<span class="system-info-value font-mono">4</span>
|
||||
</li>
|
||||
<li class="system-info-item">
|
||||
<span class="system-info-label">Pipelines</span>
|
||||
<span class="system-info-value font-mono">5</span>
|
||||
</li>
|
||||
<li class="system-info-item">
|
||||
<span class="system-info-label">Terminal</span>
|
||||
<span class="system-info-value font-mono">6</span>
|
||||
</li>
|
||||
<li class="system-info-item">
|
||||
<span class="system-info-label">Histórico</span>
|
||||
<span class="system-info-value font-mono">7</span>
|
||||
</li>
|
||||
<li class="system-info-item">
|
||||
<span class="system-info-label">Webhooks</span>
|
||||
<span class="system-info-value font-mono">8</span>
|
||||
</li>
|
||||
<li class="system-info-item">
|
||||
<span class="system-info-label">Configurações</span>
|
||||
<span class="system-info-value font-mono">9</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -778,11 +929,11 @@
|
||||
<span>Revisão OWASP</span>
|
||||
</button>
|
||||
<button class="template-btn" type="button" data-template="Refatorar o código para melhorar legibilidade, manutenibilidade e aderência às boas práticas. Manter comportamento funcional intacto.">
|
||||
<i data-lucide="wand-2"></i>
|
||||
<i data-lucide="wand"></i>
|
||||
<span>Refatorar Código</span>
|
||||
</button>
|
||||
<button class="template-btn" type="button" data-template="Escrever testes unitários e de integração abrangentes. Garantir cobertura dos casos de sucesso, erro e edge cases.">
|
||||
<i data-lucide="test-tube-2"></i>
|
||||
<i data-lucide="test-tube"></i>
|
||||
<span>Escrever Testes</span>
|
||||
</button>
|
||||
<button class="template-btn" type="button" data-template="Documentar o código com JSDoc/docstrings, README atualizado, exemplos de uso e descrição de APIs públicas.">
|
||||
@@ -994,7 +1145,7 @@
|
||||
<div class="modal modal--sm">
|
||||
<div class="modal-header">
|
||||
<div class="confirm-modal-icon" id="confirm-modal-icon">
|
||||
<i data-lucide="alert-triangle"></i>
|
||||
<i data-lucide="triangle-alert"></i>
|
||||
</div>
|
||||
<h2 class="modal-title" id="confirm-modal-title">Confirmar Ação</h2>
|
||||
<button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="confirm-modal-overlay">
|
||||
@@ -1060,7 +1211,7 @@
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn--ghost" type="button" data-modal-close="import-modal-overlay">Cancelar</button>
|
||||
<button class="btn btn--primary btn--icon-text" type="button" id="import-confirm-btn">
|
||||
<i data-lucide="upload"></i>
|
||||
<i data-lucide="file-down"></i>
|
||||
<span>Importar</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1116,7 +1267,7 @@
|
||||
|
||||
<div class="toast-container" id="toast-container" aria-live="assertive" aria-atomic="false" role="region" aria-label="Notificações"></div>
|
||||
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
|
||||
<script src="js/lucide.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/utils.js"></script>
|
||||
<script src="js/components/toast.js"></script>
|
||||
@@ -1130,9 +1281,10 @@
|
||||
<script src="js/components/settings.js"></script>
|
||||
<script src="js/components/history.js"></script>
|
||||
<script src="js/components/webhooks.js"></script>
|
||||
<script src="js/components/notifications.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
Utils.refreshIcons();
|
||||
App.init();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -43,6 +43,7 @@ const API = {
|
||||
continue(id, sessionId, message) { return API.request('POST', `/agents/${id}/continue`, { sessionId, message }); },
|
||||
export(id) { return API.request('GET', `/agents/${id}/export`); },
|
||||
import(data) { return API.request('POST', '/agents/import', data); },
|
||||
duplicate(id) { return API.request('POST', `/agents/${id}/duplicate`); },
|
||||
},
|
||||
|
||||
tasks: {
|
||||
@@ -81,10 +82,19 @@ const API = {
|
||||
create(data) { return API.request('POST', '/webhooks', data); },
|
||||
update(id, data) { return API.request('PUT', `/webhooks/${id}`, data); },
|
||||
delete(id) { return API.request('DELETE', `/webhooks/${id}`); },
|
||||
test(id) { return API.request('POST', `/webhooks/${id}/test`); },
|
||||
},
|
||||
|
||||
stats: {
|
||||
costs(days) { return API.request('GET', `/stats/costs${days ? '?days=' + days : ''}`); },
|
||||
charts(days) { return API.request('GET', `/stats/charts${days ? '?days=' + days : ''}`); },
|
||||
},
|
||||
|
||||
notifications: {
|
||||
list() { return API.request('GET', '/notifications'); },
|
||||
markRead(id) { return API.request('POST', `/notifications/${id}/read`); },
|
||||
markAllRead() { return API.request('POST', '/notifications/read-all'); },
|
||||
clear() { return API.request('DELETE', '/notifications'); },
|
||||
},
|
||||
|
||||
system: {
|
||||
@@ -98,6 +108,12 @@ const API = {
|
||||
save(data) { return API.request('PUT', '/settings', data); },
|
||||
},
|
||||
|
||||
reports: {
|
||||
list() { return API.request('GET', '/reports'); },
|
||||
get(filename) { return API.request('GET', `/reports/${encodeURIComponent(filename)}`); },
|
||||
delete(filename) { return API.request('DELETE', `/reports/${encodeURIComponent(filename)}`); },
|
||||
},
|
||||
|
||||
executions: {
|
||||
recent(limit = 20) { return API.request('GET', `/executions/recent?limit=${limit}`); },
|
||||
history(params = {}) {
|
||||
@@ -107,6 +123,19 @@ const API = {
|
||||
get(id) { return API.request('GET', `/executions/history/${id}`); },
|
||||
delete(id) { return API.request('DELETE', `/executions/history/${id}`); },
|
||||
clearAll() { return API.request('DELETE', '/executions/history'); },
|
||||
retry(id) { return API.request('POST', `/executions/${id}/retry`); },
|
||||
async exportCsv() {
|
||||
const response = await fetch('/api/executions/export', {
|
||||
headers: { 'X-Client-Id': API.clientId },
|
||||
});
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `execucoes_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -18,18 +18,47 @@ const App = {
|
||||
settings: 'Configurações',
|
||||
},
|
||||
|
||||
sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'settings'],
|
||||
|
||||
init() {
|
||||
if (App._initialized) return;
|
||||
App._initialized = true;
|
||||
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
|
||||
App.setupNavigation();
|
||||
App.setupWebSocket();
|
||||
App.setupEventListeners();
|
||||
App.setupKeyboardShortcuts();
|
||||
App.navigateTo('dashboard');
|
||||
|
||||
const initialSection = location.hash.replace('#', '') || 'dashboard';
|
||||
App.navigateTo(App.sections.includes(initialSection) ? initialSection : 'dashboard');
|
||||
App.startPeriodicRefresh();
|
||||
|
||||
if (window.lucide) lucide.createIcons();
|
||||
window.addEventListener('hashchange', () => {
|
||||
const section = location.hash.replace('#', '') || 'dashboard';
|
||||
if (App.sections.includes(section)) App.navigateTo(section);
|
||||
});
|
||||
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
Utils.refreshIcons();
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof NotificationsUI !== 'undefined') NotificationsUI.init();
|
||||
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
|
||||
Utils.refreshIcons();
|
||||
},
|
||||
|
||||
setupNavigation() {
|
||||
@@ -47,6 +76,10 @@ const App = {
|
||||
},
|
||||
|
||||
navigateTo(section) {
|
||||
if (location.hash !== `#${section}`) {
|
||||
history.pushState(null, '', `#${section}`);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.section').forEach((el) => {
|
||||
const isActive = el.id === section;
|
||||
el.classList.toggle('active', isActive);
|
||||
@@ -174,6 +207,11 @@ const App = {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof NotificationsUI !== 'undefined') {
|
||||
NotificationsUI.loadCount();
|
||||
NotificationsUI.showBrowserNotification('Execução concluída', data.agentName || 'Agente');
|
||||
}
|
||||
|
||||
Toast.success('Execução concluída');
|
||||
App.refreshCurrentSection();
|
||||
App._updateActiveBadge();
|
||||
@@ -183,6 +221,12 @@ const App = {
|
||||
case 'execution_error':
|
||||
Terminal.stopProcessing();
|
||||
Terminal.addLine(data.data?.error || 'Erro na execução', 'error', data.executionId);
|
||||
|
||||
if (typeof NotificationsUI !== 'undefined') {
|
||||
NotificationsUI.loadCount();
|
||||
NotificationsUI.showBrowserNotification('Execução falhou', data.agentName || 'Agente');
|
||||
}
|
||||
|
||||
Toast.error(`Erro na execução: ${data.data?.error || 'desconhecido'}`);
|
||||
App._updateActiveBadge();
|
||||
break;
|
||||
@@ -241,7 +285,30 @@ const App = {
|
||||
|
||||
case 'pipeline_status':
|
||||
break;
|
||||
|
||||
case 'report_generated':
|
||||
if (data.reportFile) {
|
||||
Terminal.addLine(`📄 Relatório gerado: ${data.reportFile}`, 'info');
|
||||
App._openReport(data.reportFile);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
async _openReport(filename) {
|
||||
try {
|
||||
const data = await API.request('GET', `/reports/${encodeURIComponent(filename)}`);
|
||||
if (!data || !data.content) return;
|
||||
|
||||
const modal = document.getElementById('execution-detail-modal-overlay');
|
||||
const title = document.getElementById('execution-detail-title');
|
||||
const content = document.getElementById('execution-detail-content');
|
||||
if (!modal || !title || !content) return;
|
||||
|
||||
title.textContent = 'Relatório de Execução';
|
||||
content.innerHTML = `<div class="report-content"><pre class="report-markdown">${Utils.escapeHtml(data.content)}</pre></div>`;
|
||||
Modal.open('execution-detail-modal-overlay');
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
_showApprovalNotification(pipelineId, stepIndex, agentName) {
|
||||
@@ -264,7 +331,7 @@ const App = {
|
||||
container.hidden = false;
|
||||
container.dataset.pipelineId = pipelineId;
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
|
||||
document.getElementById('approval-approve-btn')?.addEventListener('click', () => {
|
||||
App._handleApproval(pipelineId, true);
|
||||
@@ -538,6 +605,7 @@ const App = {
|
||||
case 'edit': AgentsUI.openEditModal(id); break;
|
||||
case 'export': AgentsUI.export(id); break;
|
||||
case 'delete': AgentsUI.delete(id); break;
|
||||
case 'duplicate': AgentsUI.duplicate(id); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -598,6 +666,7 @@ const App = {
|
||||
switch (action) {
|
||||
case 'view-execution': HistoryUI.viewDetail(id); break;
|
||||
case 'delete-execution': HistoryUI.deleteExecution(id); break;
|
||||
case 'retry': HistoryUI.retryExecution(id); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -610,6 +679,8 @@ const App = {
|
||||
case 'delete-webhook': WebhooksUI.delete(id); break;
|
||||
case 'copy-webhook-url': WebhooksUI.copyUrl(url); break;
|
||||
case 'copy-webhook-curl': WebhooksUI.copyCurl(id); break;
|
||||
case 'edit-webhook': WebhooksUI.openEditModal(id); break;
|
||||
case 'test-webhook': WebhooksUI.test(id); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -768,14 +839,32 @@ const App = {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName);
|
||||
if (isTyping) return;
|
||||
const isInInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName);
|
||||
if (isInInput) return;
|
||||
|
||||
if (e.key === 'n' || e.key === 'N') {
|
||||
if (App.currentSection === 'agents') {
|
||||
AgentsUI.openCreateModal();
|
||||
}
|
||||
}
|
||||
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const sectionKeys = {
|
||||
'1': 'dashboard',
|
||||
'2': 'agents',
|
||||
'3': 'tasks',
|
||||
'4': 'schedules',
|
||||
'5': 'pipelines',
|
||||
'6': 'terminal',
|
||||
'7': 'history',
|
||||
'8': 'webhooks',
|
||||
'9': 'settings',
|
||||
};
|
||||
if (sectionKeys[e.key]) {
|
||||
e.preventDefault();
|
||||
App.navigateTo(sectionKeys[e.key]);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ const AgentsUI = {
|
||||
|
||||
grid.appendChild(fragment);
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [grid] });
|
||||
Utils.refreshIcons(grid);
|
||||
},
|
||||
|
||||
filter(searchText, statusFilter) {
|
||||
@@ -116,6 +116,9 @@ const AgentsUI = {
|
||||
<i data-lucide="pencil"></i>
|
||||
Editar
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="duplicate" data-id="${agent.id}" title="Duplicar agente">
|
||||
<i data-lucide="copy"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="export" data-id="${agent.id}" title="Exportar agente">
|
||||
<i data-lucide="download"></i>
|
||||
</button>
|
||||
@@ -322,6 +325,16 @@ const AgentsUI = {
|
||||
|
||||
_savedTasksCache: [],
|
||||
|
||||
async duplicate(agentId) {
|
||||
try {
|
||||
await API.agents.duplicate(agentId);
|
||||
Toast.success('Agente duplicado com sucesso');
|
||||
await AgentsUI.load();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao duplicar agente: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async export(agentId) {
|
||||
try {
|
||||
const data = await API.agents.export(agentId);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const DashboardUI = {
|
||||
charts: {},
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const [status, recentExecs] = await Promise.all([
|
||||
@@ -9,11 +11,253 @@ const DashboardUI = {
|
||||
DashboardUI.updateMetrics(status);
|
||||
DashboardUI.updateRecentActivity(recentExecs || []);
|
||||
DashboardUI.updateSystemStatus(status);
|
||||
DashboardUI.setupChartPeriod();
|
||||
DashboardUI.loadCharts();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao carregar dashboard: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async loadCharts() {
|
||||
try {
|
||||
const period = document.getElementById('chart-period');
|
||||
const days = period ? parseInt(period.value) : 7;
|
||||
const data = await API.stats.charts(days);
|
||||
DashboardUI.renderExecutionsChart(data);
|
||||
DashboardUI.renderCostChart(data);
|
||||
DashboardUI.renderStatusChart(data);
|
||||
DashboardUI.renderTopAgentsChart(data);
|
||||
DashboardUI.renderSuccessRateChart(data);
|
||||
} catch (e) {
|
||||
console.error('Erro ao carregar gráficos:', e);
|
||||
}
|
||||
},
|
||||
|
||||
_cssVar(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
},
|
||||
|
||||
renderExecutionsChart(data) {
|
||||
const ctx = document.getElementById('executions-chart');
|
||||
if (!ctx) return;
|
||||
if (DashboardUI.charts.executions) DashboardUI.charts.executions.destroy();
|
||||
|
||||
const labels = (data.labels || []).map(l => {
|
||||
const d = new Date(l + 'T12:00:00');
|
||||
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
|
||||
});
|
||||
|
||||
DashboardUI.charts.executions = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'Sucesso', data: data.successCounts || [], backgroundColor: 'rgba(34, 197, 94, 0.8)', borderRadius: 4 },
|
||||
{ label: 'Erro', data: data.errorCounts || [], backgroundColor: 'rgba(239, 68, 68, 0.8)', borderRadius: 4 },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: DashboardUI._cssVar('--text-secondary'), font: { size: 11 } },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: { display: false },
|
||||
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(128,128,128,0.1)' },
|
||||
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
renderCostChart(data) {
|
||||
const ctx = document.getElementById('cost-chart');
|
||||
if (!ctx) return;
|
||||
if (DashboardUI.charts.cost) DashboardUI.charts.cost.destroy();
|
||||
|
||||
const labels = (data.labels || []).map(l => {
|
||||
const d = new Date(l + 'T12:00:00');
|
||||
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
|
||||
});
|
||||
|
||||
DashboardUI.charts.cost = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Custo (USD)',
|
||||
data: data.costData || [],
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#6366f1',
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(128,128,128,0.1)' },
|
||||
ticks: {
|
||||
color: DashboardUI._cssVar('--text-tertiary'),
|
||||
font: { size: 10 },
|
||||
callback: (v) => '$' + v.toFixed(2),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
renderStatusChart(data) {
|
||||
const ctx = document.getElementById('status-chart');
|
||||
if (!ctx) return;
|
||||
if (DashboardUI.charts.status) DashboardUI.charts.status.destroy();
|
||||
|
||||
const dist = data.statusDistribution || {};
|
||||
const statuses = Object.keys(dist);
|
||||
const values = Object.values(dist);
|
||||
const colors = {
|
||||
completed: '#22c55e',
|
||||
error: '#ef4444',
|
||||
running: '#6366f1',
|
||||
canceled: '#f59e0b',
|
||||
rejected: '#ef4444',
|
||||
};
|
||||
|
||||
DashboardUI.charts.status = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: statuses.map(s => s.charAt(0).toUpperCase() + s.slice(1)),
|
||||
datasets: [{
|
||||
data: values,
|
||||
backgroundColor: statuses.map(s => colors[s] || '#94a3b8'),
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
aspectRatio: 1,
|
||||
cutout: '65%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: DashboardUI._cssVar('--text-secondary'),
|
||||
font: { size: 11 },
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
renderTopAgentsChart(data) {
|
||||
const ctx = document.getElementById('agents-chart');
|
||||
if (!ctx) return;
|
||||
if (DashboardUI.charts.agents) DashboardUI.charts.agents.destroy();
|
||||
|
||||
const top = data.topAgents || [];
|
||||
|
||||
DashboardUI.charts.agents = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: top.map(a => a.name.length > 15 ? a.name.substring(0, 15) + '\u2026' : a.name),
|
||||
datasets: [{
|
||||
data: top.map(a => a.count),
|
||||
backgroundColor: ['#6366f1', '#8b5cf6', '#a78bfa', '#c4b5fd', '#ddd6fe'],
|
||||
borderRadius: 4,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y',
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(128,128,128,0.1)' },
|
||||
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
|
||||
},
|
||||
y: {
|
||||
grid: { display: false },
|
||||
ticks: { color: DashboardUI._cssVar('--text-secondary'), font: { size: 10 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
renderSuccessRateChart(data) {
|
||||
const ctx = document.getElementById('success-rate-chart');
|
||||
if (!ctx) return;
|
||||
if (DashboardUI.charts.successRate) DashboardUI.charts.successRate.destroy();
|
||||
|
||||
const dist = data.statusDistribution || {};
|
||||
const total = Object.values(dist).reduce((a, b) => a + b, 0);
|
||||
const success = dist.completed || 0;
|
||||
const rate = total > 0 ? Math.round((success / total) * 100) : 0;
|
||||
|
||||
DashboardUI.charts.successRate = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Sucesso', 'Outros'],
|
||||
datasets: [{
|
||||
data: [rate, 100 - rate],
|
||||
backgroundColor: ['#22c55e', 'rgba(128,128,128,0.15)'],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
aspectRatio: 1,
|
||||
cutout: '75%',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { enabled: false },
|
||||
},
|
||||
},
|
||||
plugins: [{
|
||||
id: 'centerText',
|
||||
afterDraw(chart) {
|
||||
const { ctx: c, width, height } = chart;
|
||||
c.save();
|
||||
c.font = 'bold 24px Inter';
|
||||
c.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--text-primary').trim();
|
||||
c.textAlign = 'center';
|
||||
c.textBaseline = 'middle';
|
||||
c.fillText(rate + '%', width / 2, height / 2);
|
||||
c.restore();
|
||||
},
|
||||
}],
|
||||
});
|
||||
},
|
||||
|
||||
updateMetrics(status) {
|
||||
const metrics = {
|
||||
'metric-total-agents': status.agents?.total ?? 0,
|
||||
@@ -71,7 +315,7 @@ const DashboardUI = {
|
||||
<span>Nenhuma execução recente</span>
|
||||
</li>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [list] });
|
||||
Utils.refreshIcons(list);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,6 +354,14 @@ const DashboardUI = {
|
||||
}).join('');
|
||||
},
|
||||
|
||||
setupChartPeriod() {
|
||||
const chartPeriod = document.getElementById('chart-period');
|
||||
if (chartPeriod && !chartPeriod._listenerAdded) {
|
||||
chartPeriod._listenerAdded = true;
|
||||
chartPeriod.addEventListener('change', () => DashboardUI.loadCharts());
|
||||
}
|
||||
},
|
||||
|
||||
updateSystemStatus(status) {
|
||||
const wsBadge = document.getElementById('system-ws-status-badge');
|
||||
if (wsBadge) {
|
||||
|
||||
@@ -7,7 +7,17 @@ const HistoryUI = {
|
||||
_currentType: '',
|
||||
_currentStatus: '',
|
||||
|
||||
_exportListenerAdded: false,
|
||||
|
||||
async load() {
|
||||
if (!HistoryUI._exportListenerAdded) {
|
||||
HistoryUI._exportListenerAdded = true;
|
||||
const exportBtn = document.getElementById('history-export-csv');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', () => API.executions.exportCsv());
|
||||
}
|
||||
}
|
||||
|
||||
const params = { limit: HistoryUI.pageSize, offset: HistoryUI.page * HistoryUI.pageSize };
|
||||
if (HistoryUI._currentType) params.type = HistoryUI._currentType;
|
||||
if (HistoryUI._currentStatus) params.status = HistoryUI._currentStatus;
|
||||
@@ -38,12 +48,12 @@ const HistoryUI = {
|
||||
<p class="empty-state-text">O histórico de execuções aparecerá aqui.</p>
|
||||
</div>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = HistoryUI.executions.map((exec) => HistoryUI._renderCard(exec)).join('');
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
_renderCard(exec) {
|
||||
@@ -55,9 +65,10 @@ const HistoryUI = {
|
||||
const name = exec.type === 'pipeline'
|
||||
? (exec.pipelineName || 'Pipeline')
|
||||
: (exec.agentName || 'Agente');
|
||||
const task = exec.type === 'pipeline'
|
||||
const taskRaw = exec.type === 'pipeline'
|
||||
? (exec.input || '')
|
||||
: (exec.task || '');
|
||||
const task = taskRaw.length > 150 ? taskRaw.slice(0, 150) + '…' : taskRaw;
|
||||
const date = HistoryUI._formatDate(exec.startedAt);
|
||||
const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt);
|
||||
const cost = exec.costUsd || exec.totalCostUsd || 0;
|
||||
@@ -74,7 +85,7 @@ const HistoryUI = {
|
||||
${statusBadge}
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-card-task">${Utils.escapeHtml(task)}</div>
|
||||
<div class="history-card-task" title="${Utils.escapeHtml(taskRaw)}">${Utils.escapeHtml(task)}</div>
|
||||
<div class="history-card-info">
|
||||
<span class="history-card-date">
|
||||
<i data-lucide="calendar" aria-hidden="true"></i>
|
||||
@@ -91,6 +102,10 @@ const HistoryUI = {
|
||||
<i data-lucide="eye"></i>
|
||||
Ver detalhes
|
||||
</button>
|
||||
${(exec.status === 'error' || exec.status === 'canceled') ? `
|
||||
<button class="btn btn-ghost btn-sm" data-action="retry" data-id="${exec.id}" type="button" title="Reexecutar">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
</button>` : ''}
|
||||
<button class="btn btn-ghost btn-sm btn-danger" data-action="delete-execution" data-id="${exec.id}" type="button" aria-label="Excluir execução">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
@@ -131,7 +146,7 @@ const HistoryUI = {
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
|
||||
document.getElementById('history-prev-btn')?.addEventListener('click', () => {
|
||||
HistoryUI.page--;
|
||||
@@ -171,7 +186,7 @@ const HistoryUI = {
|
||||
: HistoryUI._renderAgentDetail(exec);
|
||||
|
||||
Modal.open('execution-detail-modal-overlay');
|
||||
if (window.lucide) lucide.createIcons({ nodes: [content] });
|
||||
Utils.refreshIcons(content);
|
||||
|
||||
content.querySelectorAll('.pipeline-step-prompt-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
@@ -359,6 +374,16 @@ const HistoryUI = {
|
||||
`;
|
||||
},
|
||||
|
||||
async retryExecution(id) {
|
||||
try {
|
||||
await API.executions.retry(id);
|
||||
Toast.success('Execução reiniciada');
|
||||
App.navigateTo('terminal');
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao reexecutar: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteExecution(id) {
|
||||
const confirmed = await Modal.confirm(
|
||||
'Excluir execução',
|
||||
|
||||
153
public/js/components/notifications.js
Normal file
153
public/js/components/notifications.js
Normal file
@@ -0,0 +1,153 @@
|
||||
const NotificationsUI = {
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
pollInterval: null,
|
||||
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.startPolling();
|
||||
},
|
||||
|
||||
setupEventListeners() {
|
||||
const bell = document.getElementById('notification-bell');
|
||||
const panel = document.getElementById('notification-panel');
|
||||
|
||||
if (bell) {
|
||||
bell.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
panel.classList.toggle('hidden');
|
||||
if (!panel.classList.contains('hidden')) this.load();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (panel && !panel.contains(e.target) && e.target !== bell) {
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
const markAllBtn = document.getElementById('mark-all-read');
|
||||
if (markAllBtn) {
|
||||
markAllBtn.addEventListener('click', () => this.markAllRead());
|
||||
}
|
||||
|
||||
const clearBtn = document.getElementById('clear-notifications');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => this.clearAll());
|
||||
}
|
||||
},
|
||||
|
||||
startPolling() {
|
||||
this.pollInterval = setInterval(() => this.loadCount(), 15000);
|
||||
this.loadCount();
|
||||
},
|
||||
|
||||
async loadCount() {
|
||||
try {
|
||||
const data = await API.request('GET', '/notifications');
|
||||
this.unreadCount = data.unreadCount || 0;
|
||||
this.updateBadge();
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const data = await API.request('GET', '/notifications');
|
||||
this.notifications = data.notifications || [];
|
||||
this.unreadCount = data.unreadCount || 0;
|
||||
this.updateBadge();
|
||||
this.render();
|
||||
} catch (e) {
|
||||
console.error('Erro ao carregar notificações:', e);
|
||||
}
|
||||
},
|
||||
|
||||
updateBadge() {
|
||||
const badge = document.getElementById('notification-badge');
|
||||
if (!badge) return;
|
||||
if (this.unreadCount > 0) {
|
||||
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
const list = document.getElementById('notification-list');
|
||||
if (!list) return;
|
||||
|
||||
if (this.notifications.length === 0) {
|
||||
list.innerHTML = '<div class="notification-empty">Nenhuma notificação</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = this.notifications.map(n => {
|
||||
const iconClass = n.type === 'success' ? 'success' : n.type === 'error' ? 'error' : 'info';
|
||||
const icon = n.type === 'success' ? '✓' : n.type === 'error' ? '✕' : 'ℹ';
|
||||
const time = this.timeAgo(n.createdAt);
|
||||
const unread = n.read ? '' : ' unread';
|
||||
return `<div class="notification-item${unread}" data-id="${n.id}">
|
||||
<div class="notification-item-icon ${iconClass}">${icon}</div>
|
||||
<div class="notification-item-content">
|
||||
<div class="notification-item-title">${Utils.escapeHtml(n.title)}</div>
|
||||
<div class="notification-item-message">${Utils.escapeHtml(n.message)}</div>
|
||||
<div class="notification-item-time">${time}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
list.querySelectorAll('.notification-item').forEach(item => {
|
||||
item.addEventListener('click', () => this.markAsRead(item.dataset.id));
|
||||
});
|
||||
},
|
||||
|
||||
async markAsRead(id) {
|
||||
try {
|
||||
await API.request('POST', `/notifications/${id}/read`);
|
||||
const n = this.notifications.find(n => n.id === id);
|
||||
if (n) n.read = true;
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||||
this.updateBadge();
|
||||
this.render();
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async markAllRead() {
|
||||
try {
|
||||
await API.request('POST', '/notifications/read-all');
|
||||
this.notifications.forEach(n => n.read = true);
|
||||
this.unreadCount = 0;
|
||||
this.updateBadge();
|
||||
this.render();
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async clearAll() {
|
||||
try {
|
||||
await API.request('DELETE', '/notifications');
|
||||
this.notifications = [];
|
||||
this.unreadCount = 0;
|
||||
this.updateBadge();
|
||||
this.render();
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
timeAgo(dateStr) {
|
||||
const now = new Date();
|
||||
const date = new Date(dateStr);
|
||||
const diff = Math.floor((now - date) / 1000);
|
||||
if (diff < 60) return 'agora';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}min atrás`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h atrás`;
|
||||
return `${Math.floor(diff / 86400)}d atrás`;
|
||||
},
|
||||
|
||||
showBrowserNotification(title, body) {
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(title, { body, icon: '/favicon.ico' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.NotificationsUI = NotificationsUI;
|
||||
@@ -44,7 +44,7 @@ const PipelinesUI = {
|
||||
if (!emptyState) {
|
||||
grid.insertAdjacentHTML('beforeend', PipelinesUI.renderEmpty());
|
||||
}
|
||||
if (window.lucide) lucide.createIcons({ nodes: [grid] });
|
||||
Utils.refreshIcons(grid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ const PipelinesUI = {
|
||||
|
||||
grid.appendChild(fragment);
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [grid] });
|
||||
Utils.refreshIcons(grid);
|
||||
},
|
||||
|
||||
renderEmpty() {
|
||||
@@ -249,7 +249,7 @@ const PipelinesUI = {
|
||||
select.value = PipelinesUI._steps[index].agentId || '';
|
||||
});
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
_syncStepsFromDOM() {
|
||||
|
||||
@@ -28,7 +28,7 @@ const SchedulesUI = {
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [tbody] });
|
||||
Utils.refreshIcons(tbody);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ const SchedulesUI = {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [tbody] });
|
||||
Utils.refreshIcons(tbody);
|
||||
},
|
||||
|
||||
filter(searchText, statusFilter) {
|
||||
@@ -208,7 +208,11 @@ const SchedulesUI = {
|
||||
if (!container) return;
|
||||
|
||||
if (history.length === 0) {
|
||||
container.innerHTML = '<p class="empty-state-desc">Nenhum disparo registrado</p>';
|
||||
const hasSchedules = SchedulesUI.schedules.length > 0;
|
||||
const msg = hasSchedules
|
||||
? 'Nenhum disparo registrado ainda. As tarefas agendadas aparecerão aqui após a próxima execução.'
|
||||
: 'Nenhum disparo registrado. Crie um agendamento para começar.';
|
||||
container.innerHTML = `<p class="empty-state-desc">${msg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -256,7 +260,7 @@ const SchedulesUI = {
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
_statusBadge(status) {
|
||||
|
||||
@@ -8,11 +8,20 @@ const SettingsUI = {
|
||||
|
||||
SettingsUI.populateForm(settings);
|
||||
SettingsUI.populateSystemInfo(info);
|
||||
SettingsUI.updateThemeInfo();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao carregar configurações: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
updateThemeInfo() {
|
||||
const themeEl = document.getElementById('info-current-theme');
|
||||
if (themeEl) {
|
||||
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
themeEl.textContent = theme === 'dark' ? 'Escuro' : 'Claro';
|
||||
}
|
||||
},
|
||||
|
||||
populateForm(settings) {
|
||||
const fields = {
|
||||
'settings-default-model': settings.defaultModel || 'claude-sonnet-4-6',
|
||||
|
||||
@@ -39,7 +39,7 @@ const TasksUI = {
|
||||
|
||||
container.appendChild(fragment);
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
filter(searchText, categoryFilter) {
|
||||
|
||||
@@ -5,6 +5,9 @@ const Terminal = {
|
||||
executionFilter: null,
|
||||
_processingInterval: null,
|
||||
_chatSession: null,
|
||||
searchMatches: [],
|
||||
searchIndex: -1,
|
||||
_toolbarInitialized: false,
|
||||
|
||||
enableChat(agentId, agentName, sessionId) {
|
||||
Terminal._chatSession = { agentId, agentName, sessionId };
|
||||
@@ -83,7 +86,121 @@ const Terminal = {
|
||||
if (output) output.scrollTop = output.scrollHeight;
|
||||
},
|
||||
|
||||
initToolbar() {
|
||||
if (Terminal._toolbarInitialized) return;
|
||||
Terminal._toolbarInitialized = true;
|
||||
|
||||
const searchToggle = document.getElementById('terminal-search-toggle');
|
||||
const searchBar = document.getElementById('terminal-search-bar');
|
||||
const searchInput = document.getElementById('terminal-search-input');
|
||||
const searchClose = document.getElementById('terminal-search-close');
|
||||
const searchPrev = document.getElementById('terminal-search-prev');
|
||||
const searchNext = document.getElementById('terminal-search-next');
|
||||
const downloadBtn = document.getElementById('terminal-download');
|
||||
const copyBtn = document.getElementById('terminal-copy');
|
||||
const autoScrollCheck = document.getElementById('terminal-autoscroll');
|
||||
|
||||
if (searchToggle && searchBar) {
|
||||
searchToggle.addEventListener('click', () => {
|
||||
searchBar.classList.toggle('hidden');
|
||||
if (!searchBar.classList.contains('hidden') && searchInput) searchInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => Terminal.search(searchInput.value));
|
||||
}
|
||||
|
||||
if (searchClose && searchBar) {
|
||||
searchClose.addEventListener('click', () => {
|
||||
searchBar.classList.add('hidden');
|
||||
Terminal.clearSearch();
|
||||
});
|
||||
}
|
||||
|
||||
if (searchPrev) searchPrev.addEventListener('click', () => Terminal.searchPrev());
|
||||
if (searchNext) searchNext.addEventListener('click', () => Terminal.searchNext());
|
||||
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', () => Terminal.downloadOutput());
|
||||
}
|
||||
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => Terminal.copyOutput());
|
||||
}
|
||||
|
||||
if (autoScrollCheck) {
|
||||
autoScrollCheck.addEventListener('change', (e) => {
|
||||
Terminal.autoScroll = e.target.checked;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
search(query) {
|
||||
const output = document.getElementById('terminal-output');
|
||||
if (!output || !query) { Terminal.clearSearch(); return; }
|
||||
|
||||
const text = output.textContent;
|
||||
Terminal.searchMatches = [];
|
||||
Terminal.searchIndex = -1;
|
||||
|
||||
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
Terminal.searchMatches.push(match.index);
|
||||
}
|
||||
|
||||
const countEl = document.getElementById('terminal-search-count');
|
||||
if (countEl) countEl.textContent = Terminal.searchMatches.length > 0 ? `0/${Terminal.searchMatches.length}` : '0/0';
|
||||
|
||||
if (Terminal.searchMatches.length > 0) Terminal.searchNext();
|
||||
},
|
||||
|
||||
searchNext() {
|
||||
if (Terminal.searchMatches.length === 0) return;
|
||||
Terminal.searchIndex = (Terminal.searchIndex + 1) % Terminal.searchMatches.length;
|
||||
const countEl = document.getElementById('terminal-search-count');
|
||||
if (countEl) countEl.textContent = `${Terminal.searchIndex + 1}/${Terminal.searchMatches.length}`;
|
||||
},
|
||||
|
||||
searchPrev() {
|
||||
if (Terminal.searchMatches.length === 0) return;
|
||||
Terminal.searchIndex = Terminal.searchIndex <= 0 ? Terminal.searchMatches.length - 1 : Terminal.searchIndex - 1;
|
||||
const countEl = document.getElementById('terminal-search-count');
|
||||
if (countEl) countEl.textContent = `${Terminal.searchIndex + 1}/${Terminal.searchMatches.length}`;
|
||||
},
|
||||
|
||||
clearSearch() {
|
||||
Terminal.searchMatches = [];
|
||||
Terminal.searchIndex = -1;
|
||||
const countEl = document.getElementById('terminal-search-count');
|
||||
if (countEl) countEl.textContent = '0/0';
|
||||
},
|
||||
|
||||
downloadOutput() {
|
||||
const output = document.getElementById('terminal-output');
|
||||
if (!output) return;
|
||||
const text = output.textContent;
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `terminal_${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
if (typeof Toast !== 'undefined') Toast.success('Saída baixada');
|
||||
},
|
||||
|
||||
copyOutput() {
|
||||
const output = document.getElementById('terminal-output');
|
||||
if (!output) return;
|
||||
navigator.clipboard.writeText(output.textContent).then(() => {
|
||||
if (typeof Toast !== 'undefined') Toast.success('Saída copiada');
|
||||
});
|
||||
},
|
||||
|
||||
render() {
|
||||
Terminal.initToolbar();
|
||||
const output = document.getElementById('terminal-output');
|
||||
if (!output) return;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const Toast = {
|
||||
iconMap: {
|
||||
success: 'check-circle',
|
||||
error: 'x-circle',
|
||||
success: 'circle-check',
|
||||
error: 'circle-x',
|
||||
info: 'info',
|
||||
warning: 'alert-triangle',
|
||||
warning: 'triangle-alert',
|
||||
},
|
||||
|
||||
colorMap: {
|
||||
@@ -35,9 +35,7 @@ const Toast = {
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
if (window.lucide) {
|
||||
lucide.createIcons({ nodes: [toast] });
|
||||
}
|
||||
Utils.refreshIcons(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('toast-show');
|
||||
|
||||
@@ -44,12 +44,12 @@ const WebhooksUI = {
|
||||
<p class="empty-state-desc">Crie webhooks para disparar agentes ou pipelines via HTTP.</p>
|
||||
</div>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = webhooks.map((w) => WebhooksUI._renderCard(w)).join('');
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
_renderCard(webhook) {
|
||||
@@ -79,6 +79,12 @@ const WebhooksUI = {
|
||||
<button class="btn btn-ghost btn-sm btn-icon" data-action="toggle-webhook" data-id="${webhook.id}" title="${webhook.active ? 'Desativar' : 'Ativar'}">
|
||||
<i data-lucide="${webhook.active ? 'pause' : 'play'}"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm btn-icon" data-action="edit-webhook" data-id="${webhook.id}" title="Editar">
|
||||
<i data-lucide="pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm btn-icon" data-action="test-webhook" data-id="${webhook.id}" title="Testar">
|
||||
<i data-lucide="zap"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm btn-icon btn-danger" data-action="delete-webhook" data-id="${webhook.id}" title="Excluir">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
@@ -141,9 +147,46 @@ const WebhooksUI = {
|
||||
WebhooksUI._updateTargetSelect('agent');
|
||||
}
|
||||
|
||||
const submitBtn = document.getElementById('webhook-form-submit');
|
||||
if (submitBtn) submitBtn.dataset.editId = '';
|
||||
|
||||
Modal.open('webhook-modal-overlay');
|
||||
},
|
||||
|
||||
openEditModal(webhookId) {
|
||||
const webhook = WebhooksUI.webhooks.find(w => w.id === webhookId);
|
||||
if (!webhook) return;
|
||||
|
||||
const titleEl = document.getElementById('webhook-modal-title');
|
||||
if (titleEl) titleEl.textContent = 'Editar Webhook';
|
||||
|
||||
const nameEl = document.getElementById('webhook-name');
|
||||
if (nameEl) nameEl.value = webhook.name || '';
|
||||
|
||||
const typeEl = document.getElementById('webhook-target-type');
|
||||
if (typeEl) {
|
||||
typeEl.value = webhook.targetType || 'agent';
|
||||
WebhooksUI._updateTargetSelect(webhook.targetType || 'agent');
|
||||
}
|
||||
|
||||
const targetEl = document.getElementById('webhook-target-id');
|
||||
if (targetEl) targetEl.value = webhook.targetId || '';
|
||||
|
||||
const submitBtn = document.getElementById('webhook-form-submit');
|
||||
if (submitBtn) submitBtn.dataset.editId = webhookId;
|
||||
|
||||
Modal.open('webhook-modal-overlay');
|
||||
},
|
||||
|
||||
async test(webhookId) {
|
||||
try {
|
||||
const result = await API.webhooks.test(webhookId);
|
||||
Toast.success(result.message || 'Webhook testado com sucesso');
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao testar webhook: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
_updateTargetSelect(targetType) {
|
||||
const selectEl = document.getElementById('webhook-target-id');
|
||||
if (!selectEl) return;
|
||||
@@ -161,17 +204,25 @@ const WebhooksUI = {
|
||||
const name = document.getElementById('webhook-name')?.value.trim();
|
||||
const targetType = document.getElementById('webhook-target-type')?.value;
|
||||
const targetId = document.getElementById('webhook-target-id')?.value;
|
||||
const submitBtn = document.getElementById('webhook-form-submit');
|
||||
const editId = submitBtn?.dataset.editId || '';
|
||||
|
||||
if (!name) { Toast.warning('Nome do webhook é obrigatório'); return; }
|
||||
if (!targetId) { Toast.warning('Selecione um destino'); return; }
|
||||
|
||||
try {
|
||||
if (editId) {
|
||||
await API.webhooks.update(editId, { name, targetType, targetId });
|
||||
Modal.close('webhook-modal-overlay');
|
||||
Toast.success('Webhook atualizado com sucesso');
|
||||
} else {
|
||||
await API.webhooks.create({ name, targetType, targetId });
|
||||
Modal.close('webhook-modal-overlay');
|
||||
Toast.success('Webhook criado com sucesso');
|
||||
}
|
||||
await WebhooksUI.load();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao criar webhook: ${err.message}`);
|
||||
Toast.error(`Erro ao salvar webhook: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
16688
public/js/lucide.js
Normal file
16688
public/js/lucide.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,14 @@ const Utils = {
|
||||
if (!str) return '';
|
||||
return str.length > max ? str.slice(0, max) + '…' : str;
|
||||
},
|
||||
|
||||
refreshIcons(container) {
|
||||
if (!window.lucide) return;
|
||||
const target = container || document;
|
||||
const pending = target.querySelectorAll('i[data-lucide]');
|
||||
if (pending.length === 0) return;
|
||||
lucide.createIcons();
|
||||
},
|
||||
};
|
||||
|
||||
window.Utils = Utils;
|
||||
|
||||
42
server.js
42
server.js
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import crypto from 'crypto';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/routes/api.js';
|
||||
import * as manager from './src/agents/manager.js';
|
||||
import { setGlobalBroadcast } from './src/agents/manager.js';
|
||||
@@ -14,9 +15,25 @@ import { flushAllStores } from './src/store/db.js';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const AUTH_TOKEN = process.env.AUTH_TOKEN || '';
|
||||
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || '';
|
||||
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'http://localhost:3000';
|
||||
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
|
||||
|
||||
function timingSafeCompare(a, b) {
|
||||
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return crypto.timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 100,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Limite de requisições excedido. Tente novamente em breve.' },
|
||||
});
|
||||
|
||||
function verifyWebhookSignature(req, res, next) {
|
||||
if (!WEBHOOK_SECRET) return next();
|
||||
const sig = req.headers['x-hub-signature-256'];
|
||||
@@ -39,19 +56,34 @@ const httpServer = createServer(app);
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const origin = ALLOWED_ORIGIN || req.headers.origin || '*';
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGIN);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Client-Id');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Client-Id, X-Correlation-ID');
|
||||
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
req.correlationId = req.headers['x-correlation-id'] || crypto.randomUUID();
|
||||
res.setHeader('X-Correlation-ID', req.correlationId);
|
||||
next();
|
||||
});
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor(process.uptime()),
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api', apiLimiter);
|
||||
|
||||
if (AUTH_TOKEN) {
|
||||
app.use('/api', (req, res, next) => {
|
||||
const header = req.headers.authorization || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : req.query.token;
|
||||
if (token !== AUTH_TOKEN) {
|
||||
if (!timingSafeCompare(token, AUTH_TOKEN)) {
|
||||
return res.status(401).json({ error: 'Token de autenticação inválido' });
|
||||
}
|
||||
next();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { agentsStore, schedulesStore, executionsStore } from '../store/db.js';
|
||||
import { agentsStore, schedulesStore, executionsStore, notificationsStore } from '../store/db.js';
|
||||
import * as executor from './executor.js';
|
||||
import * as scheduler from './scheduler.js';
|
||||
import { generateAgentReport } from '../reports/generator.js';
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
model: 'claude-sonnet-4-6',
|
||||
@@ -25,6 +26,14 @@ function getWsCallback(wsCallback) {
|
||||
return wsCallback || globalBroadcast || null;
|
||||
}
|
||||
|
||||
function createNotification(type, title, message, metadata = {}) {
|
||||
notificationsStore.create({
|
||||
type, title, message, metadata,
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
let dailyExecutionCount = 0;
|
||||
let dailyCountDate = new Date().toDateString();
|
||||
|
||||
@@ -145,6 +154,7 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
const endedAt = new Date().toISOString();
|
||||
updateExecutionRecord(agentId, execId, { status: 'error', error: err.message, endedAt });
|
||||
executionsStore.update(historyRecord.id, { status: 'error', error: err.message, endedAt });
|
||||
createNotification('error', 'Execução falhou', `Agente "${agent.agent_name}" encontrou um erro`, { agentId, executionId: execId });
|
||||
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: err.message } });
|
||||
},
|
||||
onComplete: (result, execId) => {
|
||||
@@ -161,6 +171,14 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
numTurns: result.numTurns || 0,
|
||||
sessionId: result.sessionId || '',
|
||||
});
|
||||
createNotification('success', 'Execução concluída', `Agente "${agent.agent_name}" finalizou a tarefa`, { agentId, executionId: execId });
|
||||
try {
|
||||
const updated = executionsStore.getById(historyRecord.id);
|
||||
if (updated) {
|
||||
const report = generateAgentReport(updated);
|
||||
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
|
||||
}
|
||||
} catch (e) {}
|
||||
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
|
||||
},
|
||||
}
|
||||
@@ -290,6 +308,13 @@ export function continueConversation(agentId, sessionId, message, wsCallback) {
|
||||
numTurns: result.numTurns || 0,
|
||||
sessionId: result.sessionId || sessionId,
|
||||
});
|
||||
try {
|
||||
const updated = executionsStore.getById(historyRecord.id);
|
||||
if (updated) {
|
||||
const report = generateAgentReport(updated);
|
||||
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
|
||||
}
|
||||
} catch (e) {}
|
||||
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { pipelinesStore, agentsStore, executionsStore } from '../store/db.js';
|
||||
import * as executor from './executor.js';
|
||||
import { mem } from '../cache/index.js';
|
||||
import { generatePipelineReport } from '../reports/generator.js';
|
||||
|
||||
const activePipelines = new Map();
|
||||
const AGENT_MAP_TTL = 30_000;
|
||||
@@ -265,8 +266,15 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
totalCostUsd: totalCost,
|
||||
});
|
||||
|
||||
if (!pipelineState.canceled && wsCallback) {
|
||||
wsCallback({ type: 'pipeline_complete', pipelineId, results, totalCostUsd: totalCost });
|
||||
if (!pipelineState.canceled) {
|
||||
try {
|
||||
const updated = executionsStore.getById(historyRecord.id);
|
||||
if (updated) {
|
||||
const report = generatePipelineReport(updated);
|
||||
if (wsCallback) wsCallback({ type: 'report_generated', pipelineId, reportFile: report.filename });
|
||||
}
|
||||
} catch (e) {}
|
||||
if (wsCallback) wsCallback({ type: 'pipeline_complete', pipelineId, results, totalCostUsd: totalCost });
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -300,6 +308,15 @@ export function cancelPipeline(pipelineId) {
|
||||
}
|
||||
if (state.currentExecutionId) executor.cancel(state.currentExecutionId);
|
||||
activePipelines.delete(pipelineId);
|
||||
|
||||
const allExecs = executionsStore.getAll();
|
||||
const idx = allExecs.findIndex(e => e.pipelineId === pipelineId && (e.status === 'running' || e.status === 'awaiting_approval'));
|
||||
if (idx !== -1) {
|
||||
allExecs[idx].status = 'canceled';
|
||||
allExecs[idx].endedAt = new Date().toISOString();
|
||||
executionsStore.save(allExecs);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
188
src/reports/generator.js
Normal file
188
src/reports/generator.js
Normal file
@@ -0,0 +1,188 @@
|
||||
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPORTS_DIR = join(__dirname, '..', '..', 'data', 'reports');
|
||||
|
||||
function ensureDir() {
|
||||
if (!existsSync(REPORTS_DIR)) mkdirSync(REPORTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function sanitizeFilename(name) {
|
||||
return name.replace(/[^a-zA-Z0-9À-ÿ_-]/g, '_').slice(0, 60);
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('pt-BR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(startIso, endIso) {
|
||||
if (!startIso || !endIso) return '—';
|
||||
const ms = new Date(endIso) - new Date(startIso);
|
||||
if (ms < 0) return '—';
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
if (h > 0) return `${h}h ${m % 60}m ${s % 60}s`;
|
||||
return `${m}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
export function generateAgentReport(execution) {
|
||||
ensureDir();
|
||||
|
||||
const name = execution.agentName || 'Agente';
|
||||
const filename = `agente_${sanitizeFilename(name)}_${timestamp()}.md`;
|
||||
const filepath = join(REPORTS_DIR, filename);
|
||||
|
||||
const status = execution.status === 'completed' ? '✅ Concluído' : '❌ Erro';
|
||||
const cost = (execution.costUsd || execution.totalCostUsd || 0).toFixed(4);
|
||||
|
||||
const lines = [
|
||||
`# Relatório de Execução — ${name}`,
|
||||
'',
|
||||
`**Data:** ${formatDate(execution.startedAt)}`,
|
||||
`**Status:** ${status}`,
|
||||
`**Duração:** ${formatDuration(execution.startedAt, execution.endedAt)}`,
|
||||
`**Custo:** $${cost}`,
|
||||
`**Turnos:** ${execution.numTurns || '—'}`,
|
||||
`**Session ID:** \`${execution.sessionId || '—'}\``,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Tarefa',
|
||||
'',
|
||||
execution.task || '_(sem tarefa definida)_',
|
||||
'',
|
||||
];
|
||||
|
||||
if (execution.instructions) {
|
||||
lines.push('## Instruções Adicionais', '', execution.instructions, '');
|
||||
}
|
||||
|
||||
lines.push('---', '', '## Resultado', '');
|
||||
|
||||
if (execution.status === 'error' && execution.error) {
|
||||
lines.push('### Erro', '', '```', execution.error, '```', '');
|
||||
}
|
||||
|
||||
if (execution.result) {
|
||||
lines.push(execution.result);
|
||||
} else {
|
||||
lines.push('_(sem resultado textual)_');
|
||||
}
|
||||
|
||||
lines.push('', '---', '', `_Relatório gerado automaticamente em ${formatDate(new Date().toISOString())}_`);
|
||||
|
||||
writeFileSync(filepath, lines.join('\n'), 'utf-8');
|
||||
return { filename, filepath };
|
||||
}
|
||||
|
||||
export function generatePipelineReport(execution) {
|
||||
ensureDir();
|
||||
|
||||
const name = execution.pipelineName || 'Pipeline';
|
||||
const filename = `pipeline_${sanitizeFilename(name)}_${timestamp()}.md`;
|
||||
const filepath = join(REPORTS_DIR, filename);
|
||||
|
||||
const status = execution.status === 'completed' ? '✅ Concluído'
|
||||
: execution.status === 'error' ? '❌ Erro'
|
||||
: execution.status === 'canceled' ? '⚠️ Cancelado'
|
||||
: execution.status;
|
||||
|
||||
const totalCost = (execution.totalCostUsd || 0).toFixed(4);
|
||||
const steps = Array.isArray(execution.steps) ? execution.steps : [];
|
||||
|
||||
const lines = [
|
||||
`# Relatório de Pipeline — ${name}`,
|
||||
'',
|
||||
`**Data:** ${formatDate(execution.startedAt)}`,
|
||||
`**Status:** ${status}`,
|
||||
`**Duração:** ${formatDuration(execution.startedAt, execution.endedAt)}`,
|
||||
`**Custo Total:** $${totalCost}`,
|
||||
`**Passos:** ${steps.length}`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Input Inicial',
|
||||
'',
|
||||
execution.input || '_(sem input)_',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Execução dos Passos',
|
||||
'',
|
||||
];
|
||||
|
||||
steps.forEach((step, i) => {
|
||||
const stepStatus = step.status === 'completed' ? '✅' : step.status === 'error' ? '❌' : '⏳';
|
||||
const stepCost = (step.costUsd || 0).toFixed(4);
|
||||
const stepDuration = formatDuration(step.startedAt, step.endedAt);
|
||||
|
||||
lines.push(
|
||||
`### Passo ${i + 1} — ${step.agentName || 'Agente'} ${stepStatus}`,
|
||||
'',
|
||||
`| Propriedade | Valor |`,
|
||||
`|-------------|-------|`,
|
||||
`| Status | ${step.status || '—'} |`,
|
||||
`| Duração | ${stepDuration} |`,
|
||||
`| Custo | $${stepCost} |`,
|
||||
`| Turnos | ${step.numTurns || '—'} |`,
|
||||
'',
|
||||
);
|
||||
|
||||
if (step.prompt) {
|
||||
lines.push(
|
||||
'<details>',
|
||||
'<summary>Prompt utilizado</summary>',
|
||||
'',
|
||||
'```',
|
||||
step.prompt,
|
||||
'```',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
if (step.result) {
|
||||
lines.push('**Resultado:**', '', step.result, '');
|
||||
} else if (step.status === 'error') {
|
||||
lines.push('**Erro:** Passo falhou durante a execução.', '');
|
||||
}
|
||||
|
||||
if (i < steps.length - 1) {
|
||||
lines.push('---', '');
|
||||
}
|
||||
});
|
||||
|
||||
if (execution.error) {
|
||||
lines.push('---', '', '## Erro da Pipeline', '', '```', execution.error, '```', '');
|
||||
}
|
||||
|
||||
const lastStep = steps[steps.length - 1];
|
||||
if (execution.status === 'completed' && lastStep?.result) {
|
||||
lines.push(
|
||||
'---',
|
||||
'',
|
||||
'## Resultado Final',
|
||||
'',
|
||||
lastStep.result,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('---', '', `_Relatório gerado automaticamente em ${formatDate(new Date().toISOString())}_`);
|
||||
|
||||
writeFileSync(filepath, lines.join('\n'), 'utf-8');
|
||||
return { filename, filepath };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -30,7 +30,9 @@ function readJson(path, fallback) {
|
||||
|
||||
function writeJson(path, data) {
|
||||
ensureDir();
|
||||
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8');
|
||||
const tmpPath = path + '.tmp';
|
||||
writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8');
|
||||
renameSync(tmpPath, path);
|
||||
}
|
||||
|
||||
function clone(v) {
|
||||
@@ -198,4 +200,5 @@ export const webhooksStore = createStore(`${DATA_DIR}/webhooks.json`);
|
||||
export const settingsStore = createSettingsStore(`${DATA_DIR}/settings.json`);
|
||||
export const secretsStore = createStore(`${DATA_DIR}/secrets.json`);
|
||||
export const notificationsStore = createStore(`${DATA_DIR}/notifications.json`);
|
||||
notificationsStore.setMaxSize(200);
|
||||
export const agentVersionsStore = createStore(`${DATA_DIR}/agent_versions.json`);
|
||||
|
||||
Reference in New Issue
Block a user