Implementação completa de funcionalidades pendentes
- Settings persistentes (modelo padrão, workdir, max concurrent) - Import/export de agentes via JSON - Agendamentos persistentes com restore no startup - Edição de agendamentos e tarefas existentes - Filtros e busca em todas as seções - Isolamento de WebSocket por clientId - Autenticação via AUTH_TOKEN e CORS configurável - Graceful shutdown com cancelamento de execuções - Correção: --max-tokens removido (flag inválida do CLI) - Correção: pipeline agora verifica exit code e propaga erros - Correção: streaming de output em pipelines via WebSocket - Permission mode bypassPermissions como padrão - Página de configurações do sistema - Contagem diária de execuções no dashboard - Histórico de execuções recentes
This commit is contained in:
@@ -2504,6 +2504,24 @@ tbody tr:hover td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#schedules-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.schedule-task-cell {
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-actions-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.terminal-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -91,6 +91,10 @@
|
||||
<i data-lucide="server"></i>
|
||||
<span>Sistema</span>
|
||||
</button>
|
||||
<button class="btn btn--ghost btn--icon-text" id="import-agent-btn" type="button">
|
||||
<i data-lucide="upload"></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>
|
||||
@@ -288,6 +292,14 @@
|
||||
<div class="card">
|
||||
<div class="table-wrapper">
|
||||
<table class="table" id="schedules-table">
|
||||
<colgroup>
|
||||
<col style="width: 15%">
|
||||
<col style="width: 30%">
|
||||
<col style="width: 18%">
|
||||
<col style="width: 17%">
|
||||
<col style="width: 8%">
|
||||
<col style="width: 12%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Agente</th>
|
||||
@@ -311,6 +323,15 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 1rem;">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Histórico de Disparos</h2>
|
||||
</div>
|
||||
<div class="card-body" id="schedules-history">
|
||||
<p class="empty-state-desc">Nenhum disparo registrado</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pipelines" class="section" aria-label="Pipelines" hidden>
|
||||
@@ -534,6 +555,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group form-group--grow">
|
||||
<label class="form-label" for="agent-allowed-tools">Ferramentas Permitidas</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
id="agent-allowed-tools"
|
||||
name="allowedTools"
|
||||
placeholder="Bash,Read,Write,Glob,Grep"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="form-hint">Lista separada por vírgula. Vazio = todas permitidas.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="agent-max-turns">Max Turns</label>
|
||||
<input
|
||||
type="number"
|
||||
class="input"
|
||||
id="agent-max-turns"
|
||||
name="maxTurns"
|
||||
min="0"
|
||||
max="100"
|
||||
value="0"
|
||||
/>
|
||||
<p class="form-hint">Limite de turnos agênticos. 0 = sem limite.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="agent-permission-mode">Permission Mode</label>
|
||||
<select class="select" id="agent-permission-mode" name="permissionMode">
|
||||
<option value="">Padrão</option>
|
||||
<option value="default">default</option>
|
||||
<option value="plan">plan</option>
|
||||
<option value="acceptEdits">acceptEdits</option>
|
||||
<option value="bypassPermissions">bypassPermissions</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="agent-tags-input">Tags</label>
|
||||
<div class="tags-input-wrapper" id="agent-tags-wrapper">
|
||||
@@ -852,6 +911,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="import-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="import-modal-title" hidden>
|
||||
<div class="modal modal--md">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="import-modal-title">Importar Agente</h2>
|
||||
<button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="import-modal-overlay">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="import-json-content">JSON do Agente</label>
|
||||
<textarea
|
||||
class="textarea textarea--code"
|
||||
id="import-json-content"
|
||||
rows="10"
|
||||
placeholder="Cole aqui o JSON exportado do agente..."
|
||||
></textarea>
|
||||
<p class="form-hint">Cole o JSON gerado pela função de exportar agente.</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<span>Importar</span>
|
||||
</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>
|
||||
@@ -864,6 +953,7 @@
|
||||
<script src="js/components/tasks.js"></script>
|
||||
<script src="js/components/schedules.js"></script>
|
||||
<script src="js/components/pipelines.js"></script>
|
||||
<script src="js/components/settings.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
const API = {
|
||||
baseUrl: '/api',
|
||||
clientId: sessionStorage.getItem('clientId') || (() => {
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
|
||||
sessionStorage.setItem('clientId', id);
|
||||
return id;
|
||||
})(),
|
||||
|
||||
async request(method, path, body = null) {
|
||||
const options = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-Id': API.clientId,
|
||||
},
|
||||
};
|
||||
|
||||
if (body !== null) {
|
||||
@@ -33,6 +41,7 @@ const API = {
|
||||
execute(id, task, instructions) { return API.request('POST', `/agents/${id}/execute`, { task, instructions }); },
|
||||
cancel(id, executionId) { return API.request('POST', `/agents/${id}/cancel/${executionId}`); },
|
||||
export(id) { return API.request('GET', `/agents/${id}/export`); },
|
||||
import(data) { return API.request('POST', '/agents/import', data); },
|
||||
},
|
||||
|
||||
tasks: {
|
||||
@@ -45,7 +54,9 @@ const API = {
|
||||
schedules: {
|
||||
list() { return API.request('GET', '/schedules'); },
|
||||
create(data) { return API.request('POST', '/schedules', data); },
|
||||
update(id, data) { return API.request('PUT', `/schedules/${id}`, data); },
|
||||
delete(taskId) { return API.request('DELETE', `/schedules/${taskId}`); },
|
||||
history() { return API.request('GET', '/schedules/history'); },
|
||||
},
|
||||
|
||||
pipelines: {
|
||||
@@ -60,8 +71,18 @@ const API = {
|
||||
|
||||
system: {
|
||||
status() { return API.request('GET', '/system/status'); },
|
||||
info() { return API.request('GET', '/system/info'); },
|
||||
activeExecutions() { return API.request('GET', '/executions/active'); },
|
||||
},
|
||||
|
||||
settings: {
|
||||
get() { return API.request('GET', '/settings'); },
|
||||
save(data) { return API.request('PUT', '/settings', data); },
|
||||
},
|
||||
|
||||
executions: {
|
||||
recent(limit = 20) { return API.request('GET', `/executions/recent?limit=${limit}`); },
|
||||
},
|
||||
};
|
||||
|
||||
window.API = API;
|
||||
|
||||
@@ -70,6 +70,7 @@ const App = {
|
||||
case 'tasks': await TasksUI.load(); break;
|
||||
case 'schedules': await SchedulesUI.load(); break;
|
||||
case 'pipelines': await PipelinesUI.load(); break;
|
||||
case 'settings': await SettingsUI.load(); break;
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao carregar seção: ${err.message}`);
|
||||
@@ -78,7 +79,8 @@ const App = {
|
||||
|
||||
setupWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${protocol}//${window.location.host}`;
|
||||
const clientId = API.clientId;
|
||||
const url = `${protocol}//${window.location.host}?clientId=${clientId}`;
|
||||
|
||||
try {
|
||||
App.ws = new WebSocket(url);
|
||||
@@ -126,11 +128,14 @@ const App = {
|
||||
|
||||
handleWsMessage(data) {
|
||||
switch (data.type) {
|
||||
case 'connected':
|
||||
break;
|
||||
|
||||
case 'execution_output': {
|
||||
Terminal.stopProcessing();
|
||||
const content = data.data?.content || '';
|
||||
if (content) {
|
||||
Terminal.addLine(content, 'default');
|
||||
Terminal.addLine(content, 'default', data.executionId);
|
||||
}
|
||||
App._updateActiveBadge();
|
||||
break;
|
||||
@@ -140,12 +145,12 @@ const App = {
|
||||
Terminal.stopProcessing();
|
||||
const result = data.data?.result || '';
|
||||
if (result) {
|
||||
Terminal.addLine(result, 'success');
|
||||
Terminal.addLine(result, 'success', data.executionId);
|
||||
} else {
|
||||
Terminal.addLine('Execução concluída (sem resultado textual).', 'info');
|
||||
Terminal.addLine('Execução concluída (sem resultado textual).', 'info', data.executionId);
|
||||
}
|
||||
if (data.data?.stderr) {
|
||||
Terminal.addLine(data.data.stderr, 'error');
|
||||
Terminal.addLine(data.data.stderr, 'error', data.executionId);
|
||||
}
|
||||
Toast.success('Execução concluída');
|
||||
App.refreshCurrentSection();
|
||||
@@ -155,11 +160,20 @@ const App = {
|
||||
|
||||
case 'execution_error':
|
||||
Terminal.stopProcessing();
|
||||
Terminal.addLine(data.data?.error || 'Erro na execução', 'error');
|
||||
Terminal.addLine(data.data?.error || 'Erro na execução', 'error', data.executionId);
|
||||
Toast.error(`Erro na execução: ${data.data?.error || 'desconhecido'}`);
|
||||
App._updateActiveBadge();
|
||||
break;
|
||||
|
||||
case 'pipeline_step_output': {
|
||||
Terminal.stopProcessing();
|
||||
const stepContent = data.data?.content || '';
|
||||
if (stepContent) {
|
||||
Terminal.addLine(stepContent, 'default', data.executionId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pipeline_step_start':
|
||||
Terminal.stopProcessing();
|
||||
Terminal.addLine(`Pipeline passo ${data.stepIndex + 1}/${data.totalSteps}: Executando agente "${data.agentName}"...`, 'system');
|
||||
@@ -238,6 +252,7 @@ const App = {
|
||||
|
||||
on('new-agent-btn', 'click', () => AgentsUI.openCreateModal());
|
||||
on('agents-empty-new-btn', 'click', () => AgentsUI.openCreateModal());
|
||||
on('import-agent-btn', 'click', () => AgentsUI.openImportModal());
|
||||
|
||||
on('agent-form-submit', 'click', (e) => {
|
||||
e.preventDefault();
|
||||
@@ -249,6 +264,8 @@ const App = {
|
||||
AgentsUI.save();
|
||||
});
|
||||
|
||||
on('import-confirm-btn', 'click', () => AgentsUI.importAgent());
|
||||
|
||||
on('execute-form-submit', 'click', (e) => {
|
||||
e.preventDefault();
|
||||
App._handleExecute();
|
||||
@@ -297,7 +314,53 @@ const App = {
|
||||
|
||||
on('settings-form', 'submit', (e) => {
|
||||
e.preventDefault();
|
||||
Toast.info('Configurações salvas');
|
||||
SettingsUI.save();
|
||||
});
|
||||
|
||||
on('agents-search', 'input', () => {
|
||||
AgentsUI.filter(
|
||||
document.getElementById('agents-search')?.value,
|
||||
document.getElementById('agents-filter-status')?.value
|
||||
);
|
||||
});
|
||||
|
||||
on('agents-filter-status', 'change', () => {
|
||||
AgentsUI.filter(
|
||||
document.getElementById('agents-search')?.value,
|
||||
document.getElementById('agents-filter-status')?.value
|
||||
);
|
||||
});
|
||||
|
||||
on('tasks-search', 'input', () => {
|
||||
TasksUI.filter(
|
||||
document.getElementById('tasks-search')?.value,
|
||||
document.getElementById('tasks-filter-category')?.value
|
||||
);
|
||||
});
|
||||
|
||||
on('tasks-filter-category', 'change', () => {
|
||||
TasksUI.filter(
|
||||
document.getElementById('tasks-search')?.value,
|
||||
document.getElementById('tasks-filter-category')?.value
|
||||
);
|
||||
});
|
||||
|
||||
on('schedules-search', 'input', () => {
|
||||
SchedulesUI.filter(
|
||||
document.getElementById('schedules-search')?.value,
|
||||
document.getElementById('schedules-filter-status')?.value
|
||||
);
|
||||
});
|
||||
|
||||
on('schedules-filter-status', 'change', () => {
|
||||
SchedulesUI.filter(
|
||||
document.getElementById('schedules-search')?.value,
|
||||
document.getElementById('schedules-filter-status')?.value
|
||||
);
|
||||
});
|
||||
|
||||
on('pipelines-search', 'input', () => {
|
||||
PipelinesUI.filter(document.getElementById('pipelines-search')?.value);
|
||||
});
|
||||
|
||||
document.getElementById('agents-grid')?.addEventListener('click', (e) => {
|
||||
@@ -320,7 +383,10 @@ const App = {
|
||||
|
||||
const { action, id } = btn.dataset;
|
||||
|
||||
if (action === 'delete-task') TasksUI.delete(id);
|
||||
switch (action) {
|
||||
case 'edit-task': TasksUI.openEditModal(id); break;
|
||||
case 'delete-task': TasksUI.delete(id); break;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('schedules-tbody')?.addEventListener('click', (e) => {
|
||||
@@ -329,7 +395,10 @@ const App = {
|
||||
|
||||
const { action, id } = btn.dataset;
|
||||
|
||||
if (action === 'delete-schedule') SchedulesUI.delete(id);
|
||||
switch (action) {
|
||||
case 'edit-schedule': SchedulesUI.openEditModal(id); break;
|
||||
case 'delete-schedule': SchedulesUI.delete(id); break;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('pipelines-grid')?.addEventListener('click', (e) => {
|
||||
|
||||
@@ -21,24 +21,26 @@ const AgentsUI = {
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
render(filteredAgents) {
|
||||
const grid = document.getElementById('agents-grid');
|
||||
const empty = document.getElementById('agents-empty-state');
|
||||
|
||||
if (!grid) return;
|
||||
|
||||
if (AgentsUI.agents.length === 0) {
|
||||
const agents = filteredAgents || AgentsUI.agents;
|
||||
|
||||
const existingCards = grid.querySelectorAll('.agent-card');
|
||||
existingCards.forEach((c) => c.remove());
|
||||
|
||||
if (agents.length === 0) {
|
||||
if (empty) empty.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
const existingCards = grid.querySelectorAll('.agent-card');
|
||||
existingCards.forEach((c) => c.remove());
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
AgentsUI.agents.forEach((agent) => {
|
||||
agents.forEach((agent) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = AgentsUI.renderCard(agent);
|
||||
fragment.appendChild(wrapper.firstElementChild);
|
||||
@@ -49,6 +51,22 @@ const AgentsUI = {
|
||||
if (window.lucide) lucide.createIcons({ nodes: [grid] });
|
||||
},
|
||||
|
||||
filter(searchText, statusFilter) {
|
||||
const search = (searchText || '').toLowerCase();
|
||||
const status = statusFilter || '';
|
||||
|
||||
const filtered = AgentsUI.agents.filter((a) => {
|
||||
const name = (a.agent_name || '').toLowerCase();
|
||||
const desc = (a.description || '').toLowerCase();
|
||||
const tags = (a.tags || []).join(' ').toLowerCase();
|
||||
const matchesSearch = !search || name.includes(search) || desc.includes(search) || tags.includes(search);
|
||||
const matchesStatus = !status || a.status === status;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
AgentsUI.render(filtered);
|
||||
},
|
||||
|
||||
renderCard(agent) {
|
||||
const name = agent.agent_name || agent.name || 'Sem nome';
|
||||
const color = AgentsUI.getAvatarColor(name);
|
||||
@@ -57,6 +75,9 @@ const AgentsUI = {
|
||||
const statusClass = agent.status === 'active' ? 'badge-active' : 'badge-inactive';
|
||||
const model = (agent.config && agent.config.model) || agent.model || 'claude-sonnet-4-6';
|
||||
const updatedAt = AgentsUI.formatDate(agent.updated_at || agent.updatedAt || agent.created_at || agent.createdAt);
|
||||
const tags = Array.isArray(agent.tags) && agent.tags.length > 0
|
||||
? `<div class="agent-tags">${agent.tags.map((t) => `<span class="tag-chip tag-chip--sm">${t}</span>`).join('')}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="agent-card" data-agent-id="${agent.id}">
|
||||
@@ -72,6 +93,7 @@ const AgentsUI = {
|
||||
</div>
|
||||
|
||||
${agent.description ? `<p class="agent-description">${agent.description}</p>` : ''}
|
||||
${tags}
|
||||
|
||||
<div class="agent-meta">
|
||||
<span class="agent-meta-item">
|
||||
@@ -124,6 +146,15 @@ const AgentsUI = {
|
||||
const tagsChips = document.getElementById('agent-tags-chips');
|
||||
if (tagsChips) tagsChips.innerHTML = '';
|
||||
|
||||
const allowedTools = document.getElementById('agent-allowed-tools');
|
||||
if (allowedTools) allowedTools.value = '';
|
||||
|
||||
const maxTurns = document.getElementById('agent-max-turns');
|
||||
if (maxTurns) maxTurns.value = '0';
|
||||
|
||||
const permissionMode = document.getElementById('agent-permission-mode');
|
||||
if (permissionMode) permissionMode.value = '';
|
||||
|
||||
Modal.open('agent-modal-overlay');
|
||||
},
|
||||
|
||||
@@ -141,6 +172,9 @@ const AgentsUI = {
|
||||
'agent-system-prompt': (agent.config && agent.config.systemPrompt) || '',
|
||||
'agent-model': (agent.config && agent.config.model) || 'claude-sonnet-4-6',
|
||||
'agent-workdir': (agent.config && agent.config.workingDirectory) || '',
|
||||
'agent-allowed-tools': (agent.config && agent.config.allowedTools) || '',
|
||||
'agent-max-turns': (agent.config && agent.config.maxTurns) || 0,
|
||||
'agent-permission-mode': (agent.config && agent.config.permissionMode) || '',
|
||||
};
|
||||
|
||||
for (const [fieldId, value] of Object.entries(fields)) {
|
||||
@@ -191,11 +225,15 @@ const AgentsUI = {
|
||||
const data = {
|
||||
agent_name: nameEl.value.trim(),
|
||||
description: document.getElementById('agent-description')?.value.trim() || '',
|
||||
tags,
|
||||
status: toggle && toggle.checked ? 'active' : 'inactive',
|
||||
config: {
|
||||
systemPrompt: document.getElementById('agent-system-prompt')?.value.trim() || '',
|
||||
model: document.getElementById('agent-model')?.value || 'claude-sonnet-4-6',
|
||||
workingDirectory: document.getElementById('agent-workdir')?.value.trim() || '',
|
||||
allowedTools: document.getElementById('agent-allowed-tools')?.value.trim() || '',
|
||||
maxTurns: parseInt(document.getElementById('agent-max-turns')?.value) || 0,
|
||||
permissionMode: document.getElementById('agent-permission-mode')?.value || '',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -233,8 +271,6 @@ const AgentsUI = {
|
||||
},
|
||||
|
||||
async execute(agentId) {
|
||||
const agent = AgentsUI.agents.find((a) => a.id === agentId);
|
||||
|
||||
try {
|
||||
const allAgents = AgentsUI.agents.length > 0 ? AgentsUI.agents : await API.agents.list();
|
||||
const selectEl = document.getElementById('execute-agent-select');
|
||||
@@ -275,6 +311,37 @@ const AgentsUI = {
|
||||
}
|
||||
},
|
||||
|
||||
openImportModal() {
|
||||
const textarea = document.getElementById('import-json-content');
|
||||
if (textarea) textarea.value = '';
|
||||
Modal.open('import-modal-overlay');
|
||||
},
|
||||
|
||||
async importAgent() {
|
||||
const textarea = document.getElementById('import-json-content');
|
||||
if (!textarea || !textarea.value.trim()) {
|
||||
Toast.warning('Cole o JSON do agente para importar');
|
||||
return;
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(textarea.value.trim());
|
||||
} catch {
|
||||
Toast.error('JSON inválido');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.agents.import(data);
|
||||
Toast.success('Agente importado com sucesso');
|
||||
Modal.close('import-modal-overlay');
|
||||
await AgentsUI.load();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao importar agente: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
getAvatarColor(name) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
const DashboardUI = {
|
||||
async load() {
|
||||
try {
|
||||
const [status, agents] = await Promise.all([
|
||||
const [status, recentExecs] = await Promise.all([
|
||||
API.system.status(),
|
||||
API.agents.list(),
|
||||
API.executions.recent(10),
|
||||
]);
|
||||
|
||||
DashboardUI.updateMetrics(status, agents);
|
||||
DashboardUI.updateRecentActivity(status.executions?.list || []);
|
||||
DashboardUI.updateMetrics(status);
|
||||
DashboardUI.updateRecentActivity(recentExecs || []);
|
||||
DashboardUI.updateSystemStatus(status);
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao carregar dashboard: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
updateMetrics(status, agents) {
|
||||
updateMetrics(status) {
|
||||
const metrics = {
|
||||
'metric-total-agents': status.agents?.total ?? (agents?.length ?? 0),
|
||||
'metric-total-agents': status.agents?.total ?? 0,
|
||||
'metric-active-agents': status.agents?.active ?? 0,
|
||||
'metric-executions-today': status.executions?.active ?? 0,
|
||||
'metric-executions-today': status.executions?.today ?? 0,
|
||||
'metric-schedules': status.schedules?.total ?? 0,
|
||||
};
|
||||
|
||||
|
||||
@@ -18,16 +18,28 @@ const PipelinesUI = {
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
filter(searchText) {
|
||||
const search = (searchText || '').toLowerCase();
|
||||
const filtered = PipelinesUI.pipelines.filter((p) => {
|
||||
const name = (p.name || '').toLowerCase();
|
||||
const desc = (p.description || '').toLowerCase();
|
||||
return !search || name.includes(search) || desc.includes(search);
|
||||
});
|
||||
PipelinesUI.render(filtered);
|
||||
},
|
||||
|
||||
render(filteredPipelines) {
|
||||
const grid = document.getElementById('pipelines-grid');
|
||||
if (!grid) return;
|
||||
|
||||
const pipelines = filteredPipelines || PipelinesUI.pipelines;
|
||||
|
||||
const existingCards = grid.querySelectorAll('.pipeline-card');
|
||||
existingCards.forEach((c) => c.remove());
|
||||
|
||||
const emptyState = grid.querySelector('.empty-state');
|
||||
|
||||
if (PipelinesUI.pipelines.length === 0) {
|
||||
if (pipelines.length === 0) {
|
||||
if (!emptyState) {
|
||||
grid.insertAdjacentHTML('beforeend', PipelinesUI.renderEmpty());
|
||||
}
|
||||
@@ -38,7 +50,7 @@ const PipelinesUI = {
|
||||
if (emptyState) emptyState.remove();
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
PipelinesUI.pipelines.forEach((pipeline) => {
|
||||
pipelines.forEach((pipeline) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = PipelinesUI.renderCard(pipeline);
|
||||
fragment.appendChild(wrapper.firstElementChild);
|
||||
|
||||
@@ -5,16 +5,19 @@ const SchedulesUI = {
|
||||
try {
|
||||
SchedulesUI.schedules = await API.schedules.list();
|
||||
SchedulesUI.render();
|
||||
SchedulesUI.loadHistory();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao carregar agendamentos: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
render(filteredSchedules) {
|
||||
const tbody = document.getElementById('schedules-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (SchedulesUI.schedules.length === 0) {
|
||||
const schedules = filteredSchedules || SchedulesUI.schedules;
|
||||
|
||||
if (schedules.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr class="table-empty-row">
|
||||
<td colspan="6">
|
||||
@@ -29,7 +32,7 @@ const SchedulesUI = {
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = SchedulesUI.schedules.map((schedule) => {
|
||||
tbody.innerHTML = schedules.map((schedule) => {
|
||||
const cronExpr = schedule.cronExpression || schedule.cronExpr || '';
|
||||
const statusClass = schedule.active ? 'badge-active' : 'badge-inactive';
|
||||
const statusLabel = schedule.active ? 'Ativo' : 'Inativo';
|
||||
@@ -37,10 +40,11 @@ const SchedulesUI = {
|
||||
const nextRun = schedule.nextRun
|
||||
? new Date(schedule.nextRun).toLocaleString('pt-BR')
|
||||
: '—';
|
||||
const scheduleId = schedule.id || schedule.taskId;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${schedule.agentName || schedule.agentId || '—'}</td>
|
||||
<td>${schedule.agentName || '—'}</td>
|
||||
<td class="schedule-task-cell" title="${schedule.taskDescription || ''}">${schedule.taskDescription || '—'}</td>
|
||||
<td>
|
||||
<span title="${cronExpr}">${humanCron}</span>
|
||||
@@ -49,15 +53,26 @@ const SchedulesUI = {
|
||||
<td>${nextRun}</td>
|
||||
<td><span class="badge ${statusClass}">${statusLabel}</span></td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-danger"
|
||||
data-action="delete-schedule"
|
||||
data-id="${schedule.taskId}"
|
||||
title="Remover agendamento"
|
||||
aria-label="Remover agendamento"
|
||||
>
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
<div class="schedule-actions-cell">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
data-action="edit-schedule"
|
||||
data-id="${scheduleId}"
|
||||
title="Editar agendamento"
|
||||
aria-label="Editar agendamento"
|
||||
>
|
||||
<i data-lucide="pencil"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-danger"
|
||||
data-action="delete-schedule"
|
||||
data-id="${scheduleId}"
|
||||
title="Remover agendamento"
|
||||
aria-label="Remover agendamento"
|
||||
>
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
@@ -66,7 +81,24 @@ const SchedulesUI = {
|
||||
if (window.lucide) lucide.createIcons({ nodes: [tbody] });
|
||||
},
|
||||
|
||||
async openCreateModal() {
|
||||
filter(searchText, statusFilter) {
|
||||
const search = (searchText || '').toLowerCase();
|
||||
const status = statusFilter || '';
|
||||
|
||||
const filtered = SchedulesUI.schedules.filter((s) => {
|
||||
const agent = (s.agentName || '').toLowerCase();
|
||||
const task = (s.taskDescription || '').toLowerCase();
|
||||
const matchesSearch = !search || agent.includes(search) || task.includes(search);
|
||||
const matchesStatus = !status ||
|
||||
(status === 'active' && s.active) ||
|
||||
(status === 'paused' && !s.active);
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
SchedulesUI.render(filtered);
|
||||
},
|
||||
|
||||
async openCreateModal(editSchedule) {
|
||||
try {
|
||||
const agents = await API.agents.list();
|
||||
const select = document.getElementById('schedule-agent');
|
||||
@@ -79,11 +111,23 @@ const SchedulesUI = {
|
||||
.join('');
|
||||
}
|
||||
|
||||
const titleEl = document.getElementById('schedule-modal-title');
|
||||
const idEl = document.getElementById('schedule-form-id');
|
||||
const taskEl = document.getElementById('schedule-task');
|
||||
if (taskEl) taskEl.value = '';
|
||||
|
||||
const cronEl = document.getElementById('schedule-cron');
|
||||
if (cronEl) cronEl.value = '';
|
||||
|
||||
if (editSchedule) {
|
||||
if (titleEl) titleEl.textContent = 'Editar Agendamento';
|
||||
if (idEl) idEl.value = editSchedule.id || editSchedule.taskId || '';
|
||||
if (select) select.value = editSchedule.agentId || '';
|
||||
if (taskEl) taskEl.value = editSchedule.taskDescription || '';
|
||||
if (cronEl) cronEl.value = editSchedule.cronExpression || editSchedule.cronExpr || '';
|
||||
} else {
|
||||
if (titleEl) titleEl.textContent = 'Novo Agendamento';
|
||||
if (idEl) idEl.value = '';
|
||||
if (taskEl) taskEl.value = '';
|
||||
if (cronEl) cronEl.value = '';
|
||||
}
|
||||
|
||||
Modal.open('schedule-modal-overlay');
|
||||
} catch (err) {
|
||||
@@ -91,7 +135,16 @@ const SchedulesUI = {
|
||||
}
|
||||
},
|
||||
|
||||
async openEditModal(scheduleId) {
|
||||
const schedule = SchedulesUI.schedules.find(
|
||||
(s) => (s.id || s.taskId) === scheduleId
|
||||
);
|
||||
if (!schedule) return;
|
||||
await SchedulesUI.openCreateModal(schedule);
|
||||
},
|
||||
|
||||
async save() {
|
||||
const scheduleId = document.getElementById('schedule-form-id')?.value.trim();
|
||||
const agentId = document.getElementById('schedule-agent')?.value;
|
||||
const taskDescription = document.getElementById('schedule-task')?.value.trim();
|
||||
const cronExpression = document.getElementById('schedule-cron')?.value.trim();
|
||||
@@ -112,12 +165,17 @@ const SchedulesUI = {
|
||||
}
|
||||
|
||||
try {
|
||||
await API.schedules.create({ agentId, taskDescription, cronExpression });
|
||||
Toast.success('Agendamento criado com sucesso');
|
||||
if (scheduleId) {
|
||||
await API.schedules.update(scheduleId, { agentId, taskDescription, cronExpression });
|
||||
Toast.success('Agendamento atualizado com sucesso');
|
||||
} else {
|
||||
await API.schedules.create({ agentId, taskDescription, cronExpression });
|
||||
Toast.success('Agendamento criado com sucesso');
|
||||
}
|
||||
Modal.close('schedule-modal-overlay');
|
||||
await SchedulesUI.load();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao criar agendamento: ${err.message}`);
|
||||
Toast.error(`Erro ao salvar agendamento: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -138,6 +196,39 @@ const SchedulesUI = {
|
||||
}
|
||||
},
|
||||
|
||||
async loadHistory() {
|
||||
try {
|
||||
const history = await API.schedules.history();
|
||||
SchedulesUI.renderHistory(history || []);
|
||||
} catch {
|
||||
}
|
||||
},
|
||||
|
||||
renderHistory(history) {
|
||||
const container = document.getElementById('schedules-history');
|
||||
if (!container) return;
|
||||
|
||||
if (history.length === 0) {
|
||||
container.innerHTML = '<p class="empty-state-desc">Nenhum disparo registrado</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
},
|
||||
|
||||
cronToHuman(expression) {
|
||||
if (!expression) return '—';
|
||||
|
||||
|
||||
72
public/js/components/settings.js
Normal file
72
public/js/components/settings.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const SettingsUI = {
|
||||
async load() {
|
||||
try {
|
||||
const [settings, info] = await Promise.all([
|
||||
API.settings.get(),
|
||||
API.system.info(),
|
||||
]);
|
||||
|
||||
SettingsUI.populateForm(settings);
|
||||
SettingsUI.populateSystemInfo(info);
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao carregar configurações: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
populateForm(settings) {
|
||||
const fields = {
|
||||
'settings-default-model': settings.defaultModel || 'claude-sonnet-4-6',
|
||||
'settings-default-workdir': settings.defaultWorkdir || '',
|
||||
'settings-max-concurrent': settings.maxConcurrent || 5,
|
||||
};
|
||||
|
||||
for (const [id, value] of Object.entries(fields)) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = value;
|
||||
}
|
||||
},
|
||||
|
||||
populateSystemInfo(info) {
|
||||
const fields = {
|
||||
'info-server-version': info.serverVersion || '1.0.0',
|
||||
'info-node-version': info.nodeVersion || 'N/A',
|
||||
'info-claude-version': info.claudeVersion || 'N/A',
|
||||
'info-platform': info.platform || 'N/A',
|
||||
'info-uptime': SettingsUI.formatUptime(info.uptime),
|
||||
};
|
||||
|
||||
for (const [id, value] of Object.entries(fields)) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
}
|
||||
},
|
||||
|
||||
formatUptime(seconds) {
|
||||
if (!seconds && seconds !== 0) return 'N/A';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
const parts = [];
|
||||
if (h > 0) parts.push(`${h}h`);
|
||||
if (m > 0) parts.push(`${m}m`);
|
||||
parts.push(`${s}s`);
|
||||
return parts.join(' ');
|
||||
},
|
||||
|
||||
async save() {
|
||||
const data = {
|
||||
defaultModel: document.getElementById('settings-default-model')?.value || 'claude-sonnet-4-6',
|
||||
defaultWorkdir: document.getElementById('settings-default-workdir')?.value.trim() || '',
|
||||
maxConcurrent: parseInt(document.getElementById('settings-max-concurrent')?.value) || 5,
|
||||
};
|
||||
|
||||
try {
|
||||
await API.settings.save(data);
|
||||
Toast.success('Configurações salvas com sucesso');
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao salvar configurações: ${err.message}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
window.SettingsUI = SettingsUI;
|
||||
@@ -1,5 +1,6 @@
|
||||
const TasksUI = {
|
||||
tasks: [],
|
||||
_editingId: null,
|
||||
|
||||
async load() {
|
||||
try {
|
||||
@@ -10,16 +11,18 @@ const TasksUI = {
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
render(filteredTasks) {
|
||||
const container = document.getElementById('tasks-grid');
|
||||
const empty = document.getElementById('tasks-empty-state');
|
||||
|
||||
if (!container) return;
|
||||
|
||||
const tasks = filteredTasks || TasksUI.tasks;
|
||||
|
||||
const existingCards = container.querySelectorAll('.task-card');
|
||||
existingCards.forEach((c) => c.remove());
|
||||
|
||||
if (TasksUI.tasks.length === 0) {
|
||||
if (tasks.length === 0) {
|
||||
if (empty) empty.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
@@ -28,7 +31,7 @@ const TasksUI = {
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
TasksUI.tasks.forEach((task) => {
|
||||
tasks.forEach((task) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = TasksUI._renderCard(task);
|
||||
fragment.appendChild(wrapper.firstElementChild);
|
||||
@@ -39,10 +42,25 @@ const TasksUI = {
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
},
|
||||
|
||||
filter(searchText, categoryFilter) {
|
||||
const search = (searchText || '').toLowerCase();
|
||||
const category = categoryFilter || '';
|
||||
|
||||
const filtered = TasksUI.tasks.filter((t) => {
|
||||
const name = (t.name || '').toLowerCase();
|
||||
const desc = (t.description || '').toLowerCase();
|
||||
const matchesSearch = !search || name.includes(search) || desc.includes(search);
|
||||
const matchesCategory = !category || t.category === category;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
TasksUI.render(filtered);
|
||||
},
|
||||
|
||||
_renderCard(task) {
|
||||
const categoryClass = TasksUI._categoryClass(task.category);
|
||||
const categoryLabel = task.category || 'Geral';
|
||||
const createdAt = TasksUI._formatDate(task.createdAt);
|
||||
const createdAt = TasksUI._formatDate(task.createdAt || task.created_at);
|
||||
|
||||
return `
|
||||
<div class="task-card" data-task-id="${task.id}">
|
||||
@@ -70,39 +88,52 @@ const TasksUI = {
|
||||
},
|
||||
|
||||
openCreateModal() {
|
||||
TasksUI._editingId = null;
|
||||
TasksUI._openInlineForm({});
|
||||
},
|
||||
|
||||
openEditModal(taskId) {
|
||||
const task = TasksUI.tasks.find((t) => t.id === taskId);
|
||||
if (!task) return;
|
||||
TasksUI._editingId = taskId;
|
||||
TasksUI._openInlineForm(task);
|
||||
},
|
||||
|
||||
_openInlineForm(task) {
|
||||
const container = document.getElementById('tasks-grid');
|
||||
if (!container) return;
|
||||
|
||||
const existing = document.getElementById('task-inline-form');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
return;
|
||||
}
|
||||
if (existing) existing.remove();
|
||||
|
||||
const isEdit = !!TasksUI._editingId;
|
||||
const title = isEdit ? 'Editar tarefa' : 'Nome da tarefa *';
|
||||
const btnLabel = isEdit ? 'Atualizar' : 'Salvar';
|
||||
|
||||
const formHtml = `
|
||||
<div class="task-card task-card--form" id="task-inline-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-inline-name">Nome da tarefa *</label>
|
||||
<input type="text" id="task-inline-name" class="input" placeholder="Ex: Code Review de PR" required autocomplete="off">
|
||||
<label class="form-label" for="task-inline-name">${title}</label>
|
||||
<input type="text" id="task-inline-name" class="input" placeholder="Ex: Code Review de PR" required autocomplete="off" value="${task.name || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-inline-category">Categoria</label>
|
||||
<select id="task-inline-category" class="select">
|
||||
<option value="">Selecionar...</option>
|
||||
<option value="code-review">Code Review</option>
|
||||
<option value="security">Segurança</option>
|
||||
<option value="refactor">Refatoração</option>
|
||||
<option value="tests">Testes</option>
|
||||
<option value="docs">Documentação</option>
|
||||
<option value="performance">Performance</option>
|
||||
<option value="code-review" ${task.category === 'code-review' ? 'selected' : ''}>Code Review</option>
|
||||
<option value="security" ${task.category === 'security' ? 'selected' : ''}>Segurança</option>
|
||||
<option value="refactor" ${task.category === 'refactor' ? 'selected' : ''}>Refatoração</option>
|
||||
<option value="tests" ${task.category === 'tests' ? 'selected' : ''}>Testes</option>
|
||||
<option value="docs" ${task.category === 'docs' ? 'selected' : ''}>Documentação</option>
|
||||
<option value="performance" ${task.category === 'performance' ? 'selected' : ''}>Performance</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-inline-description">Descrição</label>
|
||||
<textarea id="task-inline-description" class="textarea" rows="2" placeholder="Descreva o objetivo desta tarefa..."></textarea>
|
||||
<textarea id="task-inline-description" class="textarea" rows="2" placeholder="Descreva o objetivo desta tarefa...">${task.description || ''}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn--primary" id="btn-save-inline-task" type="button">Salvar</button>
|
||||
<button class="btn btn--primary" id="btn-save-inline-task" type="button">${btnLabel}</button>
|
||||
<button class="btn btn--ghost" id="btn-cancel-inline-task" type="button">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,6 +159,7 @@ const TasksUI = {
|
||||
|
||||
document.getElementById('btn-cancel-inline-task')?.addEventListener('click', () => {
|
||||
document.getElementById('task-inline-form')?.remove();
|
||||
TasksUI._editingId = null;
|
||||
if (TasksUI.tasks.length === 0) {
|
||||
const emptyEl = document.getElementById('tasks-empty-state');
|
||||
if (emptyEl) emptyEl.style.display = 'flex';
|
||||
@@ -144,8 +176,15 @@ const TasksUI = {
|
||||
}
|
||||
|
||||
try {
|
||||
await API.tasks.create(data);
|
||||
Toast.success('Tarefa criada com sucesso');
|
||||
if (TasksUI._editingId) {
|
||||
await API.tasks.update(TasksUI._editingId, data);
|
||||
Toast.success('Tarefa atualizada com sucesso');
|
||||
} else {
|
||||
await API.tasks.create(data);
|
||||
Toast.success('Tarefa criada com sucesso');
|
||||
}
|
||||
|
||||
TasksUI._editingId = null;
|
||||
document.getElementById('task-inline-form')?.remove();
|
||||
await TasksUI.load();
|
||||
} catch (err) {
|
||||
|
||||
@@ -5,11 +5,11 @@ const Terminal = {
|
||||
executionFilter: null,
|
||||
_processingInterval: null,
|
||||
|
||||
addLine(content, type = 'default') {
|
||||
addLine(content, type = 'default', executionId = null) {
|
||||
const time = new Date();
|
||||
const formatted = time.toTimeString().slice(0, 8);
|
||||
|
||||
Terminal.lines.push({ content, type, timestamp: formatted });
|
||||
Terminal.lines.push({ content, type, timestamp: formatted, executionId });
|
||||
|
||||
if (Terminal.lines.length > Terminal.maxLines) {
|
||||
Terminal.lines.shift();
|
||||
@@ -63,7 +63,7 @@ const Terminal = {
|
||||
if (!output) return;
|
||||
|
||||
const lines = Terminal.executionFilter
|
||||
? Terminal.lines.filter((l) => l.executionId === Terminal.executionFilter)
|
||||
? Terminal.lines.filter((l) => !l.executionId || l.executionId === Terminal.executionFilter)
|
||||
: Terminal.lines;
|
||||
|
||||
if (lines.length === 0 && !Terminal._processingInterval) {
|
||||
|
||||
Reference in New Issue
Block a user