Histórico persistente de execuções com visualização detalhada
- Novo executionsStore em db.js com cache in-memory e escrita debounced - Camada de cache (src/cache/index.js) com TTL e suporte opcional a Redis - Persistência de execuções de agentes e pipelines com metadados completos - Pipeline grava cada etapa com prompt, resultado, timestamps e status - 4 endpoints REST: listagem paginada com filtros, detalhe, exclusão individual e limpeza total - Componente frontend (history.js) com cards, filtros, paginação e modal de detalhe - Timeline visual para pipelines com prompts colapsáveis por etapa - Correção do executor: --max-turns em vez de --max-tokens, --permission-mode bypassPermissions - Refatoração do scheduler com persistência melhorada e graceful shutdown
This commit is contained in:
421
public/js/components/history.js
Normal file
421
public/js/components/history.js
Normal file
@@ -0,0 +1,421 @@
|
||||
const HistoryUI = {
|
||||
executions: [],
|
||||
total: 0,
|
||||
page: 0,
|
||||
pageSize: 20,
|
||||
_currentSearch: '',
|
||||
_currentType: '',
|
||||
_currentStatus: '',
|
||||
|
||||
async load() {
|
||||
const params = { limit: HistoryUI.pageSize, offset: HistoryUI.page * HistoryUI.pageSize };
|
||||
if (HistoryUI._currentType) params.type = HistoryUI._currentType;
|
||||
if (HistoryUI._currentStatus) params.status = HistoryUI._currentStatus;
|
||||
if (HistoryUI._currentSearch) params.search = HistoryUI._currentSearch;
|
||||
|
||||
try {
|
||||
const data = await API.executions.history(params);
|
||||
HistoryUI.executions = data.items || [];
|
||||
HistoryUI.total = data.total || 0;
|
||||
HistoryUI.render();
|
||||
HistoryUI._renderPagination();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao carregar histórico: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
const container = document.getElementById('history-list');
|
||||
if (!container) return;
|
||||
|
||||
if (HistoryUI.executions.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">
|
||||
<i data-lucide="history"></i>
|
||||
</div>
|
||||
<h3 class="empty-state-title">Nenhuma execução encontrada</h3>
|
||||
<p class="empty-state-text">O histórico de execuções aparecerá aqui.</p>
|
||||
</div>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = HistoryUI.executions.map((exec) => HistoryUI._renderCard(exec)).join('');
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
},
|
||||
|
||||
_renderCard(exec) {
|
||||
const typeBadge = exec.type === 'pipeline'
|
||||
? '<span class="badge badge-pipeline">Pipeline</span>'
|
||||
: '<span class="badge badge-agent">Agente</span>';
|
||||
|
||||
const statusBadge = HistoryUI._statusBadge(exec.status);
|
||||
const name = exec.type === 'pipeline'
|
||||
? (exec.pipelineName || 'Pipeline')
|
||||
: (exec.agentName || 'Agente');
|
||||
const task = exec.type === 'pipeline'
|
||||
? (exec.input || '')
|
||||
: (exec.task || '');
|
||||
const date = HistoryUI._formatDate(exec.startedAt);
|
||||
const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt);
|
||||
|
||||
return `
|
||||
<article class="history-card">
|
||||
<div class="history-card-header">
|
||||
<div class="history-card-identity">
|
||||
${typeBadge}
|
||||
<span class="history-card-name">${HistoryUI._escapeHtml(name)}</span>
|
||||
</div>
|
||||
<div class="history-card-status">
|
||||
${statusBadge}
|
||||
<span class="history-card-date">${date}</span>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
<div class="history-card-actions">
|
||||
<button class="btn btn-ghost btn-sm" data-action="view-execution" data-id="${exec.id}" type="button">
|
||||
<i data-lucide="eye"></i>
|
||||
Ver detalhes
|
||||
</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>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
},
|
||||
|
||||
_renderPagination() {
|
||||
const container = document.getElementById('history-pagination');
|
||||
if (!container) return;
|
||||
|
||||
const totalPages = Math.ceil(HistoryUI.total / HistoryUI.pageSize);
|
||||
if (totalPages <= 1) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPrev = HistoryUI.page > 0;
|
||||
const hasNext = HistoryUI.page < totalPages - 1;
|
||||
const start = HistoryUI.page * HistoryUI.pageSize + 1;
|
||||
const end = Math.min((HistoryUI.page + 1) * HistoryUI.pageSize, HistoryUI.total);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="pagination">
|
||||
<span class="pagination-info">${start}–${end} de ${HistoryUI.total}</span>
|
||||
<div class="pagination-controls">
|
||||
<button class="btn btn-ghost btn-sm" id="history-prev-btn" type="button" ${hasPrev ? '' : 'disabled'}>
|
||||
<i data-lucide="chevron-left"></i>
|
||||
Anterior
|
||||
</button>
|
||||
<span class="pagination-page">Página ${HistoryUI.page + 1} de ${totalPages}</span>
|
||||
<button class="btn btn-ghost btn-sm" id="history-next-btn" type="button" ${hasNext ? '' : 'disabled'}>
|
||||
Próxima
|
||||
<i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
|
||||
document.getElementById('history-prev-btn')?.addEventListener('click', () => {
|
||||
HistoryUI.page--;
|
||||
HistoryUI.load();
|
||||
});
|
||||
|
||||
document.getElementById('history-next-btn')?.addEventListener('click', () => {
|
||||
HistoryUI.page++;
|
||||
HistoryUI.load();
|
||||
});
|
||||
},
|
||||
|
||||
filter(search, type, status) {
|
||||
HistoryUI._currentSearch = search || '';
|
||||
HistoryUI._currentType = type || '';
|
||||
HistoryUI._currentStatus = status || '';
|
||||
HistoryUI.page = 0;
|
||||
HistoryUI.load();
|
||||
},
|
||||
|
||||
async viewDetail(id) {
|
||||
try {
|
||||
const exec = await API.executions.get(id);
|
||||
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;
|
||||
|
||||
const name = exec.type === 'pipeline'
|
||||
? (exec.pipelineName || 'Pipeline')
|
||||
: (exec.agentName || 'Agente');
|
||||
|
||||
title.textContent = name;
|
||||
content.innerHTML = exec.type === 'pipeline'
|
||||
? HistoryUI._renderPipelineDetail(exec)
|
||||
: HistoryUI._renderAgentDetail(exec);
|
||||
|
||||
Modal.open('execution-detail-modal-overlay');
|
||||
if (window.lucide) lucide.createIcons({ nodes: [content] });
|
||||
|
||||
content.querySelectorAll('.pipeline-step-prompt-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const stepCard = btn.closest('.pipeline-step-detail');
|
||||
const promptBody = stepCard?.querySelector('.pipeline-step-prompt-body');
|
||||
if (!promptBody) return;
|
||||
const isHidden = promptBody.hidden;
|
||||
promptBody.hidden = !isHidden;
|
||||
btn.setAttribute('aria-expanded', String(isHidden));
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao carregar execução: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
_renderAgentDetail(exec) {
|
||||
const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt);
|
||||
const startDate = HistoryUI._formatDate(exec.startedAt);
|
||||
const endDate = exec.endedAt ? HistoryUI._formatDate(exec.endedAt) : '—';
|
||||
|
||||
const resultBlock = exec.result
|
||||
? `<div class="execution-result" role="region" aria-label="Resultado da execução">${HistoryUI._escapeHtml(exec.result)}</div>`
|
||||
: '';
|
||||
|
||||
const errorBlock = exec.error
|
||||
? `<div class="execution-result execution-result--error" role="alert">${HistoryUI._escapeHtml(exec.error)}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="execution-detail-meta">
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Agente</span>
|
||||
<span class="execution-detail-value">${HistoryUI._escapeHtml(exec.agentName || exec.agentId || '—')}</span>
|
||||
</div>
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Status</span>
|
||||
<span class="execution-detail-value">${HistoryUI._statusBadge(exec.status)}</span>
|
||||
</div>
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Início</span>
|
||||
<span class="execution-detail-value">${startDate}</span>
|
||||
</div>
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Fim</span>
|
||||
<span class="execution-detail-value">${endDate}</span>
|
||||
</div>
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Duração</span>
|
||||
<span class="execution-detail-value">${duration}</span>
|
||||
</div>
|
||||
${exec.exitCode !== undefined && exec.exitCode !== null ? `
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Exit Code</span>
|
||||
<span class="execution-detail-value font-mono">${exec.exitCode}</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
${exec.task ? `
|
||||
<div class="execution-detail-section">
|
||||
<h3 class="execution-detail-section-title">Tarefa</h3>
|
||||
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.task)}</p>
|
||||
</div>` : ''}
|
||||
${resultBlock ? `
|
||||
<div class="execution-detail-section">
|
||||
<h3 class="execution-detail-section-title">Resultado</h3>
|
||||
${resultBlock}
|
||||
</div>` : ''}
|
||||
${errorBlock ? `
|
||||
<div class="execution-detail-section">
|
||||
<h3 class="execution-detail-section-title">Erro</h3>
|
||||
${errorBlock}
|
||||
</div>` : ''}
|
||||
`;
|
||||
},
|
||||
|
||||
_renderPipelineDetail(exec) {
|
||||
const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt);
|
||||
const startDate = HistoryUI._formatDate(exec.startedAt);
|
||||
const endDate = exec.endedAt ? HistoryUI._formatDate(exec.endedAt) : '—';
|
||||
const steps = Array.isArray(exec.steps) ? exec.steps : [];
|
||||
|
||||
const stepsHtml = steps.map((step, index) => {
|
||||
const stepDuration = HistoryUI._formatDuration(step.startedAt, step.endedAt);
|
||||
const isLast = index === steps.length - 1;
|
||||
|
||||
return `
|
||||
<div class="pipeline-step-item">
|
||||
<div class="pipeline-step-connector">
|
||||
<div class="pipeline-step-node ${step.status === 'error' ? 'pipeline-step-node--error' : step.status === 'completed' ? 'pipeline-step-node--completed' : ''}">
|
||||
<span>${step.stepIndex + 1}</span>
|
||||
</div>
|
||||
${isLast ? '' : '<div class="pipeline-step-connector-line" aria-hidden="true"></div>'}
|
||||
</div>
|
||||
<div class="pipeline-step-detail">
|
||||
<div class="pipeline-step-header">
|
||||
<div class="pipeline-step-identity">
|
||||
<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>
|
||||
</div>
|
||||
${step.prompt ? `
|
||||
<div class="pipeline-step-prompt">
|
||||
<button class="pipeline-step-prompt-toggle" type="button" aria-expanded="false">
|
||||
<i data-lucide="chevron-right"></i>
|
||||
Prompt utilizado
|
||||
</button>
|
||||
<div class="pipeline-step-prompt-body" hidden>
|
||||
<div class="execution-result execution-result--prompt">${HistoryUI._escapeHtml(step.prompt)}</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
${step.result ? `
|
||||
<div class="pipeline-step-result">
|
||||
<span class="pipeline-step-result-label">Resultado</span>
|
||||
<div class="execution-result">${HistoryUI._escapeHtml(step.result)}</div>
|
||||
</div>` : ''}
|
||||
${step.status === 'error' ? `
|
||||
<div class="execution-result execution-result--error">Passo falhou.</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="execution-detail-meta">
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Pipeline</span>
|
||||
<span class="execution-detail-value">${HistoryUI._escapeHtml(exec.pipelineName || exec.pipelineId || '—')}</span>
|
||||
</div>
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Status</span>
|
||||
<span class="execution-detail-value">${HistoryUI._statusBadge(exec.status)}</span>
|
||||
</div>
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Início</span>
|
||||
<span class="execution-detail-value">${startDate}</span>
|
||||
</div>
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Fim</span>
|
||||
<span class="execution-detail-value">${endDate}</span>
|
||||
</div>
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Duração</span>
|
||||
<span class="execution-detail-value">${duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
${exec.input ? `
|
||||
<div class="execution-detail-section">
|
||||
<h3 class="execution-detail-section-title">Input Inicial</h3>
|
||||
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.input)}</p>
|
||||
</div>` : ''}
|
||||
${steps.length > 0 ? `
|
||||
<div class="execution-detail-section">
|
||||
<h3 class="execution-detail-section-title">Passos do Pipeline</h3>
|
||||
<div class="pipeline-timeline">
|
||||
${stepsHtml}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
${exec.error ? `
|
||||
<div class="execution-detail-section">
|
||||
<h3 class="execution-detail-section-title">Erro</h3>
|
||||
<div class="execution-result execution-result--error">${HistoryUI._escapeHtml(exec.error)}</div>
|
||||
</div>` : ''}
|
||||
`;
|
||||
},
|
||||
|
||||
async deleteExecution(id) {
|
||||
const confirmed = await Modal.confirm(
|
||||
'Excluir execução',
|
||||
'Tem certeza que deseja excluir esta execução do histórico? Esta ação não pode ser desfeita.'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await API.executions.delete(id);
|
||||
Toast.success('Execução excluída do histórico');
|
||||
await HistoryUI.load();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao excluir execução: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async clearHistory() {
|
||||
const confirmed = await Modal.confirm(
|
||||
'Limpar histórico',
|
||||
'Tem certeza que deseja excluir todo o histórico de execuções? Esta ação não pode ser desfeita.'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await API.executions.clearAll();
|
||||
Toast.success('Histórico limpo com sucesso');
|
||||
HistoryUI.page = 0;
|
||||
await HistoryUI.load();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao limpar histórico: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
_statusBadge(status) {
|
||||
const map = {
|
||||
running: ['badge-running', 'Em execução'],
|
||||
completed: ['badge-active', 'Concluído'],
|
||||
error: ['badge-error', 'Erro'],
|
||||
};
|
||||
const [cls, label] = map[status] || ['badge-inactive', status || 'Desconhecido'];
|
||||
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`;
|
||||
},
|
||||
|
||||
_formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
const date = new Date(iso);
|
||||
return date.toLocaleString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
};
|
||||
|
||||
window.HistoryUI = HistoryUI;
|
||||
Reference in New Issue
Block a user