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:
Frederico Castro
2026-02-26 01:36:28 -03:00
parent 2f7a9d4c56
commit 4b6c876f36
13 changed files with 1536 additions and 398 deletions

View File

@@ -3295,3 +3295,429 @@ tbody tr:hover td {
align-items: center;
gap: 4px;
}
.toolbar-filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
flex: 1;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.badge-agent {
background-color: rgba(99, 102, 241, 0.15);
color: var(--accent);
}
.badge-pipeline {
background-color: rgba(139, 92, 246, 0.15);
color: #8b5cf6;
}
.history-card {
background-color: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: 12px;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 10px;
transition: all 0.2s;
animation: fadeInUp 0.2s ease both;
margin-bottom: 8px;
}
.history-card:hover {
border-color: var(--border-secondary);
background-color: var(--bg-card-hover);
}
.history-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.history-card-identity {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.history-card-name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-card-status {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.history-card-date {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
}
.history-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.history-card-task {
font-size: 13px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.history-card-duration {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
white-space: nowrap;
}
.history-card-duration i {
width: 12px;
height: 12px;
flex-shrink: 0;
}
.history-card-actions {
display: flex;
align-items: center;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-primary);
}
.execution-result {
background-color: #08080e;
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #c8c8d8;
white-space: pre-wrap;
word-break: break-word;
overflow-y: auto;
max-height: 400px;
line-height: 1.7;
}
.execution-result--error {
border-color: rgba(239, 68, 68, 0.3);
color: var(--error);
}
.execution-result--prompt {
color: var(--text-secondary);
background-color: var(--bg-tertiary);
max-height: 200px;
}
.execution-detail-meta {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--border-primary);
border-radius: 10px;
overflow: hidden;
margin-bottom: 20px;
}
.execution-detail-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border-primary);
}
.execution-detail-row:last-child {
border-bottom: none;
}
.execution-detail-label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.4px;
min-width: 80px;
flex-shrink: 0;
}
.execution-detail-value {
font-size: 14px;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.execution-detail-section {
margin-bottom: 20px;
}
.execution-detail-section:last-child {
margin-bottom: 0;
}
.execution-detail-section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.execution-detail-task {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
background-color: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 12px 16px;
}
.pipeline-timeline {
display: flex;
flex-direction: column;
gap: 0;
}
.pipeline-step-item {
display: flex;
gap: 16px;
align-items: flex-start;
}
.pipeline-step-connector {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
padding-top: 2px;
}
.pipeline-step-node {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: var(--bg-tertiary);
border: 2px solid var(--border-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
flex-shrink: 0;
z-index: 1;
}
.pipeline-step-node--completed {
background-color: rgba(34, 197, 94, 0.15);
border-color: var(--success);
color: var(--success);
}
.pipeline-step-node--error {
background-color: rgba(239, 68, 68, 0.15);
border-color: var(--error);
color: var(--error);
}
.pipeline-step-connector-line {
width: 2px;
flex: 1;
min-height: 24px;
background-color: var(--border-secondary);
margin: 4px 0;
}
.pipeline-step-detail {
flex: 1;
min-width: 0;
background-color: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.pipeline-step-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pipeline-step-identity {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.pipeline-step-agent {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pipeline-step-duration {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
white-space: nowrap;
}
.pipeline-step-duration i {
width: 12px;
height: 12px;
}
.pipeline-step-prompt {
display: flex;
flex-direction: column;
gap: 8px;
}
.pipeline-step-prompt-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
padding: 0;
font-family: 'Inter', sans-serif;
transition: color 0.2s;
}
.pipeline-step-prompt-toggle:hover {
color: var(--accent);
}
.pipeline-step-prompt-toggle[aria-expanded="true"] i {
transform: rotate(90deg);
}
.pipeline-step-prompt-toggle i {
width: 14px;
height: 14px;
transition: transform 0.2s;
}
.pipeline-step-prompt-body {
margin-top: 4px;
}
.pipeline-step-result {
display: flex;
flex-direction: column;
gap: 6px;
}
.pipeline-step-result-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
gap: 12px;
}
.pagination-info {
font-size: 13px;
color: var(--text-muted);
}
.pagination-controls {
display: flex;
align-items: center;
gap: 10px;
}
.pagination-page {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
}
.input {
width: 100%;
padding: 8px 12px;
background-color: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
font-family: 'Inter', sans-serif;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.input::placeholder {
color: var(--text-muted);
}
.input--sm {
padding: 5px 10px;
font-size: 12px;
}

View File

@@ -59,6 +59,12 @@
<span>Terminal</span>
</a>
</li>
<li class="sidebar-nav-item">
<a href="#" class="sidebar-nav-link" data-section="history">
<i data-lucide="history"></i>
<span>Histórico</span>
</a>
</li>
<li class="sidebar-nav-item">
<a href="#" class="sidebar-nav-link" data-section="settings">
<i data-lucide="settings"></i>
@@ -387,6 +393,36 @@
</div>
</section>
<section id="history" class="section" aria-label="Histórico de Execuções" hidden>
<div class="section-toolbar">
<div class="toolbar-filters">
<div class="search-field">
<i data-lucide="search"></i>
<input type="text" placeholder="Buscar..." id="history-search" aria-label="Buscar no histórico" />
</div>
<select class="select" id="history-filter-type" aria-label="Filtrar por tipo">
<option value="">Todos os tipos</option>
<option value="agent">Agentes</option>
<option value="pipeline">Pipelines</option>
</select>
<select class="select" id="history-filter-status" aria-label="Filtrar por status">
<option value="">Todos os status</option>
<option value="completed">Concluído</option>
<option value="error">Erro</option>
<option value="running">Em execução</option>
</select>
</div>
<div class="toolbar-actions">
<button class="btn btn-ghost btn-sm btn-danger" id="history-clear-btn" type="button">
<i data-lucide="trash-2"></i>
Limpar Histórico
</button>
</div>
</div>
<div id="history-list"></div>
<div id="history-pagination"></div>
</section>
<section id="settings" class="section" aria-label="Configurações" hidden>
<div class="settings-grid">
<div class="card">
@@ -864,6 +900,19 @@
</div>
</div>
<div class="modal-overlay" id="execution-detail-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="execution-detail-title" hidden>
<div class="modal modal-lg">
<div class="modal-header">
<h2 class="modal-title" id="execution-detail-title">Detalhe da Execução</h2>
<button class="modal-close" data-modal-close="execution-detail-modal-overlay" aria-label="Fechar" type="button">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body" id="execution-detail-content">
</div>
</div>
</div>
<div class="modal-overlay" id="confirm-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title" hidden>
<div class="modal modal--sm">
<div class="modal-header">
@@ -954,6 +1003,7 @@
<script src="js/components/schedules.js"></script>
<script src="js/components/pipelines.js"></script>
<script src="js/components/settings.js"></script>
<script src="js/components/history.js"></script>
<script src="js/app.js"></script>
<script>
lucide.createIcons();

View File

@@ -82,6 +82,13 @@ const API = {
executions: {
recent(limit = 20) { return API.request('GET', `/executions/recent?limit=${limit}`); },
history(params = {}) {
const qs = new URLSearchParams(params).toString();
return API.request('GET', `/executions/history${qs ? '?' + qs : ''}`);
},
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'); },
},
};

View File

@@ -12,6 +12,7 @@ const App = {
schedules: 'Agendamentos',
pipelines: 'Pipelines',
terminal: 'Terminal',
history: 'Histórico',
settings: 'Configurações',
},
@@ -70,6 +71,7 @@ const App = {
case 'tasks': await TasksUI.load(); break;
case 'schedules': await SchedulesUI.load(); break;
case 'pipelines': await PipelinesUI.load(); break;
case 'history': await HistoryUI.load(); break;
case 'settings': await SettingsUI.load(); break;
}
} catch (err) {
@@ -363,6 +365,32 @@ const App = {
PipelinesUI.filter(document.getElementById('pipelines-search')?.value);
});
on('history-search', 'input', () => {
HistoryUI.filter(
document.getElementById('history-search')?.value,
document.getElementById('history-filter-type')?.value,
document.getElementById('history-filter-status')?.value
);
});
on('history-filter-type', 'change', () => {
HistoryUI.filter(
document.getElementById('history-search')?.value,
document.getElementById('history-filter-type')?.value,
document.getElementById('history-filter-status')?.value
);
});
on('history-filter-status', 'change', () => {
HistoryUI.filter(
document.getElementById('history-search')?.value,
document.getElementById('history-filter-type')?.value,
document.getElementById('history-filter-status')?.value
);
});
on('history-clear-btn', 'click', () => HistoryUI.clearHistory());
document.getElementById('agents-grid')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
@@ -419,6 +447,16 @@ const App = {
}
});
document.getElementById('history-list')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const { action, id } = btn.dataset;
switch (action) {
case 'view-execution': HistoryUI.viewDetail(id); break;
case 'delete-execution': HistoryUI.deleteExecution(id); break;
}
});
document.getElementById('pipeline-steps-container')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-step-action]');
if (!btn) return;

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
};
window.HistoryUI = HistoryUI;