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:
Frederico Castro
2026-02-26 01:24:51 -03:00
parent 723a08d2e1
commit 2f7a9d4c56
18 changed files with 1104 additions and 115 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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