Continuação de conversa no terminal, histórico de agendamentos, webhooks e melhorias gerais

- Terminal com input de chat: após execução, permite continuar conversa com o agente
  via --resume do CLI, mantendo contexto da sessão (sessionId persistido)
- Nova rota POST /api/agents/:id/continue para retomar sessões
- Executor com função resume() para spawnar claude com --resume <sessionId>
- Histórico de agendamentos agora busca do executionsStore (persistente) com dados
  completos: agente, tarefa, status, duração, custo e link para detalhes no modal
- Execuções de agendamento tagueadas com source:'schedule' e scheduleId
- Correção da expressão cron duplicada na UI de agendamentos
- cronToHuman trata expressões com minuto específico (ex: 37 3 * * * → Todo dia às 03:37)
- Botão "Copiar cURL" nos cards de webhook com payload de exemplo contextual
- Webhooks component (webhooks.js) adicionado ao repositório
This commit is contained in:
Frederico Castro
2026-02-26 04:01:12 -03:00
parent 22a3ce9262
commit 93d9027e2c
18 changed files with 1609 additions and 75 deletions

View File

@@ -1029,6 +1029,17 @@ textarea {
color: var(--success);
}
.terminal-line.user-message {
border-left: 3px solid var(--accent);
padding-left: 8px;
margin: 6px 0;
}
.terminal-line.user-message .content {
color: var(--accent);
font-weight: 500;
}
.terminal-line.info .content {
color: var(--info);
}
@@ -2004,6 +2015,20 @@ tbody tr:hover td {
color: var(--warning);
}
.metric-card-icon--yellow {
background-color: rgba(250, 204, 21, 0.12);
color: #facc15;
}
.metric-card-icon--cyan {
background-color: rgba(6, 182, 212, 0.12);
color: #06b6d4;
}
.metric-card-value--sm {
font-size: 1.25rem;
}
.metric-card-body {
display: flex;
flex-direction: column;
@@ -2619,6 +2644,55 @@ tbody tr:hover td {
color: var(--text-muted);
}
.terminal-input-bar {
border-top: 1px solid var(--border);
background-color: #0c0c14;
padding: 10px 16px;
}
.terminal-input-context {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 6px;
font-family: 'JetBrains Mono', monospace;
}
.terminal-input-row {
display: flex;
align-items: center;
gap: 8px;
}
.terminal-input-prompt {
color: var(--accent);
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
.terminal-input {
flex: 1;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.terminal-input:focus {
border-color: var(--accent);
}
.terminal-input::placeholder {
color: var(--text-muted);
opacity: 0.6;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -3721,3 +3795,250 @@ tbody tr:hover td {
padding: 5px 10px;
font-size: 12px;
}
.approval-notification {
padding: 12px 16px;
background: linear-gradient(135deg, rgba(245, 158, 11, 0.12), rgba(245, 158, 11, 0.05));
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 0;
}
.approval-content {
display: flex;
align-items: center;
gap: 12px;
}
.approval-icon {
color: var(--warning);
flex-shrink: 0;
}
.approval-icon svg {
width: 20px;
height: 20px;
}
.approval-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.approval-text strong {
font-size: 13px;
color: var(--warning);
}
.approval-text span {
font-size: 12px;
color: var(--text-secondary);
}
.approval-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.webhook-card {
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
transition: border-color 0.2s;
}
.webhook-card:hover {
border-color: var(--border-secondary);
}
.webhook-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.webhook-card-identity {
display: flex;
align-items: center;
gap: 8px;
}
.webhook-card-name {
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
}
.webhook-card-actions {
display: flex;
gap: 4px;
}
.webhook-card-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.webhook-card-target,
.webhook-card-url {
display: flex;
flex-direction: column;
gap: 4px;
}
.webhook-card-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.webhook-card-value {
font-size: 13px;
color: var(--text-secondary);
}
.webhook-url-field {
display: flex;
align-items: center;
gap: 8px;
}
.webhook-url-code {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent);
background: var(--bg-input);
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--border-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.webhook-card-curl {
padding-top: 6px;
}
.webhook-card-meta {
display: flex;
gap: 16px;
padding-top: 8px;
border-top: 1px solid var(--border-primary);
}
.webhook-meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
}
.pipeline-step-approval {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--warning);
cursor: pointer;
padding: 4px 0;
}
.pipeline-step-approval input[type="checkbox"] {
accent-color: var(--warning);
width: 14px;
height: 14px;
cursor: pointer;
}
.cost-value {
color: #facc15;
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
}
.activity-item-cost {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: #facc15;
}
.history-card-cost {
display: flex;
align-items: center;
gap: 3px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #facc15;
}
.history-card-cost svg {
width: 12px;
height: 12px;
}
.history-card-duration-group {
display: flex;
align-items: center;
gap: 12px;
}
.pipeline-step-meta-group {
display: flex;
align-items: center;
gap: 10px;
}
.pipeline-step-cost {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #facc15;
}
.badge-warning {
background-color: rgba(245, 158, 11, 0.15);
color: var(--warning);
}
.badge--yellow {
background-color: rgba(245, 158, 11, 0.15);
color: var(--warning);
}
.form-hint-block {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 12px 14px;
font-size: 12px;
color: var(--text-secondary);
}
.form-hint-block p {
margin-bottom: 6px;
}
.form-hint-block p:last-child {
margin-bottom: 0;
}
.form-hint-code {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent);
background: var(--bg-input);
padding: 8px 10px;
border-radius: 6px;
margin-top: 4px;
display: block;
white-space: pre;
}

View File

@@ -53,6 +53,12 @@
<span>Pipelines</span>
</a>
</li>
<li class="sidebar-nav-item">
<a href="#" class="sidebar-nav-link" data-section="webhooks">
<i data-lucide="webhook"></i>
<span>Webhooks</span>
</a>
</li>
<li class="sidebar-nav-item">
<a href="#" class="sidebar-nav-link" data-section="terminal">
<i data-lucide="terminal"></i>
@@ -148,6 +154,24 @@
<span class="metric-card-value" id="metric-schedules">0</span>
</div>
</article>
<article class="metric-card">
<div class="metric-card-icon metric-card-icon--yellow">
<i data-lucide="dollar-sign"></i>
</div>
<div class="metric-card-body">
<span class="metric-card-label">Custo Hoje</span>
<span class="metric-card-value metric-card-value--sm" id="metric-cost-today">$0.0000</span>
</div>
</article>
<article class="metric-card">
<div class="metric-card-icon metric-card-icon--cyan">
<i data-lucide="webhook"></i>
</div>
<div class="metric-card-body">
<span class="metric-card-label">Webhooks Ativos</span>
<span class="metric-card-value" id="metric-webhooks">0</span>
</div>
</article>
</div>
<div class="dashboard-grid">
@@ -361,6 +385,22 @@
</div>
</section>
<section id="webhooks" class="section" aria-label="Webhooks" hidden>
<div class="section-toolbar">
<div class="search-field">
<i data-lucide="search"></i>
<input type="search" placeholder="Buscar webhooks..." id="webhooks-search" aria-label="Buscar webhooks" />
</div>
<div class="section-toolbar-actions">
<button class="btn btn--primary btn--icon-text" type="button" id="webhooks-new-btn">
<i data-lucide="plus"></i>
<span>Novo Webhook</span>
</button>
</div>
</div>
<div id="webhooks-list"></div>
</section>
<section id="terminal" class="section" aria-label="Terminal" hidden>
<div class="terminal-wrapper">
<div class="terminal-toolbar">
@@ -384,12 +424,30 @@
</button>
</div>
</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">
<span class="terminal-prompt">$</span>
<span class="terminal-text">Aguardando execução de agente...</span>
</div>
</div>
<div class="terminal-input-bar" id="terminal-input-bar" hidden>
<div class="terminal-input-context" id="terminal-input-context"></div>
<div class="terminal-input-row">
<span class="terminal-input-prompt"></span>
<input
type="text"
class="terminal-input"
id="terminal-input"
placeholder="Continuar conversa com o agente..."
aria-label="Enviar mensagem ao agente"
autocomplete="off"
/>
<button class="btn btn--primary btn--sm" id="terminal-send-btn" type="button" aria-label="Enviar">
<i data-lucide="send"></i>
</button>
</div>
</div>
</div>
</section>
@@ -890,6 +948,17 @@
</div>
<div class="modal-body">
<input type="hidden" id="pipeline-execute-id">
<div class="form-group">
<label class="form-label" for="pipeline-execute-workdir">Diretório de Trabalho</label>
<input
type="text"
class="input"
id="pipeline-execute-workdir"
placeholder="/home/fred/projetos/meu-projeto"
autocomplete="off"
/>
<p class="form-hint">Todos os agentes da pipeline vão trabalhar neste diretório. Se vazio, cada agente usa seu próprio.</p>
</div>
<div class="form-group">
<label class="form-label" for="pipeline-execute-input">
Input Inicial
@@ -998,6 +1067,53 @@
</div>
</div>
<div class="modal-overlay" id="webhook-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="webhook-modal-title" hidden>
<div class="modal modal--md">
<div class="modal-header">
<h2 class="modal-title" id="webhook-modal-title">Novo Webhook</h2>
<button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="webhook-modal-overlay">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body">
<form class="modal-form" id="webhook-form" novalidate>
<div class="form-group">
<label class="form-label" for="webhook-name">
Nome
<span class="form-required" aria-hidden="true">*</span>
</label>
<input type="text" class="input" id="webhook-name" placeholder="Ex: Deploy Trigger" required autocomplete="off" />
</div>
<div class="form-group">
<label class="form-label" for="webhook-target-type">Tipo de Destino</label>
<select class="select" id="webhook-target-type">
<option value="agent">Agente</option>
<option value="pipeline">Pipeline</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="webhook-target-id">
Destino
<span class="form-required" aria-hidden="true">*</span>
</label>
<select class="select" id="webhook-target-id" required>
<option value="">Selecionar...</option>
</select>
</div>
<div class="form-hint-block">
<p>Após criar, você receberá uma URL única para disparar este webhook via POST.</p>
<p><strong>Payload aceito:</strong></p>
<pre class="form-hint-code">{ "task": "descrição", "instructions": "..." }</pre>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn--ghost" type="button" data-modal-close="webhook-modal-overlay">Cancelar</button>
<button class="btn btn--primary" type="button" id="webhook-form-submit">Criar Webhook</button>
</div>
</div>
</div>
<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>
@@ -1012,6 +1128,7 @@
<script src="js/components/pipelines.js"></script>
<script src="js/components/settings.js"></script>
<script src="js/components/history.js"></script>
<script src="js/components/webhooks.js"></script>
<script src="js/app.js"></script>
<script>
lucide.createIcons();

View File

@@ -40,6 +40,7 @@ const API = {
delete(id) { return API.request('DELETE', `/agents/${id}`); },
execute(id, task, instructions) { return API.request('POST', `/agents/${id}/execute`, { task, instructions }); },
cancel(id, executionId) { return API.request('POST', `/agents/${id}/cancel/${executionId}`); },
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); },
},
@@ -65,8 +66,25 @@ const API = {
create(data) { return API.request('POST', '/pipelines', data); },
update(id, data) { return API.request('PUT', `/pipelines/${id}`, data); },
delete(id) { return API.request('DELETE', `/pipelines/${id}`); },
execute(id, input) { return API.request('POST', `/pipelines/${id}/execute`, { input }); },
execute(id, input, workingDirectory) {
const body = { input };
if (workingDirectory) body.workingDirectory = workingDirectory;
return API.request('POST', `/pipelines/${id}/execute`, body);
},
cancel(id) { return API.request('POST', `/pipelines/${id}/cancel`); },
approve(id) { return API.request('POST', `/pipelines/${id}/approve`); },
reject(id) { return API.request('POST', `/pipelines/${id}/reject`); },
},
webhooks: {
list() { return API.request('GET', '/webhooks'); },
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}`); },
},
stats: {
costs(days) { return API.request('GET', `/stats/costs${days ? '?days=' + days : ''}`); },
},
system: {

View File

@@ -4,6 +4,7 @@ const App = {
wsReconnectAttempts: 0,
wsReconnectTimer: null,
_initialized: false,
_lastAgentName: '',
sectionTitles: {
dashboard: 'Dashboard',
@@ -11,6 +12,7 @@ const App = {
tasks: 'Tarefas',
schedules: 'Agendamentos',
pipelines: 'Pipelines',
webhooks: 'Webhooks',
terminal: 'Terminal',
history: 'Histórico',
settings: 'Configurações',
@@ -71,6 +73,7 @@ const App = {
case 'tasks': await TasksUI.load(); break;
case 'schedules': await SchedulesUI.load(); break;
case 'pipelines': await PipelinesUI.load(); break;
case 'webhooks': await WebhooksUI.load(); break;
case 'history': await HistoryUI.load(); break;
case 'settings': await SettingsUI.load(); break;
}
@@ -154,6 +157,23 @@ const App = {
if (data.data?.stderr) {
Terminal.addLine(data.data.stderr, 'error', data.executionId);
}
const costUsd = data.data?.costUsd || 0;
const numTurns = data.data?.numTurns || 0;
if (costUsd > 0) {
Terminal.addLine(`Custo: $${costUsd.toFixed(4)} | Turnos: ${numTurns}`, 'info', data.executionId);
}
const sessionId = data.data?.sessionId || '';
if (sessionId && data.agentId) {
if (Terminal.getChatSession()?.sessionId === sessionId || !Terminal.getChatSession()) {
const agentName = App._lastAgentName || 'Agente';
Terminal.enableChat(data.agentId, agentName, sessionId);
}
if (Terminal.getChatSession()) {
Terminal.updateSessionId(sessionId);
}
}
Toast.success('Execução concluída');
App.refreshCurrentSection();
App._updateActiveBadge();
@@ -200,6 +220,82 @@ const App = {
Terminal.addLine(`Erro no passo ${data.stepIndex + 1}: ${data.error}`, 'error');
Toast.error('Erro no pipeline');
break;
case 'pipeline_approval_required':
Terminal.stopProcessing();
Terminal.addLine(`Passo ${data.stepIndex + 1} requer aprovação antes de executar.`, 'system');
if (data.previousOutput) {
Terminal.addLine(`Output do passo anterior:\n${data.previousOutput.slice(0, 1000)}`, 'info');
}
App._showApprovalNotification(data.pipelineId, data.stepIndex, data.agentName);
Toast.warning('Pipeline aguardando aprovação');
break;
case 'pipeline_rejected':
Terminal.stopProcessing();
Terminal.addLine(`Pipeline rejeitado no passo ${data.stepIndex + 1}.`, 'error');
App._hideApprovalNotification();
Toast.info('Pipeline rejeitado');
App.refreshCurrentSection();
break;
case 'pipeline_status':
break;
}
},
_showApprovalNotification(pipelineId, stepIndex, agentName) {
const container = document.getElementById('approval-notification');
if (!container) return;
container.innerHTML = `
<div class="approval-content">
<div class="approval-icon"><i data-lucide="shield-alert"></i></div>
<div class="approval-text">
<strong>Aprovação necessária</strong>
<span>Passo ${stepIndex + 1} (${agentName || 'agente'}) aguardando autorização</span>
</div>
<div class="approval-actions">
<button class="btn btn--primary btn--sm" id="approval-approve-btn" type="button">Aprovar</button>
<button class="btn btn--danger btn--sm" id="approval-reject-btn" type="button">Rejeitar</button>
</div>
</div>
`;
container.hidden = false;
container.dataset.pipelineId = pipelineId;
if (window.lucide) lucide.createIcons({ nodes: [container] });
document.getElementById('approval-approve-btn')?.addEventListener('click', () => {
App._handleApproval(pipelineId, true);
});
document.getElementById('approval-reject-btn')?.addEventListener('click', () => {
App._handleApproval(pipelineId, false);
});
},
_hideApprovalNotification() {
const container = document.getElementById('approval-notification');
if (container) {
container.hidden = true;
container.innerHTML = '';
}
},
async _handleApproval(pipelineId, approve) {
try {
if (approve) {
await API.pipelines.approve(pipelineId);
Terminal.addLine('Passo aprovado. Continuando pipeline...', 'success');
Toast.success('Passo aprovado');
} else {
await API.pipelines.reject(pipelineId);
Terminal.addLine('Pipeline rejeitado pelo usuário.', 'error');
Toast.info('Pipeline rejeitado');
}
App._hideApprovalNotification();
} catch (err) {
Toast.error(`Erro: ${err.message}`);
}
},
@@ -306,6 +402,17 @@ const App = {
SchedulesUI.save();
});
on('webhooks-new-btn', 'click', () => WebhooksUI.openCreateModal());
on('webhook-form-submit', 'click', (e) => {
e.preventDefault();
WebhooksUI.save();
});
on('webhook-target-type', 'change', (e) => {
WebhooksUI._updateTargetSelect(e.target.value);
});
on('pipelines-new-btn', 'click', () => PipelinesUI.openCreateModal());
on('pipeline-form-submit', 'click', (e) => {
@@ -317,7 +424,19 @@ const App = {
on('pipeline-execute-submit', 'click', () => PipelinesUI._executeFromModal());
on('terminal-clear-btn', 'click', () => Terminal.clear());
on('terminal-clear-btn', 'click', () => {
Terminal.clear();
Terminal.disableChat();
});
on('terminal-send-btn', 'click', () => App._sendChatMessage());
on('terminal-input', 'keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
App._sendChatMessage();
}
});
on('export-copy-btn', 'click', () => App._copyExportJson());
@@ -374,6 +493,10 @@ const App = {
);
});
on('webhooks-search', 'input', () => {
WebhooksUI.filter(document.getElementById('webhooks-search')?.value);
});
on('pipelines-search', 'input', () => {
PipelinesUI.filter(document.getElementById('pipelines-search')?.value);
});
@@ -443,6 +566,13 @@ const App = {
}
});
document.getElementById('schedules-history')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const { action, id } = btn.dataset;
if (action === 'view-schedule-exec') HistoryUI.viewDetail(id);
});
document.getElementById('pipelines-grid')?.addEventListener('click', (e) => {
if (e.target.closest('#pipelines-empty-new-btn')) {
PipelinesUI.openCreateModal();
@@ -471,6 +601,18 @@ const App = {
}
});
document.getElementById('webhooks-list')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const { action, id, url } = btn.dataset;
switch (action) {
case 'toggle-webhook': WebhooksUI.toggleActive(id); break;
case 'delete-webhook': WebhooksUI.delete(id); break;
case 'copy-webhook-url': WebhooksUI.copyUrl(url); break;
case 'copy-webhook-curl': WebhooksUI.copyCurl(id); break;
}
});
document.getElementById('pipeline-steps-container')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-step-action]');
if (!btn) return;
@@ -572,6 +714,9 @@ const App = {
const selectEl = document.getElementById('execute-agent-select');
const agentName = selectEl?.selectedOptions[0]?.text || 'Agente';
Terminal.disableChat();
App._lastAgentName = agentName;
await API.agents.execute(agentId, task, instructions);
Modal.close('execute-modal-overlay');
@@ -583,6 +728,27 @@ const App = {
}
},
async _sendChatMessage() {
const session = Terminal.getChatSession();
if (!session) return;
const input = document.getElementById('terminal-input');
const message = input?.value.trim();
if (!message) return;
input.value = '';
Terminal.addLine(` ${message}`, 'user-message', null);
try {
await API.agents.continue(session.agentId, session.sessionId, message);
Terminal.startProcessing(session.agentName);
} catch (err) {
Terminal.addLine(`Erro: ${err.message}`, 'error');
Toast.error(`Erro ao continuar conversa: ${err.message}`);
}
},
async _copyExportJson() {
const jsonEl = document.getElementById('export-code-content');
if (!jsonEl) return;

View File

@@ -29,6 +29,18 @@ const DashboardUI = {
const current = parseInt(el.textContent, 10) || 0;
DashboardUI._animateCount(el, current, target);
}
const costEl = document.getElementById('metric-cost-today');
if (costEl) {
const cost = status.costs?.today ?? 0;
costEl.textContent = `$${cost.toFixed(4)}`;
}
const webhooksEl = document.getElementById('metric-webhooks');
if (webhooksEl) {
const current = parseInt(webhooksEl.textContent, 10) || 0;
DashboardUI._animateCount(webhooksEl, current, status.webhooks?.active ?? 0);
}
},
_animateCount(el, from, to) {
@@ -77,6 +89,10 @@ const DashboardUI = {
const date = exec.startedAt
? new Date(exec.startedAt).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })
: '';
const cost = exec.costUsd || exec.totalCostUsd || 0;
const costHtml = cost > 0
? `<span class="activity-item-cost">$${cost.toFixed(4)}</span>`
: '';
return `
<li class="activity-item">
@@ -85,6 +101,7 @@ const DashboardUI = {
<span class="activity-item-task">${taskText.length > 80 ? taskText.slice(0, 80) + '...' : taskText}</span>
</div>
<div class="activity-item-meta">
${costHtml}
<span class="badge ${statusClass}">${statusLabel}</span>
<span class="activity-item-time">${date} ${time}</span>
</div>
@@ -107,7 +124,9 @@ const DashboardUI = {
running: 'badge--blue',
completed: 'badge--green',
error: 'badge--red',
cancelled: 'badge--gray',
canceled: 'badge--gray',
awaiting_approval: 'badge--yellow',
rejected: 'badge--red',
};
return map[status] || 'badge--gray';
},
@@ -117,7 +136,9 @@ const DashboardUI = {
running: 'Em execução',
completed: 'Concluído',
error: 'Erro',
cancelled: 'Cancelado',
canceled: 'Cancelado',
awaiting_approval: 'Aguardando',
rejected: 'Rejeitado',
};
return map[status] || status || 'Desconhecido';
},

View File

@@ -60,6 +60,10 @@ const HistoryUI = {
: (exec.task || '');
const date = HistoryUI._formatDate(exec.startedAt);
const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt);
const cost = exec.costUsd || exec.totalCostUsd || 0;
const costHtml = cost > 0
? `<span class="history-card-cost"><i data-lucide="dollar-sign" aria-hidden="true"></i>$${cost.toFixed(4)}</span>`
: '';
return `
<article class="history-card">
@@ -75,9 +79,12 @@ const HistoryUI = {
</div>
<div class="history-card-meta">
<span class="history-card-task">${HistoryUI._escapeHtml(task)}</span>
<span class="history-card-duration">
<i data-lucide="clock" aria-hidden="true"></i>
${duration}
<span class="history-card-duration-group">
<span class="history-card-duration">
<i data-lucide="clock" aria-hidden="true"></i>
${duration}
</span>
${costHtml}
</span>
</div>
<div class="history-card-actions">
@@ -222,6 +229,16 @@ const HistoryUI = {
<span class="execution-detail-label">Exit Code</span>
<span class="execution-detail-value font-mono">${exec.exitCode}</span>
</div>` : ''}
${exec.costUsd || exec.totalCostUsd ? `
<div class="execution-detail-row">
<span class="execution-detail-label">Custo</span>
<span class="execution-detail-value cost-value">$${(exec.costUsd || exec.totalCostUsd || 0).toFixed(4)}</span>
</div>` : ''}
${exec.numTurns ? `
<div class="execution-detail-row">
<span class="execution-detail-label">Turnos</span>
<span class="execution-detail-value font-mono">${exec.numTurns}</span>
</div>` : ''}
</div>
${exec.task ? `
<div class="execution-detail-section">
@@ -265,9 +282,12 @@ const HistoryUI = {
<span class="pipeline-step-agent">${HistoryUI._escapeHtml(step.agentName || step.agentId || 'Agente')}</span>
${HistoryUI._statusBadge(step.status)}
</div>
<span class="pipeline-step-duration">
<i data-lucide="clock" aria-hidden="true"></i>
${stepDuration}
<span class="pipeline-step-meta-group">
<span class="pipeline-step-duration">
<i data-lucide="clock" aria-hidden="true"></i>
${stepDuration}
</span>
${step.costUsd ? `<span class="pipeline-step-cost">$${step.costUsd.toFixed(4)}</span>` : ''}
</span>
</div>
${step.prompt ? `
@@ -314,6 +334,11 @@ const HistoryUI = {
<span class="execution-detail-label">Duração</span>
<span class="execution-detail-value">${duration}</span>
</div>
${exec.totalCostUsd ? `
<div class="execution-detail-row">
<span class="execution-detail-label">Custo Total</span>
<span class="execution-detail-value cost-value">$${(exec.totalCostUsd || 0).toFixed(4)}</span>
</div>` : ''}
</div>
${exec.input ? `
<div class="execution-detail-section">
@@ -373,6 +398,9 @@ const HistoryUI = {
running: ['badge-running', 'Em execução'],
completed: ['badge-active', 'Concluído'],
error: ['badge-error', 'Erro'],
awaiting_approval: ['badge-warning', 'Aguardando'],
rejected: ['badge-error', 'Rejeitado'],
canceled: ['badge-inactive', 'Cancelado'],
};
const [cls, label] = map[status] || ['badge-inactive', status || 'Desconhecido'];
return `<span class="badge ${cls}">${label}</span>`;

View File

@@ -3,6 +3,7 @@ const PipelinesUI = {
agents: [],
_editingId: null,
_steps: [],
_pendingApprovals: new Map(),
async load() {
try {
@@ -84,10 +85,13 @@ const PipelinesUI = {
const flowHtml = steps.map((step, index) => {
const agentName = step.agentName || step.agentId || 'Agente';
const isLast = index === steps.length - 1;
const approvalIcon = step.requiresApproval && index > 0
? '<i data-lucide="shield-check" style="width:10px;height:10px;color:var(--warning)"></i> '
: '';
return `
<span class="pipeline-step-badge">
<span class="pipeline-step-number">${index + 1}</span>
${agentName}
${approvalIcon}${agentName}
</span>
${!isLast ? '<span class="pipeline-flow-arrow">→</span>' : ''}
`;
@@ -130,8 +134,8 @@ const PipelinesUI = {
openCreateModal() {
PipelinesUI._editingId = null;
PipelinesUI._steps = [
{ agentId: '', inputTemplate: '' },
{ agentId: '', inputTemplate: '' },
{ agentId: '', inputTemplate: '', requiresApproval: false },
{ agentId: '', inputTemplate: '', requiresApproval: false },
];
const titleEl = document.getElementById('pipeline-modal-title');
@@ -156,7 +160,7 @@ const PipelinesUI = {
PipelinesUI._editingId = pipelineId;
PipelinesUI._steps = Array.isArray(pipeline.steps)
? pipeline.steps.map((s) => ({ agentId: s.agentId || '', inputTemplate: s.inputTemplate || '' }))
? pipeline.steps.map((s) => ({ agentId: s.agentId || '', inputTemplate: s.inputTemplate || '', requiresApproval: !!s.requiresApproval }))
: [];
const titleEl = document.getElementById('pipeline-modal-title');
@@ -198,6 +202,15 @@ const PipelinesUI = {
? '<div class="pipeline-step-connector"><i data-lucide="arrow-down" style="width:14px;height:14px"></i></div>'
: '';
const approvalChecked = step.requiresApproval ? 'checked' : '';
const approvalHtml = index > 0
? `<label class="pipeline-step-approval">
<input type="checkbox" data-step-field="requiresApproval" data-step-index="${index}" ${approvalChecked} />
<i data-lucide="shield-check" style="width:12px;height:12px"></i>
<span>Requer aprovação</span>
</label>`
: '';
return `
<div class="pipeline-step-row" data-step-index="${index}">
<span class="pipeline-step-number-lg">${index + 1}</span>
@@ -213,6 +226,7 @@ const PipelinesUI = {
data-step-field="inputTemplate"
data-step-index="${index}"
>${step.inputTemplate || ''}</textarea>
${approvalHtml}
</div>
<div class="pipeline-step-actions">
<button class="btn btn-ghost btn-icon btn-sm" type="button" data-step-action="move-up" data-step-index="${index}" title="Mover para cima" ${isFirst ? 'disabled' : ''}>
@@ -246,14 +260,18 @@ const PipelinesUI = {
const index = parseInt(el.dataset.stepIndex, 10);
const field = el.dataset.stepField;
if (PipelinesUI._steps[index] !== undefined) {
PipelinesUI._steps[index][field] = el.value;
if (el.type === 'checkbox') {
PipelinesUI._steps[index][field] = el.checked;
} else {
PipelinesUI._steps[index][field] = el.value;
}
}
});
},
addStep() {
PipelinesUI._syncStepsFromDOM();
PipelinesUI._steps.push({ agentId: '', inputTemplate: '' });
PipelinesUI._steps.push({ agentId: '', inputTemplate: '', requiresApproval: false });
PipelinesUI.renderSteps();
},
@@ -299,6 +317,7 @@ const PipelinesUI = {
steps: PipelinesUI._steps.map((s) => ({
agentId: s.agentId,
inputTemplate: s.inputTemplate || '',
requiresApproval: !!s.requiresApproval,
})),
};
@@ -347,12 +366,16 @@ const PipelinesUI = {
const inputEl = document.getElementById('pipeline-execute-input');
if (inputEl) inputEl.value = '';
const workdirEl = document.getElementById('pipeline-execute-workdir');
if (workdirEl) workdirEl.value = '';
Modal.open('pipeline-execute-modal-overlay');
},
async _executeFromModal() {
const pipelineId = document.getElementById('pipeline-execute-id')?.value;
const input = document.getElementById('pipeline-execute-input')?.value.trim();
const workingDirectory = document.getElementById('pipeline-execute-workdir')?.value.trim() || '';
if (!input) {
Toast.warning('O input inicial é obrigatório');
@@ -360,7 +383,7 @@ const PipelinesUI = {
}
try {
await API.pipelines.execute(pipelineId, input);
await API.pipelines.execute(pipelineId, input, workingDirectory);
Modal.close('pipeline-execute-modal-overlay');
App.navigateTo('terminal');
Toast.info('Pipeline iniciado');

View File

@@ -47,8 +47,7 @@ const SchedulesUI = {
<td>${schedule.agentName || '—'}</td>
<td class="schedule-task-cell" title="${schedule.taskDescription || ''}">${schedule.taskDescription || '—'}</td>
<td>
<span title="${cronExpr}">${humanCron}</span>
<small class="font-mono">${cronExpr}</small>
<code class="font-mono">${cronExpr}</code>
</td>
<td>${nextRun}</td>
<td><span class="badge ${statusClass}">${statusLabel}</span></td>
@@ -214,19 +213,84 @@ const SchedulesUI = {
}
container.innerHTML = `
<ul class="activity-list">
${history.slice(0, 20).map((h) => `
<li class="activity-item">
<div class="activity-item-info">
<span class="activity-item-agent">${h.cronExpr}</span>
</div>
<div class="activity-item-meta">
<span class="activity-item-time">${new Date(h.firedAt).toLocaleString('pt-BR')}</span>
</div>
</li>
`).join('')}
</ul>
<div class="table-wrapper">
<table class="table">
<thead>
<tr>
<th scope="col">Agente</th>
<th scope="col">Tarefa</th>
<th scope="col">Status</th>
<th scope="col">Data</th>
<th scope="col">Duração</th>
<th scope="col">Custo</th>
<th scope="col" aria-label="Ações"></th>
</tr>
</thead>
<tbody>
${history.map((exec) => {
const status = SchedulesUI._statusBadge(exec.status);
const date = exec.startedAt ? new Date(exec.startedAt).toLocaleString('pt-BR') : '—';
const duration = SchedulesUI._formatDuration(exec.startedAt, exec.endedAt);
const cost = exec.costUsd || exec.totalCostUsd || 0;
const costStr = cost > 0 ? `$${cost.toFixed(4)}` : '—';
const taskStr = SchedulesUI._escapeHtml(SchedulesUI._truncate(exec.task || '', 60));
return `
<tr>
<td>${SchedulesUI._escapeHtml(exec.agentName || '—')}</td>
<td title="${SchedulesUI._escapeHtml(exec.task || '')}">${taskStr}</td>
<td>${status}</td>
<td>${date}</td>
<td>${duration}</td>
<td class="font-mono">${costStr}</td>
<td>
<button class="btn btn-ghost btn-sm" data-action="view-schedule-exec" data-id="${exec.id}" type="button" title="Ver resultado">
<i data-lucide="eye"></i>
</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
if (window.lucide) lucide.createIcons({ nodes: [container] });
},
_statusBadge(status) {
const map = {
running: ['badge-running', 'Executando'],
completed: ['badge-active', 'Concluído'],
error: ['badge-error', 'Erro'],
};
const [cls, label] = map[status] || ['badge-inactive', status || '—'];
return `<span class="badge ${cls}">${label}</span>`;
},
_formatDuration(start, end) {
if (!start) return '—';
const startMs = new Date(start).getTime();
const endMs = end ? new Date(end).getTime() : Date.now();
const totalSeconds = Math.floor((endMs - startMs) / 1000);
if (totalSeconds < 0) return '—';
if (totalSeconds < 60) return `${totalSeconds}s`;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m ${seconds}s`;
},
_escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
},
_truncate(str, max) {
if (!str || str.length <= max) return str;
return str.slice(0, max) + '…';
},
cronToHuman(expression) {
@@ -262,8 +326,10 @@ const SchedulesUI = {
if (minute.startsWith('*/')) return `A cada ${minute.slice(2)} minutos`;
if (hour.startsWith('*/') && minute === '0') return `A cada ${hour.slice(2)} horas`;
if (minute === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
return `Todo dia às ${hour.padStart(2, '0')}h`;
if (hour !== '*' && day === '*' && month === '*' && weekday === '*') {
const h = hour.padStart(2, '0');
if (minute === '0') return `Todo dia às ${h}h`;
return `Todo dia às ${h}:${minute.padStart(2, '0')}`;
}
return expression;

View File

@@ -4,6 +4,31 @@ const Terminal = {
autoScroll: true,
executionFilter: null,
_processingInterval: null,
_chatSession: null,
enableChat(agentId, agentName, sessionId) {
Terminal._chatSession = { agentId, agentName, sessionId };
const bar = document.getElementById('terminal-input-bar');
const ctx = document.getElementById('terminal-input-context');
const input = document.getElementById('terminal-input');
if (bar) bar.hidden = false;
if (ctx) ctx.textContent = `Conversando com: ${agentName}`;
if (input) { input.value = ''; input.focus(); }
},
disableChat() {
Terminal._chatSession = null;
const bar = document.getElementById('terminal-input-bar');
if (bar) bar.hidden = true;
},
getChatSession() {
return Terminal._chatSession;
},
updateSessionId(sessionId) {
if (Terminal._chatSession) Terminal._chatSession.sessionId = sessionId;
},
addLine(content, type = 'default', executionId = null) {
const time = new Date();

View File

@@ -0,0 +1,257 @@
const WebhooksUI = {
webhooks: [],
agents: [],
pipelines: [],
async load() {
try {
const [webhooks, agents, pipelines] = await Promise.all([
API.webhooks.list(),
API.agents.list(),
API.pipelines.list(),
]);
WebhooksUI.webhooks = Array.isArray(webhooks) ? webhooks : [];
WebhooksUI.agents = Array.isArray(agents) ? agents : [];
WebhooksUI.pipelines = Array.isArray(pipelines) ? pipelines : [];
WebhooksUI.render();
} catch (err) {
Toast.error(`Erro ao carregar webhooks: ${err.message}`);
}
},
filter(searchText) {
const search = (searchText || '').toLowerCase();
const filtered = WebhooksUI.webhooks.filter((w) => {
const name = (w.name || '').toLowerCase();
return !search || name.includes(search);
});
WebhooksUI.render(filtered);
},
render(filteredWebhooks) {
const container = document.getElementById('webhooks-list');
if (!container) return;
const webhooks = filteredWebhooks || WebhooksUI.webhooks;
if (webhooks.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">
<i data-lucide="webhook"></i>
</div>
<h3 class="empty-state-title">Nenhum webhook cadastrado</h3>
<p class="empty-state-desc">Crie webhooks para disparar agentes ou pipelines via HTTP.</p>
</div>
`;
if (window.lucide) lucide.createIcons({ nodes: [container] });
return;
}
container.innerHTML = webhooks.map((w) => WebhooksUI._renderCard(w)).join('');
if (window.lucide) lucide.createIcons({ nodes: [container] });
},
_renderCard(webhook) {
const typeBadge = webhook.targetType === 'pipeline'
? '<span class="badge badge--purple">Pipeline</span>'
: '<span class="badge badge--blue">Agente</span>';
const statusBadge = webhook.active
? '<span class="badge badge-active">Ativo</span>'
: '<span class="badge badge-inactive">Inativo</span>';
const targetName = WebhooksUI._resolveTargetName(webhook);
const hookUrl = `${window.location.origin}/hook/${webhook.token}`;
const lastTrigger = webhook.lastTriggeredAt
? new Date(webhook.lastTriggeredAt).toLocaleString('pt-BR')
: 'Nunca';
return `
<article class="webhook-card">
<div class="webhook-card-header">
<div class="webhook-card-identity">
<span class="webhook-card-name">${WebhooksUI._escapeHtml(webhook.name)}</span>
${typeBadge}
${statusBadge}
</div>
<div class="webhook-card-actions">
<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 btn-danger" data-action="delete-webhook" data-id="${webhook.id}" title="Excluir">
<i data-lucide="trash-2"></i>
</button>
</div>
</div>
<div class="webhook-card-body">
<div class="webhook-card-target">
<span class="webhook-card-label">Destino</span>
<span class="webhook-card-value">${WebhooksUI._escapeHtml(targetName)}</span>
</div>
<div class="webhook-card-url">
<span class="webhook-card-label">URL</span>
<div class="webhook-url-field">
<code class="webhook-url-code">${hookUrl}</code>
<button class="btn btn-ghost btn-sm btn-icon" data-action="copy-webhook-url" data-url="${hookUrl}" title="Copiar URL">
<i data-lucide="copy"></i>
</button>
</div>
</div>
<div class="webhook-card-curl">
<button class="btn btn-ghost btn-sm btn-icon-text" data-action="copy-webhook-curl" data-id="${webhook.id}" title="Copiar comando cURL">
<i data-lucide="terminal"></i>
<span>Copiar cURL</span>
</button>
</div>
<div class="webhook-card-meta">
<span class="webhook-meta-item">
<i data-lucide="activity" style="width:12px;height:12px"></i>
${webhook.triggerCount || 0} disparos
</span>
<span class="webhook-meta-item">
<i data-lucide="clock" style="width:12px;height:12px"></i>
Último: ${lastTrigger}
</span>
</div>
</div>
</article>
`;
},
_resolveTargetName(webhook) {
if (webhook.targetType === 'agent') {
const agent = WebhooksUI.agents.find((a) => a.id === webhook.targetId);
return agent ? (agent.agent_name || agent.name) : webhook.targetId;
}
const pl = WebhooksUI.pipelines.find((p) => p.id === webhook.targetId);
return pl ? pl.name : webhook.targetId;
},
openCreateModal() {
const titleEl = document.getElementById('webhook-modal-title');
if (titleEl) titleEl.textContent = 'Novo Webhook';
const nameEl = document.getElementById('webhook-name');
if (nameEl) nameEl.value = '';
const typeEl = document.getElementById('webhook-target-type');
if (typeEl) {
typeEl.value = 'agent';
WebhooksUI._updateTargetSelect('agent');
}
Modal.open('webhook-modal-overlay');
},
_updateTargetSelect(targetType) {
const selectEl = document.getElementById('webhook-target-id');
if (!selectEl) return;
if (targetType === 'agent') {
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
WebhooksUI.agents.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`).join('');
} else {
selectEl.innerHTML = '<option value="">Selecionar pipeline...</option>' +
WebhooksUI.pipelines.map((p) => `<option value="${p.id}">${p.name}</option>`).join('');
}
},
async save() {
const name = document.getElementById('webhook-name')?.value.trim();
const targetType = document.getElementById('webhook-target-type')?.value;
const targetId = document.getElementById('webhook-target-id')?.value;
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');
await WebhooksUI.load();
} catch (err) {
Toast.error(`Erro ao criar webhook: ${err.message}`);
}
},
async toggleActive(webhookId) {
const webhook = WebhooksUI.webhooks.find((w) => w.id === webhookId);
if (!webhook) return;
try {
await API.webhooks.update(webhookId, { active: !webhook.active });
Toast.success(webhook.active ? 'Webhook desativado' : 'Webhook ativado');
await WebhooksUI.load();
} catch (err) {
Toast.error(`Erro ao atualizar webhook: ${err.message}`);
}
},
async delete(webhookId) {
const confirmed = await Modal.confirm(
'Excluir webhook',
'Tem certeza que deseja excluir este webhook? Integrações que usam esta URL deixarão de funcionar.'
);
if (!confirmed) return;
try {
await API.webhooks.delete(webhookId);
Toast.success('Webhook excluído');
await WebhooksUI.load();
} catch (err) {
Toast.error(`Erro ao excluir webhook: ${err.message}`);
}
},
async copyUrl(url) {
try {
await navigator.clipboard.writeText(url);
Toast.success('URL copiada');
} catch {
Toast.error('Não foi possível copiar a URL');
}
},
async copyCurl(webhookId) {
const webhook = WebhooksUI.webhooks.find((w) => w.id === webhookId);
if (!webhook) return;
const hookUrl = `${window.location.origin}/hook/${webhook.token}`;
const targetName = WebhooksUI._resolveTargetName(webhook);
let payload;
if (webhook.targetType === 'pipeline') {
payload = JSON.stringify({
input: 'Texto de entrada para o pipeline',
workingDirectory: '/caminho/do/projeto (opcional)',
}, null, 2);
} else {
payload = JSON.stringify({
task: 'Descreva a tarefa a ser executada',
instructions: 'Instruções adicionais (opcional)',
}, null, 2);
}
const curl = `curl -X POST '${hookUrl}' \\\n -H 'Content-Type: application/json' \\\n -d '${payload}'`;
try {
await navigator.clipboard.writeText(curl);
Toast.success('cURL copiado');
} catch {
Toast.error('Não foi possível copiar o cURL');
}
},
_escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
};
window.WebhooksUI = WebhooksUI;