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

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

View File

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

View File

@@ -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>

View File

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

View File

@@ -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,9 +285,32 @@ 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) {
const container = document.getElementById('approval-notification');
if (!container) return;
@@ -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]);
}
}
});
},

View File

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

View File

@@ -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) {

View File

@@ -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',

View 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;

View File

@@ -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() {

View File

@@ -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) {

View File

@@ -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',

View File

@@ -39,7 +39,7 @@ const TasksUI = {
container.appendChild(fragment);
if (window.lucide) lucide.createIcons({ nodes: [container] });
Utils.refreshIcons(container);
},
filter(searchText, categoryFilter) {

View File

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

View File

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

View File

@@ -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 {
await API.webhooks.create({ name, targetType, targetId });
Modal.close('webhook-modal-overlay');
Toast.success('Webhook criado com sucesso');
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

File diff suppressed because it is too large Load Diff

View File

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