Compare commits

...

10 Commits

Author SHA1 Message Date
Frederico Castro
64389b3cf9 Adicionar Dockerfile e .dockerignore para deploy 2026-02-27 21:26:45 -03:00
Frederico Castro
a2a1aa2c7a Download MD no histórico, relatórios externos e service systemd
- Botão de download .md no modal de detalhe do histórico (agente e pipeline)
- Relatórios de execução gravados também em ~/agent_reports/ (configurável via AGENT_REPORTS_DIR)
- Service systemd (user) para iniciar o orchestrator no boot com auto-restart
2026-02-27 04:19:10 -03:00
Frederico Castro
9b66a415ff Correções de bugs, layout de cards e webhook test funcional
- Pipeline cancel/approve/reject corrigido com busca bidirecional
- Secrets injetados no executor via cleanEnv
- Versionamento automático ao atualizar agentes
- writeJsonAsync com log de erro
- Removido asyncHandler.js (código morto)
- Restaurado permissionMode padrão bypassPermissions
- Ícones dos cards alinhados à direita com wrapper
- Botão Editar convertido para ícone nos cards
- Webhook test agora dispara execução real do agente/pipeline
- Corrigido App.navigateTo no teste de webhook
2026-02-26 23:28:50 -03:00
Frederico Castro
bbd2ec46dd Botões de copiar e download no modal de relatório de execução 2026-02-26 21:03:16 -03:00
Frederico Castro
3b10984233 Terminal verboso com eventos de tool, turno, sistema e stderr + cards com botões na base
- Executor envia 5 tipos de evento: chunk, tool, turn, system, stderr
- Frontend renderiza cada tipo com cor e formatação distintas no terminal
- Cards de agentes e pipelines com flex-column e botões alinhados na base
- CSS para novos tipos de linha do terminal (tool amarelo, turn accent, stderr muted)
2026-02-26 20:59:17 -03:00
Frederico Castro
9a874ad032 Imagem dashboard 2026-02-26 20:48:42 -03:00
Frederico Castro
da22154f66 Evolução da plataforma: dashboard com gráficos, notificações, relatórios automáticos, ícones Lucide local e melhorias gerais
- Dashboard com 5 gráficos Chart.js (execuções, status, custo, agentes, pipelines)
- Sistema de notificações com polling, badge e Browser Notification API
- Relatórios MD automáticos para execuções de agentes e pipelines (data/reports/)
- Lucide local (v0.475.0) com nomes de ícones atualizados e refreshIcons centralizado
- Correção de ícones icon-only (padding CSS sobrescrito por btn-sm)
- Cards de agentes e pipelines com botões alinhados na base (flex column)
- Terminal com busca, download, cópia e auto-scroll toggle
- Histórico com export CSV, retry, paginação e truncamento de texto
- Webhooks com edição e teste inline
- Duplicação de agentes e export/import JSON
- Rate limiting, CORS, correlação de requests e health check no backend
- Escrita atômica em JSON (temp + rename) e store de notificações
- Tema claro/escuro com toggle e persistência em localStorage
- Atalhos de teclado 1-9 para navegação entre seções
2026-02-26 20:41:17 -03:00
Frederico Castro
69943f91be Remover CLAUDE.md do repositório 2026-02-26 19:21:48 -03:00
Frederico Castro
68605d837d Atualizar README com documentação completa das funcionalidades 2026-02-26 19:17:36 -03:00
Frederico Castro
d7d2421fc2 Proteção XSS, assinatura de webhook, limite de execuções e data no histórico
- Utilitário centralizado Utils.escapeHtml() substituindo duplicações locais
- Escaping completo em todos os componentes (agents, tasks, schedules, pipelines, webhooks, terminal, history, tags)
- Verificação HMAC-SHA256 para webhooks usando raw body
- Limite de 5000 registros no store de execuções (maxSize)
- Data de execução visível no histórico com ícone de calendário
- Remoção de mutex desnecessário no flush síncrono do db.js
- Novos stores preparatórios (secrets, notifications, agentVersions)
2026-02-26 18:26:27 -03:00
32 changed files with 20438 additions and 472 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
.git
.env
*.log

View File

@@ -1,70 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Sobre o Projeto
Painel administrativo web para orquestração de agentes Claude Code. Permite criar, configurar e executar agentes que invocam o CLI `claude` como subprocesso, com suporte a agendamento via cron e pipelines sequenciais (saída de um agente alimenta o próximo).
## Comandos
```bash
npm start # Inicia o servidor (porta 3000)
npm run dev # Inicia com --watch (hot reload automático)
```
Não há testes, linting ou build configurados.
## Arquitetura
### Backend (Node.js + Express, ESM)
```
server.js → HTTP + WebSocket (ws) na mesma porta
src/routes/api.js → Todas as rotas REST sob /api
src/agents/manager.js → CRUD de agentes + orquestra execuções e agendamentos
src/agents/executor.js → Spawna o CLI claude como child_process com stream-json
src/agents/scheduler.js → Agendamento cron via node-cron (in-memory)
src/agents/pipeline.js → Execução sequencial de steps, cada um delegando ao executor
src/store/db.js → Persistência em arquivos JSON (data/*.json)
```
**Fluxo de execução:** API recebe POST → `manager.executeTask()``executor.execute()` spawna `/home/fred/.local/bin/claude` com `--output-format stream-json` → stdout é parseado linha a linha → chunks são enviados via WebSocket broadcast para o frontend.
**Pipelines:** Executam steps em sequência. Cada step usa um agente diferente. A saída de um step é passada como input do próximo via template `{{input}}`.
**Persistência:** `db.js` expõe stores (agents, tasks, pipelines) que leem/escrevem JSON em `data/`. Cada operação recarrega o arquivo inteiro. Agendamentos cron são apenas in-memory.
### Frontend (Vanilla JS, SPA)
```
public/index.html → SPA single-page com todas as seções
public/css/styles.css → Estilos (Inter + JetBrains Mono, Lucide icons)
public/js/app.js → Controlador principal, navegação, WebSocket client
public/js/api.js → Client HTTP para /api/*
public/js/components/*.js → UI por seção (dashboard, agents, tasks, schedules, pipelines, terminal, modal, toast)
```
O frontend usa objetos globais no `window` (App, API, DashboardUI, AgentsUI, etc.) sem bundler ou framework. WebSocket reconecta automaticamente com backoff exponencial.
### Endpoints REST
| Recurso | Rotas |
|---------|-------|
| Agentes | GET/POST `/api/agents`, GET/PUT/DELETE `/api/agents/:id`, POST `.../execute`, POST `.../cancel/:executionId`, GET `.../export` |
| Tarefas | GET/POST `/api/tasks`, PUT/DELETE `/api/tasks/:id` |
| Agendamentos | GET/POST `/api/schedules`, DELETE `/api/schedules/:taskId` |
| Pipelines | GET/POST `/api/pipelines`, GET/PUT/DELETE `/api/pipelines/:id`, POST `.../execute`, POST `.../cancel` |
| Sistema | GET `/api/system/status`, GET `/api/executions/active` |
### WebSocket Events
O servidor envia eventos tipados (`execution_output`, `execution_complete`, `execution_error`, `pipeline_step_start`, `pipeline_step_complete`, `pipeline_complete`, `pipeline_error`) que o frontend renderiza no terminal.
## Convenções
- Todo o código e mensagens em português brasileiro
- ESM (`"type": "module"` no package.json) — usar `import`/`export`, não `require`
- Sem TypeScript, sem bundler, sem framework frontend
- IDs gerados com `uuid` v4
- Modelo padrão dos agentes: `claude-sonnet-4-6`

10
Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN mkdir -p data
ENV HOST=0.0.0.0
ENV PORT=3000
EXPOSE 3000
CMD ["node", "server.js"]

207
README.md
View File

@@ -1,16 +1,79 @@
# Agents Orchestrator # Agents Orchestrator
Painel administrativo web para orquestração de agentes [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Crie, configure e execute múltiplos agentes com diferentes personalidades, modelos e diretórios de trabalho — tudo a partir de uma interface visual. Painel administrativo web para orquestração de agentes [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Crie, configure e execute múltiplos agentes com diferentes personalidades, modelos e diretórios de trabalho — tudo a partir de uma interface visual profissional.
![Dashboard do Agents Orchestrator — visão geral com métricas, gráficos de execuções e custos, distribuição de status, ranking de agentes e taxa de sucesso](docs/dashboard.png)
## Funcionalidades ## Funcionalidades
- **Gerenciamento de agentes** — Crie agentes com nome, system prompt, modelo (Sonnet/Opus/Haiku), diretório de trabalho e tags. Ative, desative, edite ou exclua a qualquer momento. ### Gerenciamento de Agentes
- **Execução de tarefas** — Execute tarefas sob demanda em qualquer agente ativo. Templates rápidos incluídos (detecção de bugs, revisão OWASP, refatoração, testes, documentação, performance). - Crie agentes com nome, system prompt, modelo (Sonnet/Opus/Haiku), diretório de trabalho, ferramentas permitidas, modo de permissão e tags
- **Terminal em tempo real** — Acompanhe a saída dos agentes via WebSocket com streaming chunk-a-chunk. Indicador de status de conexão e filtro por execução. - Ative, desative, edite, **duplique** ou exclua a qualquer momento
- **Agendamento cron** — Agende tarefas recorrentes com expressões cron. Presets incluídos (horário, diário, semanal, mensal). - Exporte/importe configurações completas em JSON
- **Pipelines** — Encadeie múltiplos agentes em fluxos sequenciais. A saída de cada passo alimenta o próximo via template `{{input}}`. Ideal para fluxos como "analisar → corrigir → testar".
- **Dashboard** — Visão geral com métricas (agentes, execuções ativas, agendamentos), atividade recente e status do sistema. ### Execução de Tarefas
- **Exportação** — Exporte a configuração completa de qualquer agente em JSON. - Execute tarefas sob demanda em qualquer agente ativo
- Templates rápidos incluídos (detecção de bugs, revisão OWASP, refatoração, testes, documentação, performance)
- **Reexecute** tarefas que falharam ou foram canceladas com um clique
- Continuação de conversa (resume session) no terminal
### Terminal em Tempo Real
- Streaming chunk-a-chunk via WebSocket com indicador de conexão
- **Busca** no output do terminal com navegação entre ocorrências
- **Download** da saída completa como `.txt`
- **Copiar** saída para a área de transferência
- **Toggle de auto-scroll** para controle manual da rolagem
- Filtro por execução
### Dashboard com Gráficos
- Métricas em tempo real (agentes, execuções, agendamentos, custo, webhooks)
- **Gráfico de execuções** por dia (barras empilhadas sucesso/erro)
- **Gráfico de custo** por dia (linha com área preenchida)
- **Distribuição de status** (doughnut chart)
- **Top 5 agentes** mais executados (barras horizontais)
- **Taxa de sucesso** geral (gauge com percentual)
- Seletor de período: 7, 14 ou 30 dias
### Agendamento Cron
- Agende tarefas recorrentes com expressões cron
- Presets incluídos (horário, diário, semanal, mensal)
- Histórico de execuções por agendamento com duração e custo
### Pipelines
- Encadeie múltiplos agentes em fluxos sequenciais
- Saída de cada passo alimenta o próximo via template `{{input}}`
- Portões de aprovação humana entre passos (human-in-the-loop)
- Ideal para fluxos como "analisar → corrigir → testar"
### Webhooks
- Dispare execuções de agentes ou pipelines via HTTP externo
- **Edite** webhooks existentes (nome, alvo, status)
- **Teste** webhooks com um clique para verificar configuração
- Snippet cURL pronto para copiar
- Assinatura HMAC-SHA256 para validação de origem
### Notificações
- **Centro de notificações** no header com badge de contagem
- Notificações automáticas para execuções concluídas e com erro
- **Notificações nativas do navegador** (Browser Notification API)
- Marcar como lidas / limpar todas
- Polling automático a cada 15 segundos
### Tema Claro/Escuro
- Toggle de tema no header com transições suaves
- Persistência da preferência em localStorage
- Terminal mantém fundo escuro em ambos os temas
### Exportação de Dados
- **Exportar histórico** de execuções como CSV (UTF-8 com BOM)
- Exportar configuração de agentes em JSON
### Atalhos de Teclado
| Tecla | Ação |
|-------|------|
| `1``9` | Navegar entre seções |
| `N` | Novo agente |
| `Esc` | Fechar modal |
## Pré-requisitos ## Pré-requisitos
@@ -37,7 +100,18 @@ npm run dev
Acesse **http://localhost:3000** no navegador. A porta pode ser alterada via variável de ambiente `PORT`. Acesse **http://localhost:3000** no navegador. A porta pode ser alterada via variável de ambiente `PORT`.
## Como funciona ## Variáveis de Ambiente
| Variável | Descrição | Padrão |
|----------|-----------|--------|
| `PORT` | Porta do servidor | `3000` |
| `AUTH_TOKEN` | Token Bearer para autenticação da API | _(desabilitado)_ |
| `ALLOWED_ORIGIN` | Origin permitida para CORS | `http://localhost:3000` |
| `WEBHOOK_SECRET` | Segredo HMAC para assinatura de webhooks | _(desabilitado)_ |
| `CLAUDE_BIN` | Caminho para o binário do Claude CLI | _(auto-detectado)_ |
| `REDIS_URL` | URL do Redis para cache L2 (opcional) | _(somente memória)_ |
## Como Funciona
### Criando um agente ### Criando um agente
@@ -57,7 +131,8 @@ Acesse **http://localhost:3000** no navegador. A porta pode ser alterada via var
1. Vá em **Pipelines****Novo Pipeline** 1. Vá em **Pipelines****Novo Pipeline**
2. Adicione pelo menos 2 passos, selecionando um agente para cada 2. Adicione pelo menos 2 passos, selecionando um agente para cada
3. Opcionalmente defina um template de input usando `{{input}}` para referenciar a saída do passo anterior 3. Opcionalmente defina um template de input usando `{{input}}` para referenciar a saída do passo anterior
4. Execute o pipeline fornecendo o input inicial 4. Marque passos que requerem aprovação humana antes de prosseguir
5. Execute o pipeline fornecendo o input inicial
### Agendando uma tarefa ### Agendando uma tarefa
@@ -68,32 +143,40 @@ Acesse **http://localhost:3000** no navegador. A porta pode ser alterada via var
## Arquitetura ## Arquitetura
``` ```
server.js Express + WebSocket na mesma porta server.js Express + WebSocket + rate limiting + auth
src/ src/
routes/api.js API REST (/api/*) routes/api.js API REST (/api/*) — 30+ endpoints
agents/ agents/
manager.js CRUD + orquestração de agentes manager.js CRUD + orquestração + notificações
executor.js Spawna o CLI claude como child_process executor.js Spawna o CLI claude como child_process
scheduler.js Agendamento cron (in-memory) scheduler.js Agendamento cron (in-memory + persistido)
pipeline.js Execução sequencial de steps pipeline.js Execução sequencial com aprovação humana
store/db.js Persistência em JSON (data/*.json) store/db.js Persistência em JSON com escrita atômica
cache/index.js Cache em 2 níveis (memória + Redis opcional)
public/ public/
index.html SPA single-page index.html SPA single-page com hash routing
css/styles.css Estilos (Inter, JetBrains Mono, Lucide) css/styles.css Design system (dark/light themes)
js/ js/
app.js Controlador principal + WebSocket client app.js Controlador principal + WebSocket + tema + routing
api.js Client HTTP para a API api.js Client HTTP para a API
components/ UI por seção (dashboard, agents, tasks, etc.) components/ UI por seção (15 módulos)
data/ data/
agents.json Agentes cadastrados agents.json Agentes cadastrados
tasks.json Templates de tarefas tasks.json Templates de tarefas
pipelines.json Pipelines configurados pipelines.json Pipelines configurados
schedules.json Agendamentos persistidos
executions.json Histórico de execuções (max 5000)
webhooks.json Configuração de webhooks
notifications.json Notificações do sistema
settings.json Configurações globais
``` ```
O executor invoca o binário `claude` com `--output-format stream-json`, parseia o stdout linha a linha e transmite os chunks via WebSocket para o frontend em tempo real. O executor invoca o binário `claude` com `--output-format stream-json`, parseia o stdout linha a linha e transmite os chunks via WebSocket para o frontend em tempo real.
## API REST ## API REST
### Agentes
| Método | Endpoint | Descrição | | Método | Endpoint | Descrição |
|--------|----------|-----------| |--------|----------|-----------|
| `GET` | `/api/agents` | Listar agentes | | `GET` | `/api/agents` | Listar agentes |
@@ -102,15 +185,34 @@ O executor invoca o binário `claude` com `--output-format stream-json`, parseia
| `PUT` | `/api/agents/:id` | Atualizar agente | | `PUT` | `/api/agents/:id` | Atualizar agente |
| `DELETE` | `/api/agents/:id` | Excluir agente | | `DELETE` | `/api/agents/:id` | Excluir agente |
| `POST` | `/api/agents/:id/execute` | Executar tarefa no agente | | `POST` | `/api/agents/:id/execute` | Executar tarefa no agente |
| `POST` | `/api/agents/:id/cancel/:executionId` | Cancelar execução | | `POST` | `/api/agents/:id/continue` | Continuar conversa (resume) |
| `POST` | `/api/agents/:id/cancel/:execId` | Cancelar execução |
| `GET` | `/api/agents/:id/export` | Exportar agente (JSON) | | `GET` | `/api/agents/:id/export` | Exportar agente (JSON) |
| `POST` | `/api/agents/:id/duplicate` | Duplicar agente |
### Tarefas
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `GET` | `/api/tasks` | Listar tarefas | | `GET` | `/api/tasks` | Listar tarefas |
| `POST` | `/api/tasks` | Criar tarefa | | `POST` | `/api/tasks` | Criar tarefa |
| `PUT` | `/api/tasks/:id` | Atualizar tarefa | | `PUT` | `/api/tasks/:id` | Atualizar tarefa |
| `DELETE` | `/api/tasks/:id` | Excluir tarefa | | `DELETE` | `/api/tasks/:id` | Excluir tarefa |
### Agendamentos
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `GET` | `/api/schedules` | Listar agendamentos | | `GET` | `/api/schedules` | Listar agendamentos |
| `POST` | `/api/schedules` | Criar agendamento | | `POST` | `/api/schedules` | Criar agendamento |
| `PUT` | `/api/schedules/:taskId` | Atualizar agendamento |
| `DELETE` | `/api/schedules/:taskId` | Remover agendamento | | `DELETE` | `/api/schedules/:taskId` | Remover agendamento |
| `GET` | `/api/schedules/history` | Histórico de execuções agendadas |
### Pipelines
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `GET` | `/api/pipelines` | Listar pipelines | | `GET` | `/api/pipelines` | Listar pipelines |
| `POST` | `/api/pipelines` | Criar pipeline | | `POST` | `/api/pipelines` | Criar pipeline |
| `GET` | `/api/pipelines/:id` | Obter pipeline | | `GET` | `/api/pipelines/:id` | Obter pipeline |
@@ -118,8 +220,51 @@ O executor invoca o binário `claude` com `--output-format stream-json`, parseia
| `DELETE` | `/api/pipelines/:id` | Excluir pipeline | | `DELETE` | `/api/pipelines/:id` | Excluir pipeline |
| `POST` | `/api/pipelines/:id/execute` | Executar pipeline | | `POST` | `/api/pipelines/:id/execute` | Executar pipeline |
| `POST` | `/api/pipelines/:id/cancel` | Cancelar pipeline | | `POST` | `/api/pipelines/:id/cancel` | Cancelar pipeline |
| `GET` | `/api/system/status` | Status geral do sistema | | `POST` | `/api/pipelines/:id/approve` | Aprovar passo pendente |
| `POST` | `/api/pipelines/:id/reject` | Rejeitar passo pendente |
### Webhooks
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `GET` | `/api/webhooks` | Listar webhooks |
| `POST` | `/api/webhooks` | Criar webhook |
| `PUT` | `/api/webhooks/:id` | Atualizar webhook |
| `DELETE` | `/api/webhooks/:id` | Excluir webhook |
| `POST` | `/api/webhooks/:id/test` | Testar webhook |
### Execuções e Histórico
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `GET` | `/api/executions/active` | Execuções em andamento | | `GET` | `/api/executions/active` | Execuções em andamento |
| `GET` | `/api/executions/history` | Histórico paginado com filtros |
| `GET` | `/api/executions/recent` | Execuções recentes |
| `GET` | `/api/executions/export` | Exportar histórico como CSV |
| `GET` | `/api/executions/:id` | Detalhes de uma execução |
| `DELETE` | `/api/executions/:id` | Excluir execução do histórico |
| `POST` | `/api/executions/:id/retry` | Reexecutar execução falha |
| `DELETE` | `/api/executions` | Limpar histórico |
### Notificações
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `GET` | `/api/notifications` | Listar notificações |
| `POST` | `/api/notifications/:id/read` | Marcar como lida |
| `POST` | `/api/notifications/read-all` | Marcar todas como lidas |
| `DELETE` | `/api/notifications` | Limpar notificações |
### Sistema
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `GET` | `/api/health` | Health check (sem auth) |
| `GET` | `/api/system/status` | Status geral do sistema |
| `GET` | `/api/system/info` | Informações do servidor |
| `GET` | `/api/stats/costs` | Estatísticas de custo |
| `GET` | `/api/stats/charts` | Dados para gráficos do dashboard |
| `GET/PUT` | `/api/settings` | Configurações globais |
## Eventos WebSocket ## Eventos WebSocket
@@ -134,14 +279,28 @@ O servidor envia eventos tipados via WebSocket que o frontend renderiza no termi
| `pipeline_step_complete` | Passo do pipeline concluído | | `pipeline_step_complete` | Passo do pipeline concluído |
| `pipeline_complete` | Pipeline finalizado | | `pipeline_complete` | Pipeline finalizado |
| `pipeline_error` | Erro em um passo do pipeline | | `pipeline_error` | Erro em um passo do pipeline |
| `pipeline_approval_required` | Passo aguardando aprovação humana |
## Segurança
- **Autenticação** via Bearer token (variável `AUTH_TOKEN`)
- **Rate limiting** — 100 requisições por minuto por IP
- **CORS** restrito à origin configurada
- **Timing-safe comparison** para tokens de autenticação e webhooks
- **Correlation IDs** em todas as requisições para rastreabilidade
- **Escrita atômica** em disco (temp + rename) para integridade de dados
- **Sanitização** de prompts (NUL, caracteres de controle, limite de 50.000 chars)
- **Assinatura HMAC-SHA256** para webhooks recebidos
## Stack ## Stack
- **Backend**: Node.js, Express, WebSocket (ws), node-cron, uuid - **Backend**: Node.js, Express, WebSocket (ws), node-cron, uuid, express-rate-limit
- **Frontend**: HTML, CSS, JavaScript vanilla (sem framework, sem bundler) - **Frontend**: HTML, CSS, JavaScript vanilla (sem framework, sem bundler)
- **Gráficos**: Chart.js 4.x
- **Ícones**: Lucide - **Ícones**: Lucide
- **Fontes**: Inter (UI), JetBrains Mono (código/terminal) - **Fontes**: Inter (UI), JetBrains Mono (código/terminal)
- **Persistência**: Arquivos JSON em disco - **Persistência**: Arquivos JSON em disco com escrita atômica
- **Cache**: In-memory com suporte opcional a Redis (ioredis)
## Licença ## Licença

BIN
docs/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

230
package-lock.json generated
View File

@@ -1,14 +1,18 @@
{ {
"name": "agents-orchestrator", "name": "agents-orchestrator",
"version": "1.0.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agents-orchestrator", "name": "agents-orchestrator",
"version": "1.0.0", "version": "1.1.0",
"dependencies": { "dependencies": {
"compression": "^1.8.1",
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"multer": "^2.0.2",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"ws": "^8.18.0" "ws": "^8.18.0"
@@ -27,6 +31,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -57,6 +67,23 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -95,6 +122,60 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -279,6 +360,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -397,6 +496,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -435,6 +543,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -513,12 +630,51 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -549,6 +705,15 @@
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -573,6 +738,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -640,6 +814,20 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -798,6 +986,23 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -820,6 +1025,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -829,6 +1040,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -880,6 +1097,15 @@
"optional": true "optional": true
} }
} }
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
} }
} }
} }

View File

@@ -9,9 +9,13 @@
"dev": "node --watch server.js" "dev": "node --watch server.js"
}, },
"dependencies": { "dependencies": {
"compression": "^1.8.1",
"express": "^4.21.0", "express": "^4.21.0",
"ws": "^8.18.0", "express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"multer": "^2.0.2",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"uuid": "^10.0.0" "uuid": "^10.0.0",
"ws": "^8.18.0"
} }
} }

View File

@@ -447,6 +447,8 @@ textarea {
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
transition: all 0.3s; transition: all 0.3s;
display: flex;
flex-direction: column;
animation: fadeInUp 0.3s ease both; animation: fadeInUp 0.3s ease both;
} }
@@ -461,6 +463,7 @@ textarea {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
flex: 1;
} }
.agent-card-top { .agent-card-top {
@@ -526,9 +529,17 @@ textarea {
.agent-actions { .agent-actions {
display: flex; display: flex;
align-items: center;
gap: 8px; gap: 8px;
padding: 12px 20px; padding: 12px 16px;
border-top: 1px solid var(--border-primary); border-top: 1px solid var(--border-primary);
margin-top: auto;
}
.agent-actions-icons {
display: flex;
gap: 4px;
margin-left: auto;
} }
.badge { .badge {
@@ -664,7 +675,7 @@ textarea {
color: #ffffff; color: #ffffff;
} }
.btn-icon { .btn.btn-icon {
width: 36px; width: 36px;
height: 36px; height: 36px;
padding: 0; padding: 0;
@@ -679,6 +690,9 @@ textarea {
.btn-sm.btn-icon { .btn-sm.btn-icon {
width: 30px; width: 30px;
height: 30px; height: 30px;
min-width: 30px;
padding: 0;
flex-shrink: 0;
} }
.btn-lg { .btn-lg {
@@ -1053,6 +1067,24 @@ textarea {
font-style: italic; font-style: italic;
} }
.terminal-line.tool .content {
color: var(--warning);
font-size: 12px;
}
.terminal-line.turn .content {
color: var(--accent);
font-weight: 600;
font-size: 12px;
letter-spacing: 0.5px;
}
.terminal-line.stderr .content {
color: var(--text-muted);
font-size: 11px;
opacity: 0.7;
}
.terminal-cursor { .terminal-cursor {
display: inline-block; display: inline-block;
width: 8px; width: 8px;
@@ -1967,6 +1999,7 @@ tbody tr:hover td {
flex: 1; flex: 1;
padding: 24px 32px; padding: 24px 32px;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
} }
.section { .section {
@@ -2780,6 +2813,118 @@ tbody tr:hover td {
margin-top: 4px; margin-top: 4px;
} }
.dropzone {
border: 2px dashed var(--border-secondary);
border-radius: 10px;
padding: 20px;
text-align: center;
transition: all 0.2s;
cursor: pointer;
background: var(--bg-primary);
}
.dropzone:hover,
.dropzone.dragover {
border-color: var(--accent);
background: rgba(139, 92, 246, 0.05);
}
.dropzone-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: var(--text-muted);
}
.dropzone-content i,
.dropzone-content svg {
width: 28px;
height: 28px;
color: var(--text-muted);
opacity: 0.6;
}
.dropzone-content p {
font-size: 13px;
margin: 0;
}
.dropzone-browse {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 13px;
text-decoration: underline;
padding: 0;
}
.dropzone-hint {
font-size: 11px;
color: var(--text-muted);
opacity: 0.7;
}
.dropzone-list {
list-style: none;
padding: 0;
margin: 8px 0 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.dropzone-list:empty {
display: none;
}
.dropzone-list + .dropzone-content {
display: none;
}
.dropzone-file {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--bg-secondary);
border-radius: 6px;
font-size: 12px;
}
.dropzone-file-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
text-align: left;
}
.dropzone-file-size {
color: var(--text-muted);
font-size: 11px;
flex-shrink: 0;
}
.dropzone-file-remove {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
line-height: 1;
border-radius: 4px;
flex-shrink: 0;
}
.dropzone-file-remove:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
.form-actions { .form-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -3219,6 +3364,7 @@ tbody tr:hover td {
.agent-card-actions { .agent-card-actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: 8px; gap: 8px;
padding: 12px 16px; padding: 12px 16px;
border-top: 1px solid var(--border-primary); border-top: 1px solid var(--border-primary);
@@ -3395,6 +3541,10 @@ tbody tr:hover td {
color: #8b5cf6; color: #8b5cf6;
} }
#history-list {
overflow: hidden;
}
.history-card { .history-card {
background-color: var(--bg-card); background-color: var(--bg-card);
border: 1px solid var(--border-primary); border: 1px solid var(--border-primary);
@@ -3406,6 +3556,8 @@ tbody tr:hover td {
transition: all 0.2s; transition: all 0.2s;
animation: fadeInUp 0.2s ease both; animation: fadeInUp 0.2s ease both;
margin-bottom: 8px; margin-bottom: 8px;
overflow: hidden;
min-width: 0;
} }
.history-card:hover { .history-card:hover {
@@ -3419,6 +3571,7 @@ tbody tr:hover td {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
min-width: 0;
} }
.history-card-identity { .history-card-identity {
@@ -3435,26 +3588,7 @@ tbody tr:hover td {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} max-width: 300px;
.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 { .history-card-task {
@@ -3463,24 +3597,31 @@ tbody tr:hover td {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
flex: 1; max-width: 100%;
min-width: 0;
} }
.history-card-info {
display: flex;
align-items: center;
gap: 16px;
font-size: 12px;
color: var(--text-muted);
}
.history-card-date,
.history-card-duration { .history-card-duration {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 5px;
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
} }
.history-card-date i,
.history-card-duration i { .history-card-duration i {
width: 12px; width: 13px;
height: 12px; height: 13px;
flex-shrink: 0; flex-shrink: 0;
opacity: 0.6;
} }
.history-card-actions { .history-card-actions {
@@ -3986,12 +4127,6 @@ tbody tr:hover td {
height: 12px; height: 12px;
} }
.history-card-duration-group {
display: flex;
align-items: center;
gap: 12px;
}
.pipeline-step-meta-group { .pipeline-step-meta-group {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -4042,3 +4177,448 @@ tbody tr:hover td {
display: block; display: block;
white-space: pre; white-space: pre;
} }
[data-theme="light"] {
--bg-primary: #f5f6f8;
--bg-secondary: #ffffff;
--bg-tertiary: #eef0f4;
--bg-card: #ffffff;
--bg-card-hover: #f8f9fb;
--bg-input: #eef0f4;
--text-primary: #1a1d23;
--text-secondary: #4a5068;
--text-tertiary: #7c8298;
--border-color: #dde0e9;
--border-light: #e8ebf0;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.1);
}
[data-theme="light"] .sidebar { background: #ffffff; border-right: 1px solid var(--border-color); }
[data-theme="light"] .sidebar .nav-item:hover,
[data-theme="light"] .sidebar .nav-item.active { background: #eef0f4; }
[data-theme="light"] .header { background: #ffffff; border-bottom: 1px solid var(--border-color); }
[data-theme="light"] .card { background: #ffffff; border: 1px solid var(--border-color); }
[data-theme="light"] .modal-content { background: #ffffff; border: 1px solid var(--border-color); }
[data-theme="light"] .terminal-output { background: #1e1e2e; color: #cdd6f4; }
[data-theme="light"] .input,
[data-theme="light"] .select,
[data-theme="light"] textarea { background: #eef0f4; border-color: #dde0e9; color: #1a1d23; }
[data-theme="light"] .badge { opacity: 0.9; }
[data-theme="light"] .table th { background: #eef0f4; }
[data-theme="light"] .table td { border-color: #e8ebf0; }
[data-theme="light"] .metrics-card { background: #ffffff; border: 1px solid var(--border-color); }
body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metrics-card {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
.theme-icon-dark { display: none; }
.theme-icon-light { display: block; }
[data-theme="light"] .theme-icon-dark { display: block; }
[data-theme="light"] .theme-icon-light { display: none; }
.charts-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.25rem;
margin-bottom: 1.25rem;
}
.charts-row--triple { grid-template-columns: repeat(3, 1fr); }
.chart-container {
background: var(--bg-card);
border-radius: 12px;
padding: 1.25rem;
border: 1px solid var(--border-color);
position: relative;
height: 300px;
}
.charts-row--triple .chart-container {
height: auto;
min-height: 200px;
display: flex;
flex-direction: column;
}
.charts-row--triple .chart-container canvas {
max-height: 200px;
align-self: center;
}
.chart-container canvas {
max-height: calc(100% - 2.5rem);
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.chart-header h3 {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
margin: 0;
}
.select-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 6px;
background: var(--bg-input);
border: 1px solid var(--border-color);
color: var(--text-primary);
cursor: pointer;
}
@media (max-width: 1024px) {
.charts-row, .charts-row--triple { grid-template-columns: 1fr; }
}
.notification-wrapper { position: relative; }
.notification-badge {
position: absolute; top: -4px; right: -4px;
background: var(--danger); color: white;
font-size: 0.625rem; font-weight: 700;
min-width: 18px; height: 18px; border-radius: 9px;
display: flex; align-items: center; justify-content: center;
padding: 0 4px; pointer-events: none;
animation: pulse 2s infinite;
}
.notification-panel {
position: absolute; top: calc(100% + 8px); right: 0;
width: 380px; max-height: 480px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: var(--shadow-lg);
z-index: 1000;
display: flex; flex-direction: column; overflow: hidden;
}
.notification-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.notification-panel-header h3 { font-size: 0.8125rem; font-weight: 600; margin: 0; }
.notification-panel-actions { display: flex; gap: 0.25rem; }
.notification-list { flex: 1; overflow-y: auto; max-height: 380px; }
.notification-item {
display: flex; gap: 0.75rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-light);
cursor: pointer; transition: background 0.15s;
}
.notification-item:hover { background: var(--bg-tertiary); }
.notification-item.unread { background: rgba(99, 102, 241, 0.05); }
.notification-item-icon {
flex-shrink: 0; width: 32px; height: 32px; border-radius: 8px;
display: flex; align-items: center; justify-content: center; font-size: 14px;
}
.notification-item-icon.success { background: rgba(34, 197, 94, 0.1); color: var(--success); }
.notification-item-icon.error { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
.notification-item-icon.info { background: rgba(99, 102, 241, 0.1); color: var(--primary); }
.notification-item-content { flex: 1; min-width: 0; }
.notification-item-title { font-size: 0.8125rem; font-weight: 600; color: var(--text-primary); }
.notification-item-message { font-size: 0.75rem; color: var(--text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.notification-item-time { font-size: 0.6875rem; color: var(--text-tertiary); margin-top: 2px; }
.notification-empty { padding: 2rem; text-align: center; color: var(--text-tertiary); font-size: 0.8125rem; }
.terminal-action-toolbar {
display: flex; align-items: center; justify-content: space-between;
padding: 0.5rem 0.75rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-bottom: none;
border-radius: 8px 8px 0 0;
}
.terminal-toolbar-left, .terminal-toolbar-right { display: flex; align-items: center; gap: 0.25rem; }
.terminal-toggle-label {
display: flex; align-items: center; gap: 0.375rem;
font-size: 0.75rem; color: var(--text-secondary); cursor: pointer; user-select: none;
}
.terminal-toggle-label input[type="checkbox"] { width: 14px; height: 14px; accent-color: var(--primary); }
.terminal-search-bar {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color); border-top: none;
}
.terminal-search-bar .input--sm { flex: 1; max-width: 300px; }
.terminal-search-count { font-size: 0.75rem; color: var(--text-tertiary); min-width: 40px; text-align: center; }
.terminal-search-highlight { background: rgba(250, 204, 21, 0.4); border-radius: 2px; }
.terminal-search-highlight.active { background: rgba(250, 204, 21, 0.8); }
.skeleton-pulse {
background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-card-hover) 50%, var(--bg-tertiary) 75%);
background-size: 200% 100%;
animation: skeleton-pulse 1.5s ease-in-out infinite;
border-radius: 6px;
}
@keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.hidden { display: none !important; }
.report-actions {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.report-content {
max-height: 65vh;
overflow-y: auto;
padding: 1.5rem;
background: var(--bg-primary);
border-radius: 8px;
border: 1px solid var(--border-primary);
}
.report-markdown {
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
line-height: 1.7;
color: var(--text-primary);
tab-size: 2;
}
.report-toast {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.report-toast:hover {
text-decoration: underline;
}
/* ─── Secrets Management ─── */
.form-divider {
height: 1px;
background-color: var(--border-primary);
margin: 24px 0 20px;
}
.form-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.form-section-title i,
.form-section-title svg {
width: 16px;
height: 16px;
color: var(--accent);
}
.secrets-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.secret-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background-color: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 8px;
transition: border-color 0.2s;
}
.secret-item:hover {
border-color: var(--border-secondary);
}
.secret-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
min-width: 0;
flex: 1;
}
.secret-value-placeholder {
font-size: 12px;
color: var(--text-muted);
letter-spacing: 2px;
}
.secrets-add-form {
display: flex;
gap: 8px;
align-items: flex-start;
}
.secrets-add-form .input {
flex: 1;
min-width: 0;
}
.secrets-add-form .input:first-child {
max-width: 220px;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
text-transform: uppercase;
}
/* ─── Version History Timeline ─── */
.versions-timeline {
display: flex;
flex-direction: column;
}
.version-item {
display: flex;
gap: 16px;
position: relative;
}
.version-node {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 20px;
}
.version-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: var(--border-secondary);
border: 2px solid var(--bg-secondary);
flex-shrink: 0;
z-index: 1;
}
.version-dot--active {
background-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.version-line {
width: 2px;
flex: 1;
background-color: var(--border-primary);
min-height: 20px;
}
.version-content {
flex: 1;
padding-bottom: 24px;
min-width: 0;
}
.version-header {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.version-number {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
}
.version-date {
font-size: 12px;
color: var(--text-muted);
}
.version-changes {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.version-field-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background-color: var(--accent-glow);
color: var(--accent);
}
.version-changelog {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.version-item--latest .version-content {
padding-bottom: 20px;
}
/* ─── Light theme overrides for new elements ─── */
[data-theme="light"] .secret-item {
background-color: var(--bg-tertiary);
border-color: var(--border-color);
}
[data-theme="light"] .secret-item:hover {
border-color: #c8ccd6;
}
[data-theme="light"] .version-dot {
border-color: var(--bg-secondary);
background-color: #c8ccd6;
}
[data-theme="light"] .version-dot--active {
background-color: var(--accent);
}
[data-theme="light"] .version-line {
background-color: var(--border-color);
}
[data-theme="light"] .version-field-badge {
background-color: rgba(99, 102, 241, 0.1);
}
[data-theme="light"] .form-divider {
background-color: var(--border-color);
}
[data-theme="light"] .secrets-add-form .input {
background-color: var(--bg-input);
border-color: var(--border-color);
color: var(--text-primary);
}
/* ─── Responsive adjustments ─── */
@media (max-width: 768px) {
.secrets-add-form {
flex-direction: column;
}
.secrets-add-form .input:first-child {
max-width: 100%;
}
.version-header {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agents Orchestrator</title> <title>Agents Orchestrator</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="css/styles.css" /> <link rel="stylesheet" href="css/styles.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -104,13 +105,35 @@
<span>Sistema</span> <span>Sistema</span>
</button> </button>
<button class="btn btn--ghost btn--icon-text" id="import-agent-btn" type="button"> <button class="btn btn--ghost btn--icon-text" id="import-agent-btn" type="button">
<i data-lucide="upload"></i> <i data-lucide="file-down"></i>
<span>Importar</span> <span>Importar</span>
</button> </button>
<button class="btn btn--primary btn--icon-text" id="new-agent-btn" type="button"> <button class="btn btn--primary btn--icon-text" id="new-agent-btn" type="button">
<i data-lucide="plus"></i> <i data-lucide="plus"></i>
<span>Novo Agente</span> <span>Novo Agente</span>
</button> </button>
<button id="theme-toggle" class="btn btn--icon" title="Alternar tema">
<i data-lucide="sun" class="theme-icon-light"></i>
<i data-lucide="moon" class="theme-icon-dark"></i>
</button>
<div class="notification-wrapper">
<button id="notification-bell" class="btn btn--icon" title="Notificações">
<i data-lucide="bell"></i>
<span id="notification-badge" class="notification-badge hidden">0</span>
</button>
<div id="notification-panel" class="notification-panel hidden">
<div class="notification-panel-header">
<h3>Notificações</h3>
<div class="notification-panel-actions">
<button id="mark-all-read" class="btn btn--ghost btn--sm">Marcar lidas</button>
<button id="clear-notifications" class="btn btn--ghost btn--sm">Limpar</button>
</div>
</div>
<div id="notification-list" class="notification-list">
<div class="notification-empty">Nenhuma notificação</div>
</div>
</div>
</div>
</div> </div>
</header> </header>
@@ -138,7 +161,7 @@
</article> </article>
<article class="metric-card"> <article class="metric-card">
<div class="metric-card-icon metric-card-icon--purple"> <div class="metric-card-icon metric-card-icon--purple">
<i data-lucide="play-circle"></i> <i data-lucide="circle-play"></i>
</div> </div>
<div class="metric-card-body"> <div class="metric-card-body">
<span class="metric-card-label">Execuções Hoje</span> <span class="metric-card-label">Execuções Hoje</span>
@@ -174,6 +197,46 @@
</article> </article>
</div> </div>
<div class="charts-row">
<div class="chart-container">
<div class="chart-header">
<h3>Execuções</h3>
<select id="chart-period" class="select-sm">
<option value="7">7 dias</option>
<option value="14">14 dias</option>
<option value="30">30 dias</option>
</select>
</div>
<canvas id="executions-chart"></canvas>
</div>
<div class="chart-container">
<div class="chart-header">
<h3>Custo (USD)</h3>
</div>
<canvas id="cost-chart"></canvas>
</div>
</div>
<div class="charts-row charts-row--triple">
<div class="chart-container">
<div class="chart-header">
<h3>Status</h3>
</div>
<canvas id="status-chart"></canvas>
</div>
<div class="chart-container">
<div class="chart-header">
<h3>Top Agentes</h3>
</div>
<canvas id="agents-chart"></canvas>
</div>
<div class="chart-container">
<div class="chart-header">
<h3>Taxa de Sucesso</h3>
</div>
<canvas id="success-rate-chart"></canvas>
</div>
</div>
<div class="dashboard-grid"> <div class="dashboard-grid">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
@@ -424,6 +487,32 @@
</button> </button>
</div> </div>
</div> </div>
<div class="terminal-action-toolbar">
<div class="terminal-toolbar-left">
<button id="terminal-search-toggle" class="btn btn--ghost btn--sm" title="Buscar (Ctrl+F)">
<i data-lucide="search" style="width:14px;height:14px"></i>
</button>
<button id="terminal-download" class="btn btn--ghost btn--sm" title="Baixar saída">
<i data-lucide="download" style="width:14px;height:14px"></i>
</button>
<button id="terminal-copy" class="btn btn--ghost btn--sm" title="Copiar saída">
<i data-lucide="copy" style="width:14px;height:14px"></i>
</button>
</div>
<div class="terminal-toolbar-right">
<label class="terminal-toggle-label">
<input type="checkbox" id="terminal-autoscroll" checked>
<span>Auto-scroll</span>
</label>
</div>
</div>
<div id="terminal-search-bar" class="terminal-search-bar hidden">
<input type="text" id="terminal-search-input" placeholder="Buscar no terminal..." class="input input--sm">
<span id="terminal-search-count" class="terminal-search-count">0/0</span>
<button id="terminal-search-prev" class="btn btn--ghost btn--sm"></button>
<button id="terminal-search-next" class="btn btn--ghost btn--sm"></button>
<button id="terminal-search-close" class="btn btn--ghost btn--sm"></button>
</div>
<div class="approval-notification" id="approval-notification" hidden></div> <div class="approval-notification" id="approval-notification" hidden></div>
<div class="terminal-output" id="terminal-output" role="log" aria-live="polite" aria-label="Saída do terminal"> <div class="terminal-output" id="terminal-output" role="log" aria-live="polite" aria-label="Saída do terminal">
<div class="terminal-welcome"> <div class="terminal-welcome">
@@ -471,6 +560,10 @@
</select> </select>
</div> </div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<button id="history-export-csv" class="btn btn--ghost btn--sm" title="Exportar CSV">
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i>
Exportar
</button>
<button class="btn btn-ghost btn-sm btn-danger" id="history-clear-btn" type="button"> <button class="btn btn-ghost btn-sm btn-danger" id="history-clear-btn" type="button">
<i data-lucide="trash-2"></i> <i data-lucide="trash-2"></i>
Limpar Histórico Limpar Histórico
@@ -552,6 +645,64 @@
<span class="system-info-label">Tempo Online</span> <span class="system-info-label">Tempo Online</span>
<span class="system-info-value font-mono" id="info-uptime">Carregando...</span> <span class="system-info-value font-mono" id="info-uptime">Carregando...</span>
</li> </li>
<li class="system-info-item">
<span class="system-info-label">Tema Atual</span>
<span class="system-info-value" id="info-current-theme">Escuro</span>
</li>
</ul>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Atalhos do Teclado</h2>
</div>
<div class="card-body">
<ul class="system-info-list">
<li class="system-info-item">
<span class="system-info-label">Fechar modal</span>
<span class="system-info-value font-mono">Esc</span>
</li>
<li class="system-info-item">
<span class="system-info-label">Novo agente (em Agentes)</span>
<span class="system-info-value font-mono">N</span>
</li>
<li class="system-info-item">
<span class="system-info-label">Dashboard</span>
<span class="system-info-value font-mono">1</span>
</li>
<li class="system-info-item">
<span class="system-info-label">Agentes</span>
<span class="system-info-value font-mono">2</span>
</li>
<li class="system-info-item">
<span class="system-info-label">Tarefas</span>
<span class="system-info-value font-mono">3</span>
</li>
<li class="system-info-item">
<span class="system-info-label">Agendamentos</span>
<span class="system-info-value font-mono">4</span>
</li>
<li class="system-info-item">
<span class="system-info-label">Pipelines</span>
<span class="system-info-value font-mono">5</span>
</li>
<li class="system-info-item">
<span class="system-info-label">Terminal</span>
<span class="system-info-value font-mono">6</span>
</li>
<li class="system-info-item">
<span class="system-info-label">Histórico</span>
<span class="system-info-value font-mono">7</span>
</li>
<li class="system-info-item">
<span class="system-info-label">Webhooks</span>
<span class="system-info-value font-mono">8</span>
</li>
<li class="system-info-item">
<span class="system-info-label">Configurações</span>
<span class="system-info-value font-mono">9</span>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -687,6 +838,28 @@
</div> </div>
</div> </div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Retry em caso de falha</label>
<div class="toggle-wrapper">
<input type="checkbox" class="toggle-input" id="agent-retry-toggle" name="retryOnFailure" role="switch" />
<label class="toggle-label" for="agent-retry-toggle">
<span class="toggle-thumb"></span>
<span class="toggle-text-on">Sim</span>
<span class="toggle-text-off">Não</span>
</label>
</div>
</div>
<div class="form-group" id="agent-retry-max-group" style="display:none;">
<label class="form-label" for="agent-retry-max">Máximo de tentativas</label>
<select class="select" id="agent-retry-max" name="maxRetries">
<option value="1">1</option>
<option value="2">2</option>
<option value="3" selected>3</option>
</select>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="agent-tags-input">Tags</label> <label class="form-label" for="agent-tags-input">Tags</label>
<div class="tags-input-wrapper" id="agent-tags-wrapper"> <div class="tags-input-wrapper" id="agent-tags-wrapper">
@@ -701,6 +874,24 @@
</div> </div>
<input type="hidden" id="agent-tags" name="tags" value="[]" /> <input type="hidden" id="agent-tags" name="tags" value="[]" />
</div> </div>
<div class="form-divider"></div>
<div class="form-section" id="agent-secrets-section" hidden>
<h3 class="form-section-title">
<i data-lucide="key-round"></i>
Variáveis de Ambiente (Secrets)
</h3>
<p class="form-hint mb-12">Secrets são injetados como variáveis de ambiente na execução. Valores nunca são exibidos após salvos.</p>
<div id="agent-secrets-list" class="secrets-list"></div>
<div class="secrets-add-form">
<input type="text" class="input" id="agent-secret-name" placeholder="NOME_DA_VARIAVEL" autocomplete="off" />
<input type="password" class="input" id="agent-secret-value" placeholder="valor secreto" autocomplete="new-password" />
<button type="button" class="btn btn--primary btn--sm" id="agent-secret-add-btn">
<i data-lucide="plus"></i>
Adicionar
</button>
</div>
</div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -710,6 +901,23 @@
</div> </div>
</div> </div>
<div class="modal-overlay" id="agent-versions-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="agent-versions-title" hidden>
<div class="modal modal--lg">
<div class="modal-header">
<h2 class="modal-title" id="agent-versions-title">Histórico de Versões</h2>
<button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="agent-versions-modal-overlay">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body" id="agent-versions-content">
<div class="empty-state">
<i data-lucide="history"></i>
<p>Carregando versões...</p>
</div>
</div>
</div>
</div>
<div class="modal-overlay" id="execute-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="execute-modal-title" hidden> <div class="modal-overlay" id="execute-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="execute-modal-title" hidden>
<div class="modal modal--md"> <div class="modal modal--md">
<div class="modal-header"> <div class="modal-header">
@@ -766,6 +974,19 @@
></textarea> ></textarea>
</div> </div>
<div class="form-group">
<label class="form-label">Arquivos de Contexto</label>
<div class="dropzone" id="execute-dropzone">
<input type="file" id="execute-files" multiple hidden />
<div class="dropzone-content">
<i data-lucide="upload-cloud"></i>
<p>Arraste arquivos aqui ou <button type="button" class="dropzone-browse">selecione</button></p>
<span class="dropzone-hint">Até 20 arquivos, 10MB cada</span>
</div>
<ul class="dropzone-list" id="execute-file-list"></ul>
</div>
</div>
<div class="quick-templates"> <div class="quick-templates">
<p class="quick-templates-label">Templates rápidos</p> <p class="quick-templates-label">Templates rápidos</p>
<div class="quick-templates-grid"> <div class="quick-templates-grid">
@@ -778,11 +999,11 @@
<span>Revisão OWASP</span> <span>Revisão OWASP</span>
</button> </button>
<button class="template-btn" type="button" data-template="Refatorar o código para melhorar legibilidade, manutenibilidade e aderência às boas práticas. Manter comportamento funcional intacto."> <button class="template-btn" type="button" data-template="Refatorar o código para melhorar legibilidade, manutenibilidade e aderência às boas práticas. Manter comportamento funcional intacto.">
<i data-lucide="wand-2"></i> <i data-lucide="wand"></i>
<span>Refatorar Código</span> <span>Refatorar Código</span>
</button> </button>
<button class="template-btn" type="button" data-template="Escrever testes unitários e de integração abrangentes. Garantir cobertura dos casos de sucesso, erro e edge cases."> <button class="template-btn" type="button" data-template="Escrever testes unitários e de integração abrangentes. Garantir cobertura dos casos de sucesso, erro e edge cases.">
<i data-lucide="test-tube-2"></i> <i data-lucide="test-tube"></i>
<span>Escrever Testes</span> <span>Escrever Testes</span>
</button> </button>
<button class="template-btn" type="button" data-template="Documentar o código com JSDoc/docstrings, README atualizado, exemplos de uso e descrição de APIs públicas."> <button class="template-btn" type="button" data-template="Documentar o código com JSDoc/docstrings, README atualizado, exemplos de uso e descrição de APIs públicas.">
@@ -966,6 +1187,18 @@
</label> </label>
<textarea class="textarea" id="pipeline-execute-input" rows="4" placeholder="Descreva a tarefa inicial para o pipeline..."></textarea> <textarea class="textarea" id="pipeline-execute-input" rows="4" placeholder="Descreva a tarefa inicial para o pipeline..."></textarea>
</div> </div>
<div class="form-group">
<label class="form-label">Arquivos de Contexto</label>
<div class="dropzone" id="pipeline-execute-dropzone">
<input type="file" id="pipeline-execute-files" multiple hidden />
<div class="dropzone-content">
<i data-lucide="upload-cloud"></i>
<p>Arraste arquivos aqui ou <button type="button" class="dropzone-browse">selecione</button></p>
<span class="dropzone-hint">Até 20 arquivos, 10MB cada</span>
</div>
<ul class="dropzone-list" id="pipeline-execute-file-list"></ul>
</div>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn--ghost" type="button" data-modal-close="pipeline-execute-modal-overlay">Cancelar</button> <button class="btn btn--ghost" type="button" data-modal-close="pipeline-execute-modal-overlay">Cancelar</button>
@@ -994,7 +1227,7 @@
<div class="modal modal--sm"> <div class="modal modal--sm">
<div class="modal-header"> <div class="modal-header">
<div class="confirm-modal-icon" id="confirm-modal-icon"> <div class="confirm-modal-icon" id="confirm-modal-icon">
<i data-lucide="alert-triangle"></i> <i data-lucide="triangle-alert"></i>
</div> </div>
<h2 class="modal-title" id="confirm-modal-title">Confirmar Ação</h2> <h2 class="modal-title" id="confirm-modal-title">Confirmar Ação</h2>
<button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="confirm-modal-overlay"> <button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="confirm-modal-overlay">
@@ -1060,7 +1293,7 @@
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn--ghost" type="button" data-modal-close="import-modal-overlay">Cancelar</button> <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"> <button class="btn btn--primary btn--icon-text" type="button" id="import-confirm-btn">
<i data-lucide="upload"></i> <i data-lucide="file-down"></i>
<span>Importar</span> <span>Importar</span>
</button> </button>
</div> </div>
@@ -1116,8 +1349,9 @@
<div class="toast-container" id="toast-container" aria-live="assertive" aria-atomic="false" role="region" aria-label="Notificações"></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> <script src="js/lucide.js"></script>
<script src="js/api.js"></script> <script src="js/api.js"></script>
<script src="js/utils.js"></script>
<script src="js/components/toast.js"></script> <script src="js/components/toast.js"></script>
<script src="js/components/modal.js"></script> <script src="js/components/modal.js"></script>
<script src="js/components/terminal.js"></script> <script src="js/components/terminal.js"></script>
@@ -1129,9 +1363,10 @@
<script src="js/components/settings.js"></script> <script src="js/components/settings.js"></script>
<script src="js/components/history.js"></script> <script src="js/components/history.js"></script>
<script src="js/components/webhooks.js"></script> <script src="js/components/webhooks.js"></script>
<script src="js/components/notifications.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
<script> <script>
lucide.createIcons(); Utils.refreshIcons();
App.init(); App.init();
</script> </script>
</body> </body>

View File

@@ -38,11 +38,27 @@ const API = {
create(data) { return API.request('POST', '/agents', data); }, create(data) { return API.request('POST', '/agents', data); },
update(id, data) { return API.request('PUT', `/agents/${id}`, data); }, update(id, data) { return API.request('PUT', `/agents/${id}`, data); },
delete(id) { return API.request('DELETE', `/agents/${id}`); }, delete(id) { return API.request('DELETE', `/agents/${id}`); },
execute(id, task, instructions) { return API.request('POST', `/agents/${id}/execute`, { task, instructions }); }, execute(id, task, instructions, contextFiles) {
const body = { task, instructions };
if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
return API.request('POST', `/agents/${id}/execute`, body);
},
cancel(id, executionId) { return API.request('POST', `/agents/${id}/cancel/${executionId}`); }, cancel(id, executionId) { return API.request('POST', `/agents/${id}/cancel/${executionId}`); },
continue(id, sessionId, message) { return API.request('POST', `/agents/${id}/continue`, { sessionId, message }); }, continue(id, sessionId, message) { return API.request('POST', `/agents/${id}/continue`, { sessionId, message }); },
export(id) { return API.request('GET', `/agents/${id}/export`); }, export(id) { return API.request('GET', `/agents/${id}/export`); },
import(data) { return API.request('POST', '/agents/import', data); }, import(data) { return API.request('POST', '/agents/import', data); },
duplicate(id) { return API.request('POST', `/agents/${id}/duplicate`); },
},
secrets: {
list(agentId) { return API.request('GET', `/agents/${agentId}/secrets`); },
create(agentId, data) { return API.request('POST', `/agents/${agentId}/secrets`, data); },
delete(agentId, name) { return API.request('DELETE', `/agents/${agentId}/secrets/${encodeURIComponent(name)}`); },
},
versions: {
list(agentId) { return API.request('GET', `/agents/${agentId}/versions`); },
restore(agentId, version) { return API.request('POST', `/agents/${agentId}/versions/${version}/restore`); },
}, },
tasks: { tasks: {
@@ -66,9 +82,10 @@ const API = {
create(data) { return API.request('POST', '/pipelines', data); }, create(data) { return API.request('POST', '/pipelines', data); },
update(id, data) { return API.request('PUT', `/pipelines/${id}`, data); }, update(id, data) { return API.request('PUT', `/pipelines/${id}`, data); },
delete(id) { return API.request('DELETE', `/pipelines/${id}`); }, delete(id) { return API.request('DELETE', `/pipelines/${id}`); },
execute(id, input, workingDirectory) { execute(id, input, workingDirectory, contextFiles) {
const body = { input }; const body = { input };
if (workingDirectory) body.workingDirectory = workingDirectory; if (workingDirectory) body.workingDirectory = workingDirectory;
if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
return API.request('POST', `/pipelines/${id}/execute`, body); return API.request('POST', `/pipelines/${id}/execute`, body);
}, },
cancel(id) { return API.request('POST', `/pipelines/${id}/cancel`); }, cancel(id) { return API.request('POST', `/pipelines/${id}/cancel`); },
@@ -81,10 +98,19 @@ const API = {
create(data) { return API.request('POST', '/webhooks', data); }, create(data) { return API.request('POST', '/webhooks', data); },
update(id, data) { return API.request('PUT', `/webhooks/${id}`, data); }, update(id, data) { return API.request('PUT', `/webhooks/${id}`, data); },
delete(id) { return API.request('DELETE', `/webhooks/${id}`); }, delete(id) { return API.request('DELETE', `/webhooks/${id}`); },
test(id) { return API.request('POST', `/webhooks/${id}/test`); },
}, },
stats: { stats: {
costs(days) { return API.request('GET', `/stats/costs${days ? '?days=' + days : ''}`); }, costs(days) { return API.request('GET', `/stats/costs${days ? '?days=' + days : ''}`); },
charts(days) { return API.request('GET', `/stats/charts${days ? '?days=' + days : ''}`); },
},
notifications: {
list() { return API.request('GET', '/notifications'); },
markRead(id) { return API.request('POST', `/notifications/${id}/read`); },
markAllRead() { return API.request('POST', '/notifications/read-all'); },
clear() { return API.request('DELETE', '/notifications'); },
}, },
system: { system: {
@@ -98,6 +124,27 @@ const API = {
save(data) { return API.request('PUT', '/settings', data); }, save(data) { return API.request('PUT', '/settings', data); },
}, },
uploads: {
async send(files) {
const form = new FormData();
for (const f of files) form.append('files', f);
const response = await fetch('/api/uploads', {
method: 'POST',
headers: { 'X-Client-Id': API.clientId },
body: form,
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Erro no upload');
return data;
},
},
reports: {
list() { return API.request('GET', '/reports'); },
get(filename) { return API.request('GET', `/reports/${encodeURIComponent(filename)}`); },
delete(filename) { return API.request('DELETE', `/reports/${encodeURIComponent(filename)}`); },
},
executions: { executions: {
recent(limit = 20) { return API.request('GET', `/executions/recent?limit=${limit}`); }, recent(limit = 20) { return API.request('GET', `/executions/recent?limit=${limit}`); },
history(params = {}) { history(params = {}) {
@@ -107,6 +154,19 @@ const API = {
get(id) { return API.request('GET', `/executions/history/${id}`); }, get(id) { return API.request('GET', `/executions/history/${id}`); },
delete(id) { return API.request('DELETE', `/executions/history/${id}`); }, delete(id) { return API.request('DELETE', `/executions/history/${id}`); },
clearAll() { return API.request('DELETE', '/executions/history'); }, clearAll() { return API.request('DELETE', '/executions/history'); },
retry(id) { return API.request('POST', `/executions/${id}/retry`); },
async exportCsv() {
const response = await fetch('/api/executions/export', {
headers: { 'X-Client-Id': API.clientId },
});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `execucoes_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
},
}, },
}; };

View File

@@ -5,6 +5,8 @@ const App = {
wsReconnectTimer: null, wsReconnectTimer: null,
_initialized: false, _initialized: false,
_lastAgentName: '', _lastAgentName: '',
_executeDropzone: null,
_pipelineDropzone: null,
sectionTitles: { sectionTitles: {
dashboard: 'Dashboard', dashboard: 'Dashboard',
@@ -18,18 +20,50 @@ const App = {
settings: 'Configurações', settings: 'Configurações',
}, },
sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'settings'],
init() { init() {
if (App._initialized) return; if (App._initialized) return;
App._initialized = true; App._initialized = true;
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
App.setupNavigation(); App.setupNavigation();
App.setupWebSocket(); App.setupWebSocket();
App.setupEventListeners(); App.setupEventListeners();
App.setupKeyboardShortcuts(); App.setupKeyboardShortcuts();
App.navigateTo('dashboard');
App._executeDropzone = Utils.initDropzone('execute-dropzone', 'execute-files', 'execute-file-list');
App._pipelineDropzone = Utils.initDropzone('pipeline-execute-dropzone', 'pipeline-execute-files', 'pipeline-execute-file-list');
const initialSection = location.hash.replace('#', '') || 'dashboard';
App.navigateTo(App.sections.includes(initialSection) ? initialSection : 'dashboard');
App.startPeriodicRefresh(); App.startPeriodicRefresh();
if (window.lucide) lucide.createIcons(); window.addEventListener('hashchange', () => {
const section = location.hash.replace('#', '') || 'dashboard';
if (App.sections.includes(section)) App.navigateTo(section);
});
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
Utils.refreshIcons();
});
}
if (typeof NotificationsUI !== 'undefined') NotificationsUI.init();
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
Utils.refreshIcons();
}, },
setupNavigation() { setupNavigation() {
@@ -47,6 +81,10 @@ const App = {
}, },
navigateTo(section) { navigateTo(section) {
if (location.hash !== `#${section}`) {
history.pushState(null, '', `#${section}`);
}
document.querySelectorAll('.section').forEach((el) => { document.querySelectorAll('.section').forEach((el) => {
const isActive = el.id === section; const isActive = el.id === section;
el.classList.toggle('active', isActive); el.classList.toggle('active', isActive);
@@ -138,8 +176,18 @@ const App = {
case 'execution_output': { case 'execution_output': {
Terminal.stopProcessing(); Terminal.stopProcessing();
const evtType = data.data?.type || 'chunk';
const content = data.data?.content || ''; const content = data.data?.content || '';
if (content) { if (!content) break;
if (evtType === 'tool') {
Terminal.addLine(`${content}`, 'tool', data.executionId);
} else if (evtType === 'turn') {
Terminal.addLine(`── ${content} ──`, 'turn', data.executionId);
} else if (evtType === 'system') {
Terminal.addLine(content, 'system', data.executionId);
} else if (evtType === 'stderr') {
Terminal.addLine(content, 'stderr', data.executionId);
} else {
Terminal.addLine(content, 'default', data.executionId); Terminal.addLine(content, 'default', data.executionId);
} }
App._updateActiveBadge(); App._updateActiveBadge();
@@ -174,6 +222,11 @@ const App = {
} }
} }
if (typeof NotificationsUI !== 'undefined') {
NotificationsUI.loadCount();
NotificationsUI.showBrowserNotification('Execução concluída', data.agentName || 'Agente');
}
Toast.success('Execução concluída'); Toast.success('Execução concluída');
App.refreshCurrentSection(); App.refreshCurrentSection();
App._updateActiveBadge(); App._updateActiveBadge();
@@ -183,14 +236,39 @@ const App = {
case 'execution_error': case 'execution_error':
Terminal.stopProcessing(); Terminal.stopProcessing();
Terminal.addLine(data.data?.error || 'Erro na execução', 'error', data.executionId); Terminal.addLine(data.data?.error || 'Erro na execução', 'error', data.executionId);
if (typeof NotificationsUI !== 'undefined') {
NotificationsUI.loadCount();
NotificationsUI.showBrowserNotification('Execução falhou', data.agentName || 'Agente');
}
Toast.error(`Erro na execução: ${data.data?.error || 'desconhecido'}`); Toast.error(`Erro na execução: ${data.data?.error || 'desconhecido'}`);
App._updateActiveBadge(); App._updateActiveBadge();
break; break;
case 'execution_retry':
Terminal.stopProcessing();
Terminal.addLine(
`Retry ${data.attempt || '?'}/${data.maxRetries || '?'} — próxima tentativa em ${data.nextRetryIn || '?'}s. Motivo: ${data.reason || 'erro na execução'}`,
'warning',
data.executionId
);
break;
case 'pipeline_step_output': { case 'pipeline_step_output': {
Terminal.stopProcessing(); Terminal.stopProcessing();
const stepEvtType = data.data?.type || 'chunk';
const stepContent = data.data?.content || ''; const stepContent = data.data?.content || '';
if (stepContent) { if (!stepContent) break;
if (stepEvtType === 'tool') {
Terminal.addLine(`${stepContent}`, 'tool', data.executionId);
} else if (stepEvtType === 'turn') {
Terminal.addLine(`── ${stepContent} ──`, 'turn', data.executionId);
} else if (stepEvtType === 'system') {
Terminal.addLine(stepContent, 'system', data.executionId);
} else if (stepEvtType === 'stderr') {
Terminal.addLine(stepContent, 'stderr', data.executionId);
} else {
Terminal.addLine(stepContent, 'default', data.executionId); Terminal.addLine(stepContent, 'default', data.executionId);
} }
break; break;
@@ -241,9 +319,58 @@ const App = {
case 'pipeline_status': case 'pipeline_status':
break; break;
case 'report_generated':
if (data.reportFile) {
Terminal.addLine(`📄 Relatório gerado: ${data.reportFile}`, 'info');
App._openReport(data.reportFile);
}
break;
} }
}, },
async _openReport(filename) {
try {
const data = await API.request('GET', `/reports/${encodeURIComponent(filename)}`);
if (!data || !data.content) return;
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;
title.textContent = 'Relatório de Execução';
content.innerHTML = `
<div class="report-actions">
<button class="btn btn-ghost btn-sm" id="report-copy-btn">
<i data-lucide="copy"></i> Copiar
</button>
<button class="btn btn-ghost btn-sm" id="report-download-btn">
<i data-lucide="download"></i> Download .md
</button>
</div>
<div class="report-content"><pre class="report-markdown">${Utils.escapeHtml(data.content)}</pre></div>`;
Utils.refreshIcons(content);
document.getElementById('report-copy-btn')?.addEventListener('click', () => {
navigator.clipboard.writeText(data.content).then(() => Toast.success('Relatório copiado'));
});
document.getElementById('report-download-btn')?.addEventListener('click', () => {
const blob = new Blob([data.content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
Toast.success('Download iniciado');
});
Modal.open('execution-detail-modal-overlay');
} catch (e) {}
},
_showApprovalNotification(pipelineId, stepIndex, agentName) { _showApprovalNotification(pipelineId, stepIndex, agentName) {
const container = document.getElementById('approval-notification'); const container = document.getElementById('approval-notification');
if (!container) return; if (!container) return;
@@ -253,7 +380,7 @@ const App = {
<div class="approval-icon"><i data-lucide="shield-alert"></i></div> <div class="approval-icon"><i data-lucide="shield-alert"></i></div>
<div class="approval-text"> <div class="approval-text">
<strong>Aprovação necessária</strong> <strong>Aprovação necessária</strong>
<span>Passo ${stepIndex + 1} (${agentName || 'agente'}) aguardando autorização</span> <span>Passo ${stepIndex + 1} (${Utils.escapeHtml(agentName) || 'agente'}) aguardando autorização</span>
</div> </div>
<div class="approval-actions"> <div class="approval-actions">
<button class="btn btn--primary btn--sm" id="approval-approve-btn" type="button">Aprovar</button> <button class="btn btn--primary btn--sm" id="approval-approve-btn" type="button">Aprovar</button>
@@ -264,7 +391,7 @@ const App = {
container.hidden = false; container.hidden = false;
container.dataset.pipelineId = pipelineId; container.dataset.pipelineId = pipelineId;
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
document.getElementById('approval-approve-btn')?.addEventListener('click', () => { document.getElementById('approval-approve-btn')?.addEventListener('click', () => {
App._handleApproval(pipelineId, true); App._handleApproval(pipelineId, true);
@@ -538,6 +665,8 @@ const App = {
case 'edit': AgentsUI.openEditModal(id); break; case 'edit': AgentsUI.openEditModal(id); break;
case 'export': AgentsUI.export(id); break; case 'export': AgentsUI.export(id); break;
case 'delete': AgentsUI.delete(id); break; case 'delete': AgentsUI.delete(id); break;
case 'duplicate': AgentsUI.duplicate(id); break;
case 'versions': AgentsUI.openVersionsModal(id); break;
} }
}); });
@@ -598,6 +727,7 @@ const App = {
switch (action) { switch (action) {
case 'view-execution': HistoryUI.viewDetail(id); break; case 'view-execution': HistoryUI.viewDetail(id); break;
case 'delete-execution': HistoryUI.deleteExecution(id); break; case 'delete-execution': HistoryUI.deleteExecution(id); break;
case 'retry': HistoryUI.retryExecution(id); break;
} }
}); });
@@ -610,6 +740,8 @@ const App = {
case 'delete-webhook': WebhooksUI.delete(id); break; case 'delete-webhook': WebhooksUI.delete(id); break;
case 'copy-webhook-url': WebhooksUI.copyUrl(url); break; case 'copy-webhook-url': WebhooksUI.copyUrl(url); break;
case 'copy-webhook-curl': WebhooksUI.copyCurl(id); break; case 'copy-webhook-curl': WebhooksUI.copyCurl(id); break;
case 'edit-webhook': WebhooksUI.openEditModal(id); break;
case 'test-webhook': WebhooksUI.test(id); break;
} }
}); });
@@ -661,8 +793,8 @@ const App = {
hidden.value = JSON.stringify(tags); hidden.value = JSON.stringify(tags);
chips.innerHTML = tags.map((t) => ` chips.innerHTML = tags.map((t) => `
<span class="tag-chip"> <span class="tag-chip">
${t} ${Utils.escapeHtml(t)}
<button type="button" class="tag-remove" data-tag="${t}" aria-label="Remover tag ${t}">×</button> <button type="button" class="tag-remove" data-tag="${Utils.escapeHtml(t)}" aria-label="Remover tag ${Utils.escapeHtml(t)}">×</button>
</span> </span>
`).join(''); `).join('');
}; };
@@ -714,11 +846,20 @@ const App = {
const selectEl = document.getElementById('execute-agent-select'); const selectEl = document.getElementById('execute-agent-select');
const agentName = selectEl?.selectedOptions[0]?.text || 'Agente'; const agentName = selectEl?.selectedOptions[0]?.text || 'Agente';
let contextFiles = null;
const dropzone = App._executeDropzone;
if (dropzone && dropzone.getFiles().length > 0) {
Toast.info('Fazendo upload dos arquivos...');
const uploadResult = await API.uploads.send(dropzone.getFiles());
contextFiles = uploadResult.files;
}
Terminal.disableChat(); Terminal.disableChat();
App._lastAgentName = agentName; App._lastAgentName = agentName;
await API.agents.execute(agentId, task, instructions); await API.agents.execute(agentId, task, instructions, contextFiles);
if (dropzone) dropzone.reset();
Modal.close('execute-modal-overlay'); Modal.close('execute-modal-overlay');
App.navigateTo('terminal'); App.navigateTo('terminal');
Toast.info('Execução iniciada'); Toast.info('Execução iniciada');
@@ -768,14 +909,32 @@ const App = {
return; return;
} }
const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName); const isInInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName);
if (isTyping) return; if (isInInput) return;
if (e.key === 'n' || e.key === 'N') { if (e.key === 'n' || e.key === 'N') {
if (App.currentSection === 'agents') { if (App.currentSection === 'agents') {
AgentsUI.openCreateModal(); AgentsUI.openCreateModal();
} }
} }
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
const sectionKeys = {
'1': 'dashboard',
'2': 'agents',
'3': 'tasks',
'4': 'schedules',
'5': 'pipelines',
'6': 'terminal',
'7': 'history',
'8': 'webhooks',
'9': 'settings',
};
if (sectionKeys[e.key]) {
e.preventDefault();
App.navigateTo(sectionKeys[e.key]);
}
}
}); });
}, },

View File

@@ -48,7 +48,7 @@ const AgentsUI = {
grid.appendChild(fragment); grid.appendChild(fragment);
if (window.lucide) lucide.createIcons({ nodes: [grid] }); Utils.refreshIcons(grid);
}, },
filter(searchText, statusFilter) { filter(searchText, statusFilter) {
@@ -76,7 +76,7 @@ const AgentsUI = {
const model = (agent.config && agent.config.model) || agent.model || 'claude-sonnet-4-6'; 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 updatedAt = AgentsUI.formatDate(agent.updated_at || agent.updatedAt || agent.created_at || agent.createdAt);
const tags = Array.isArray(agent.tags) && agent.tags.length > 0 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>` ? `<div class="agent-tags">${agent.tags.map((t) => `<span class="tag-chip tag-chip--sm">${Utils.escapeHtml(t)}</span>`).join('')}</div>`
: ''; : '';
return ` return `
@@ -87,12 +87,12 @@ const AgentsUI = {
<span>${initials}</span> <span>${initials}</span>
</div> </div>
<div class="agent-info"> <div class="agent-info">
<h3 class="agent-name">${name}</h3> <h3 class="agent-name">${Utils.escapeHtml(name)}</h3>
<span class="badge ${statusClass}">${statusLabel}</span> <span class="badge ${statusClass}">${statusLabel}</span>
</div> </div>
</div> </div>
${agent.description ? `<p class="agent-description">${agent.description}</p>` : ''} ${agent.description ? `<p class="agent-description">${Utils.escapeHtml(agent.description)}</p>` : ''}
${tags} ${tags}
<div class="agent-meta"> <div class="agent-meta">
@@ -112,16 +112,23 @@ const AgentsUI = {
<i data-lucide="play"></i> <i data-lucide="play"></i>
Executar Executar
</button> </button>
<button class="btn btn-ghost btn-sm" data-action="edit" data-id="${agent.id}"> <div class="agent-actions-icons">
<i data-lucide="pencil"></i> <button class="btn btn-ghost btn-icon btn-sm" data-action="edit" data-id="${agent.id}" title="Editar agente">
Editar <i data-lucide="pencil"></i>
</button> </button>
<button class="btn btn-ghost btn-icon btn-sm" data-action="export" data-id="${agent.id}" title="Exportar agente"> <button class="btn btn-ghost btn-icon btn-sm" data-action="duplicate" data-id="${agent.id}" title="Duplicar agente">
<i data-lucide="download"></i> <i data-lucide="copy"></i>
</button> </button>
<button class="btn btn-ghost btn-icon btn-sm btn-danger" data-action="delete" data-id="${agent.id}" title="Excluir agente"> <button class="btn btn-ghost btn-icon btn-sm" data-action="export" data-id="${agent.id}" title="Exportar agente">
<i data-lucide="trash-2"></i> <i data-lucide="download"></i>
</button> </button>
<button class="btn btn-ghost btn-icon btn-sm" data-action="versions" data-id="${agent.id}" title="Histórico de versões">
<i data-lucide="history"></i>
</button>
<button class="btn btn-ghost btn-icon btn-sm btn-danger" data-action="delete" data-id="${agent.id}" title="Excluir agente">
<i data-lucide="trash-2"></i>
</button>
</div>
</div> </div>
</div> </div>
`; `;
@@ -155,7 +162,23 @@ const AgentsUI = {
const permissionMode = document.getElementById('agent-permission-mode'); const permissionMode = document.getElementById('agent-permission-mode');
if (permissionMode) permissionMode.value = ''; if (permissionMode) permissionMode.value = '';
const retryToggle = document.getElementById('agent-retry-toggle');
if (retryToggle) retryToggle.checked = false;
const retryMaxGroup = document.getElementById('agent-retry-max-group');
if (retryMaxGroup) retryMaxGroup.style.display = 'none';
const retryMax = document.getElementById('agent-retry-max');
if (retryMax) retryMax.value = '3';
const secretsSection = document.getElementById('agent-secrets-section');
if (secretsSection) secretsSection.hidden = true;
const secretsList = document.getElementById('agent-secrets-list');
if (secretsList) secretsList.innerHTML = '';
Modal.open('agent-modal-overlay'); Modal.open('agent-modal-overlay');
AgentsUI._setupModalListeners();
}, },
async openEditModal(agentId) { async openEditModal(agentId) {
@@ -196,7 +219,23 @@ const AgentsUI = {
).join(''); ).join('');
} }
const retryToggle = document.getElementById('agent-retry-toggle');
const retryOnFailure = agent.config && agent.config.retryOnFailure;
if (retryToggle) retryToggle.checked = !!retryOnFailure;
const retryMaxGroup = document.getElementById('agent-retry-max-group');
if (retryMaxGroup) retryMaxGroup.style.display = retryOnFailure ? '' : 'none';
const retryMax = document.getElementById('agent-retry-max');
if (retryMax) retryMax.value = (agent.config && agent.config.maxRetries) || '3';
const secretsSection = document.getElementById('agent-secrets-section');
if (secretsSection) secretsSection.hidden = false;
AgentsUI._loadSecrets(agent.id);
Modal.open('agent-modal-overlay'); Modal.open('agent-modal-overlay');
AgentsUI._setupModalListeners();
} catch (err) { } catch (err) {
Toast.error(`Erro ao carregar agente: ${err.message}`); Toast.error(`Erro ao carregar agente: ${err.message}`);
} }
@@ -234,6 +273,8 @@ const AgentsUI = {
allowedTools: document.getElementById('agent-allowed-tools')?.value.trim() || '', allowedTools: document.getElementById('agent-allowed-tools')?.value.trim() || '',
maxTurns: parseInt(document.getElementById('agent-max-turns')?.value) || 0, maxTurns: parseInt(document.getElementById('agent-max-turns')?.value) || 0,
permissionMode: document.getElementById('agent-permission-mode')?.value || '', permissionMode: document.getElementById('agent-permission-mode')?.value || '',
retryOnFailure: !!document.getElementById('agent-retry-toggle')?.checked,
maxRetries: parseInt(document.getElementById('agent-retry-max')?.value) || 3,
}, },
}; };
@@ -279,7 +320,7 @@ const AgentsUI = {
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' + selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
allAgents allAgents
.filter((a) => a.status === 'active') .filter((a) => a.status === 'active')
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`) .map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
.join(''); .join('');
selectEl.value = agentId; selectEl.value = agentId;
@@ -294,6 +335,8 @@ const AgentsUI = {
const instructionsEl = document.getElementById('execute-instructions'); const instructionsEl = document.getElementById('execute-instructions');
if (instructionsEl) instructionsEl.value = ''; if (instructionsEl) instructionsEl.value = '';
if (App._executeDropzone) App._executeDropzone.reset();
AgentsUI._loadSavedTasks(); AgentsUI._loadSavedTasks();
Modal.open('execute-modal-overlay'); Modal.open('execute-modal-overlay');
@@ -311,7 +354,7 @@ const AgentsUI = {
savedTaskSelect.innerHTML = '<option value="">Digitar manualmente...</option>' + savedTaskSelect.innerHTML = '<option value="">Digitar manualmente...</option>' +
tasks.map((t) => { tasks.map((t) => {
const label = t.category ? `[${t.category.toUpperCase()}] ${t.name}` : t.name; const label = t.category ? `[${t.category.toUpperCase()}] ${t.name}` : t.name;
return `<option value="${t.id}">${label}</option>`; return `<option value="${t.id}">${Utils.escapeHtml(label)}</option>`;
}).join(''); }).join('');
AgentsUI._savedTasksCache = tasks; AgentsUI._savedTasksCache = tasks;
} catch { } catch {
@@ -322,6 +365,16 @@ const AgentsUI = {
_savedTasksCache: [], _savedTasksCache: [],
async duplicate(agentId) {
try {
await API.agents.duplicate(agentId);
Toast.success('Agente duplicado com sucesso');
await AgentsUI.load();
} catch (err) {
Toast.error(`Erro ao duplicar agente: ${err.message}`);
}
},
async export(agentId) { async export(agentId) {
try { try {
const data = await API.agents.export(agentId); const data = await API.agents.export(agentId);
@@ -393,6 +446,223 @@ const AgentsUI = {
minute: '2-digit', minute: '2-digit',
}); });
}, },
_setupModalListeners() {
const retryToggle = document.getElementById('agent-retry-toggle');
const retryMaxGroup = document.getElementById('agent-retry-max-group');
if (retryToggle && !retryToggle._listenerAdded) {
retryToggle._listenerAdded = true;
retryToggle.addEventListener('change', () => {
if (retryMaxGroup) retryMaxGroup.style.display = retryToggle.checked ? '' : 'none';
});
}
const addSecretBtn = document.getElementById('agent-secret-add-btn');
if (addSecretBtn && !addSecretBtn._listenerAdded) {
addSecretBtn._listenerAdded = true;
addSecretBtn.addEventListener('click', () => {
const agentId = document.getElementById('agent-form-id')?.value;
if (agentId) {
AgentsUI._addSecret(agentId);
} else {
Toast.warning('Salve o agente primeiro para adicionar secrets');
}
});
}
},
async _loadSecrets(agentId) {
const list = document.getElementById('agent-secrets-list');
if (!list) return;
try {
const secrets = await API.secrets.list(agentId);
const items = Array.isArray(secrets) ? secrets : (secrets?.secrets || []);
if (items.length === 0) {
list.innerHTML = '<p class="text-muted text-sm">Nenhum secret configurado.</p>';
return;
}
list.innerHTML = items.map(s => `
<div class="secret-item">
<span class="secret-name font-mono">${Utils.escapeHtml(s.name || s)}</span>
<span class="secret-value-placeholder">••••••••</span>
<button type="button" class="btn btn-ghost btn-icon btn-sm btn-danger" data-secret-delete="${Utils.escapeHtml(s.name || s)}" data-agent-id="${agentId}" title="Remover secret">
<i data-lucide="trash-2"></i>
</button>
</div>
`).join('');
Utils.refreshIcons(list);
list.querySelectorAll('[data-secret-delete]').forEach(btn => {
btn.addEventListener('click', () => {
AgentsUI._deleteSecret(btn.dataset.agentId, btn.dataset.secretDelete);
});
});
} catch {
list.innerHTML = '<p class="text-muted text-sm">Erro ao carregar secrets.</p>';
}
},
async _addSecret(agentId) {
const nameEl = document.getElementById('agent-secret-name');
const valueEl = document.getElementById('agent-secret-value');
const name = nameEl?.value.trim();
const value = valueEl?.value;
if (!name) {
Toast.warning('Nome do secret é obrigatório');
return;
}
if (!value) {
Toast.warning('Valor do secret é obrigatório');
return;
}
try {
await API.secrets.create(agentId, { name, value });
Toast.success(`Secret "${name}" salvo`);
if (nameEl) nameEl.value = '';
if (valueEl) valueEl.value = '';
AgentsUI._loadSecrets(agentId);
} catch (err) {
Toast.error(`Erro ao salvar secret: ${err.message}`);
}
},
async _deleteSecret(agentId, secretName) {
const confirmed = await Modal.confirm(
'Remover secret',
`Tem certeza que deseja remover o secret "${secretName}"?`
);
if (!confirmed) return;
try {
await API.secrets.delete(agentId, secretName);
Toast.success(`Secret "${secretName}" removido`);
AgentsUI._loadSecrets(agentId);
} catch (err) {
Toast.error(`Erro ao remover secret: ${err.message}`);
}
},
async openVersionsModal(agentId) {
const agent = AgentsUI.agents.find(a => a.id === agentId);
const titleEl = document.getElementById('agent-versions-title');
const contentEl = document.getElementById('agent-versions-content');
if (titleEl) titleEl.textContent = `Versões — ${agent?.agent_name || agent?.name || 'Agente'}`;
if (contentEl) {
contentEl.innerHTML = '<div class="flex flex-center gap-8"><div class="spinner"></div><span class="text-secondary">Carregando versões...</span></div>';
}
Modal.open('agent-versions-modal-overlay');
try {
const versions = await API.versions.list(agentId);
const items = Array.isArray(versions) ? versions : (versions?.versions || []);
if (!contentEl) return;
if (items.length === 0) {
contentEl.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon"><i data-lucide="history"></i></div>
<h3 class="empty-state-title">Sem histórico de versões</h3>
<p class="empty-state-desc">As alterações neste agente serão registradas aqui automaticamente.</p>
</div>`;
Utils.refreshIcons(contentEl);
return;
}
contentEl.innerHTML = `
<div class="versions-timeline">
${items.map((v, i) => {
const date = v.changedAt ? new Date(v.changedAt).toLocaleString('pt-BR') : '—';
const changedFields = AgentsUI._getChangedFields(v);
const isLatest = i === 0;
return `
<div class="version-item ${isLatest ? 'version-item--latest' : ''}">
<div class="version-node">
<div class="version-dot ${isLatest ? 'version-dot--active' : ''}"></div>
${i < items.length - 1 ? '<div class="version-line"></div>' : ''}
</div>
<div class="version-content">
<div class="version-header">
<span class="version-number">v${v.version || items.length - i}</span>
<span class="version-date">${date}</span>
${!isLatest ? `<button class="btn btn-ghost btn-sm" data-restore-version="${v.version || items.length - i}" data-agent-id="${agentId}" type="button">
<i data-lucide="undo-2"></i> Restaurar
</button>` : '<span class="badge badge-active">Atual</span>'}
</div>
${changedFields ? `<div class="version-changes">${changedFields}</div>` : ''}
${v.changelog ? `<p class="version-changelog">${Utils.escapeHtml(v.changelog)}</p>` : ''}
</div>
</div>
`;
}).join('')}
</div>`;
Utils.refreshIcons(contentEl);
contentEl.querySelectorAll('[data-restore-version]').forEach(btn => {
btn.addEventListener('click', async () => {
const version = btn.dataset.restoreVersion;
const aid = btn.dataset.agentId;
const confirmed = await Modal.confirm(
'Restaurar versão',
`Deseja restaurar a versão v${version} deste agente? A configuração atual será substituída.`
);
if (!confirmed) return;
try {
await API.versions.restore(aid, version);
Toast.success(`Versão v${version} restaurada`);
Modal.close('agent-versions-modal-overlay');
await AgentsUI.load();
} catch (err) {
Toast.error(`Erro ao restaurar versão: ${err.message}`);
}
});
});
} catch (err) {
if (contentEl) {
contentEl.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon"><i data-lucide="alert-circle"></i></div>
<h3 class="empty-state-title">Erro ao carregar versões</h3>
<p class="empty-state-desc">${Utils.escapeHtml(err.message)}</p>
</div>`;
Utils.refreshIcons(contentEl);
}
}
},
_getChangedFields(version) {
if (!version.config) return '';
const fieldLabels = {
systemPrompt: 'System Prompt',
model: 'Modelo',
workingDirectory: 'Diretório',
allowedTools: 'Ferramentas',
maxTurns: 'Max Turns',
permissionMode: 'Permission Mode',
retryOnFailure: 'Retry',
};
const fields = Object.keys(version.config || {}).filter(k => fieldLabels[k]);
if (fields.length === 0) return '';
return fields.map(f =>
`<span class="version-field-badge">${fieldLabels[f] || f}</span>`
).join('');
},
}; };
window.AgentsUI = AgentsUI; window.AgentsUI = AgentsUI;

View File

@@ -1,4 +1,6 @@
const DashboardUI = { const DashboardUI = {
charts: {},
async load() { async load() {
try { try {
const [status, recentExecs] = await Promise.all([ const [status, recentExecs] = await Promise.all([
@@ -9,11 +11,253 @@ const DashboardUI = {
DashboardUI.updateMetrics(status); DashboardUI.updateMetrics(status);
DashboardUI.updateRecentActivity(recentExecs || []); DashboardUI.updateRecentActivity(recentExecs || []);
DashboardUI.updateSystemStatus(status); DashboardUI.updateSystemStatus(status);
DashboardUI.setupChartPeriod();
DashboardUI.loadCharts();
} catch (err) { } catch (err) {
Toast.error(`Erro ao carregar dashboard: ${err.message}`); Toast.error(`Erro ao carregar dashboard: ${err.message}`);
} }
}, },
async loadCharts() {
try {
const period = document.getElementById('chart-period');
const days = period ? parseInt(period.value) : 7;
const data = await API.stats.charts(days);
DashboardUI.renderExecutionsChart(data);
DashboardUI.renderCostChart(data);
DashboardUI.renderStatusChart(data);
DashboardUI.renderTopAgentsChart(data);
DashboardUI.renderSuccessRateChart(data);
} catch (e) {
console.error('Erro ao carregar gráficos:', e);
}
},
_cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
},
renderExecutionsChart(data) {
const ctx = document.getElementById('executions-chart');
if (!ctx) return;
if (DashboardUI.charts.executions) DashboardUI.charts.executions.destroy();
const labels = (data.labels || []).map(l => {
const d = new Date(l + 'T12:00:00');
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
});
DashboardUI.charts.executions = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'Sucesso', data: data.successCounts || [], backgroundColor: 'rgba(34, 197, 94, 0.8)', borderRadius: 4 },
{ label: 'Erro', data: data.errorCounts || [], backgroundColor: 'rgba(239, 68, 68, 0.8)', borderRadius: 4 },
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { color: DashboardUI._cssVar('--text-secondary'), font: { size: 11 } },
},
},
scales: {
x: {
stacked: true,
grid: { display: false },
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
},
y: {
stacked: true,
beginAtZero: true,
grid: { color: 'rgba(128,128,128,0.1)' },
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
},
},
},
});
},
renderCostChart(data) {
const ctx = document.getElementById('cost-chart');
if (!ctx) return;
if (DashboardUI.charts.cost) DashboardUI.charts.cost.destroy();
const labels = (data.labels || []).map(l => {
const d = new Date(l + 'T12:00:00');
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
});
DashboardUI.charts.cost = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label: 'Custo (USD)',
data: data.costData || [],
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#6366f1',
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: {
grid: { display: false },
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
},
y: {
beginAtZero: true,
grid: { color: 'rgba(128,128,128,0.1)' },
ticks: {
color: DashboardUI._cssVar('--text-tertiary'),
font: { size: 10 },
callback: (v) => '$' + v.toFixed(2),
},
},
},
},
});
},
renderStatusChart(data) {
const ctx = document.getElementById('status-chart');
if (!ctx) return;
if (DashboardUI.charts.status) DashboardUI.charts.status.destroy();
const dist = data.statusDistribution || {};
const statuses = Object.keys(dist);
const values = Object.values(dist);
const colors = {
completed: '#22c55e',
error: '#ef4444',
running: '#6366f1',
canceled: '#f59e0b',
rejected: '#ef4444',
};
DashboardUI.charts.status = new Chart(ctx, {
type: 'doughnut',
data: {
labels: statuses.map(s => s.charAt(0).toUpperCase() + s.slice(1)),
datasets: [{
data: values,
backgroundColor: statuses.map(s => colors[s] || '#94a3b8'),
borderWidth: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 1,
cutout: '65%',
plugins: {
legend: {
position: 'bottom',
labels: {
color: DashboardUI._cssVar('--text-secondary'),
font: { size: 11 },
padding: 12,
},
},
},
},
});
},
renderTopAgentsChart(data) {
const ctx = document.getElementById('agents-chart');
if (!ctx) return;
if (DashboardUI.charts.agents) DashboardUI.charts.agents.destroy();
const top = data.topAgents || [];
DashboardUI.charts.agents = new Chart(ctx, {
type: 'bar',
data: {
labels: top.map(a => a.name.length > 15 ? a.name.substring(0, 15) + '\u2026' : a.name),
datasets: [{
data: top.map(a => a.count),
backgroundColor: ['#6366f1', '#8b5cf6', '#a78bfa', '#c4b5fd', '#ddd6fe'],
borderRadius: 4,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: { legend: { display: false } },
scales: {
x: {
beginAtZero: true,
grid: { color: 'rgba(128,128,128,0.1)' },
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
},
y: {
grid: { display: false },
ticks: { color: DashboardUI._cssVar('--text-secondary'), font: { size: 10 } },
},
},
},
});
},
renderSuccessRateChart(data) {
const ctx = document.getElementById('success-rate-chart');
if (!ctx) return;
if (DashboardUI.charts.successRate) DashboardUI.charts.successRate.destroy();
const dist = data.statusDistribution || {};
const total = Object.values(dist).reduce((a, b) => a + b, 0);
const success = dist.completed || 0;
const rate = total > 0 ? Math.round((success / total) * 100) : 0;
DashboardUI.charts.successRate = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Sucesso', 'Outros'],
datasets: [{
data: [rate, 100 - rate],
backgroundColor: ['#22c55e', 'rgba(128,128,128,0.15)'],
borderWidth: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 1,
cutout: '75%',
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
},
plugins: [{
id: 'centerText',
afterDraw(chart) {
const { ctx: c, width, height } = chart;
c.save();
c.font = 'bold 24px Inter';
c.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--text-primary').trim();
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillText(rate + '%', width / 2, height / 2);
c.restore();
},
}],
});
},
updateMetrics(status) { updateMetrics(status) {
const metrics = { const metrics = {
'metric-total-agents': status.agents?.total ?? 0, 'metric-total-agents': status.agents?.total ?? 0,
@@ -71,15 +315,15 @@ const DashboardUI = {
<span>Nenhuma execução recente</span> <span>Nenhuma execução recente</span>
</li> </li>
`; `;
if (window.lucide) lucide.createIcons({ nodes: [list] }); Utils.refreshIcons(list);
return; return;
} }
list.innerHTML = executions.map((exec) => { list.innerHTML = executions.map((exec) => {
const statusClass = DashboardUI._statusBadgeClass(exec.status); const statusClass = DashboardUI._statusBadgeClass(exec.status);
const statusLabel = DashboardUI._statusLabel(exec.status); const statusLabel = DashboardUI._statusLabel(exec.status);
const name = exec.agentName || exec.pipelineName || exec.agentId || 'Execução'; const name = Utils.escapeHtml(exec.agentName || exec.pipelineName || exec.agentId || 'Execução');
const taskText = exec.task || exec.input || ''; const taskText = Utils.escapeHtml(exec.task || exec.input || '');
const typeBadge = exec.type === 'pipeline' const typeBadge = exec.type === 'pipeline'
? '<span class="badge badge--purple" style="font-size:0.6rem;padding:1px 5px;">Pipeline</span> ' ? '<span class="badge badge--purple" style="font-size:0.6rem;padding:1px 5px;">Pipeline</span> '
: ''; : '';
@@ -110,6 +354,14 @@ const DashboardUI = {
}).join(''); }).join('');
}, },
setupChartPeriod() {
const chartPeriod = document.getElementById('chart-period');
if (chartPeriod && !chartPeriod._listenerAdded) {
chartPeriod._listenerAdded = true;
chartPeriod.addEventListener('change', () => DashboardUI.loadCharts());
}
},
updateSystemStatus(status) { updateSystemStatus(status) {
const wsBadge = document.getElementById('system-ws-status-badge'); const wsBadge = document.getElementById('system-ws-status-badge');
if (wsBadge) { if (wsBadge) {
@@ -117,6 +369,18 @@ const DashboardUI = {
wsBadge.textContent = wsConnected ? 'Conectado' : 'Desconectado'; wsBadge.textContent = wsConnected ? 'Conectado' : 'Desconectado';
wsBadge.className = `badge ${wsConnected ? 'badge--green' : 'badge--red'}`; wsBadge.className = `badge ${wsConnected ? 'badge--green' : 'badge--red'}`;
} }
const claudeBadge = document.getElementById('system-claude-status-badge');
if (claudeBadge) {
API.system.info().then((info) => {
const available = info.claudeVersion && info.claudeVersion !== 'N/A';
claudeBadge.textContent = available ? info.claudeVersion : 'Indisponível';
claudeBadge.className = `badge ${available ? 'badge--green' : 'badge--red'}`;
}).catch(() => {
claudeBadge.textContent = 'Indisponível';
claudeBadge.className = 'badge badge--red';
});
}
}, },
_statusBadgeClass(status) { _statusBadgeClass(status) {

View File

@@ -7,7 +7,17 @@ const HistoryUI = {
_currentType: '', _currentType: '',
_currentStatus: '', _currentStatus: '',
_exportListenerAdded: false,
async load() { async load() {
if (!HistoryUI._exportListenerAdded) {
HistoryUI._exportListenerAdded = true;
const exportBtn = document.getElementById('history-export-csv');
if (exportBtn) {
exportBtn.addEventListener('click', () => API.executions.exportCsv());
}
}
const params = { limit: HistoryUI.pageSize, offset: HistoryUI.page * HistoryUI.pageSize }; const params = { limit: HistoryUI.pageSize, offset: HistoryUI.page * HistoryUI.pageSize };
if (HistoryUI._currentType) params.type = HistoryUI._currentType; if (HistoryUI._currentType) params.type = HistoryUI._currentType;
if (HistoryUI._currentStatus) params.status = HistoryUI._currentStatus; if (HistoryUI._currentStatus) params.status = HistoryUI._currentStatus;
@@ -38,12 +48,12 @@ const HistoryUI = {
<p class="empty-state-text">O histórico de execuções aparecerá aqui.</p> <p class="empty-state-text">O histórico de execuções aparecerá aqui.</p>
</div> </div>
`; `;
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
return; return;
} }
container.innerHTML = HistoryUI.executions.map((exec) => HistoryUI._renderCard(exec)).join(''); container.innerHTML = HistoryUI.executions.map((exec) => HistoryUI._renderCard(exec)).join('');
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
}, },
_renderCard(exec) { _renderCard(exec) {
@@ -55,9 +65,10 @@ const HistoryUI = {
const name = exec.type === 'pipeline' const name = exec.type === 'pipeline'
? (exec.pipelineName || 'Pipeline') ? (exec.pipelineName || 'Pipeline')
: (exec.agentName || 'Agente'); : (exec.agentName || 'Agente');
const task = exec.type === 'pipeline' const taskRaw = exec.type === 'pipeline'
? (exec.input || '') ? (exec.input || '')
: (exec.task || ''); : (exec.task || '');
const task = taskRaw.length > 150 ? taskRaw.slice(0, 150) + '…' : taskRaw;
const date = HistoryUI._formatDate(exec.startedAt); const date = HistoryUI._formatDate(exec.startedAt);
const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt); const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt);
const cost = exec.costUsd || exec.totalCostUsd || 0; const cost = exec.costUsd || exec.totalCostUsd || 0;
@@ -70,28 +81,31 @@ const HistoryUI = {
<div class="history-card-header"> <div class="history-card-header">
<div class="history-card-identity"> <div class="history-card-identity">
${typeBadge} ${typeBadge}
<span class="history-card-name">${HistoryUI._escapeHtml(name)}</span> <span class="history-card-name">${Utils.escapeHtml(name)}</span>
</div>
<div class="history-card-status">
${statusBadge} ${statusBadge}
<span class="history-card-date">${date}</span>
</div> </div>
</div> </div>
<div class="history-card-meta"> <div class="history-card-task" title="${Utils.escapeHtml(taskRaw)}">${Utils.escapeHtml(task)}</div>
<span class="history-card-task">${HistoryUI._escapeHtml(task)}</span> <div class="history-card-info">
<span class="history-card-duration-group"> <span class="history-card-date">
<span class="history-card-duration"> <i data-lucide="calendar" aria-hidden="true"></i>
<i data-lucide="clock" aria-hidden="true"></i> ${date}
${duration}
</span>
${costHtml}
</span> </span>
<span class="history-card-duration">
<i data-lucide="clock" aria-hidden="true"></i>
${duration}
</span>
${costHtml}
</div> </div>
<div class="history-card-actions"> <div class="history-card-actions">
<button class="btn btn-ghost btn-sm" data-action="view-execution" data-id="${exec.id}" type="button"> <button class="btn btn-ghost btn-sm" data-action="view-execution" data-id="${exec.id}" type="button">
<i data-lucide="eye"></i> <i data-lucide="eye"></i>
Ver detalhes Ver detalhes
</button> </button>
${(exec.status === 'error' || exec.status === 'canceled') ? `
<button class="btn btn-ghost btn-sm" data-action="retry" data-id="${exec.id}" type="button" title="Reexecutar">
<i data-lucide="refresh-cw"></i>
</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"> <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> <i data-lucide="trash-2"></i>
</button> </button>
@@ -132,7 +146,7 @@ const HistoryUI = {
</div> </div>
`; `;
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
document.getElementById('history-prev-btn')?.addEventListener('click', () => { document.getElementById('history-prev-btn')?.addEventListener('click', () => {
HistoryUI.page--; HistoryUI.page--;
@@ -172,7 +186,11 @@ const HistoryUI = {
: HistoryUI._renderAgentDetail(exec); : HistoryUI._renderAgentDetail(exec);
Modal.open('execution-detail-modal-overlay'); Modal.open('execution-detail-modal-overlay');
if (window.lucide) lucide.createIcons({ nodes: [content] }); Utils.refreshIcons(content);
content.querySelector('[data-action="download-result-md"]')?.addEventListener('click', () => {
HistoryUI._downloadResultMd(exec);
});
content.querySelectorAll('.pipeline-step-prompt-toggle').forEach((btn) => { content.querySelectorAll('.pipeline-step-prompt-toggle').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -195,18 +213,24 @@ const HistoryUI = {
const endDate = exec.endedAt ? HistoryUI._formatDate(exec.endedAt) : '—'; const endDate = exec.endedAt ? HistoryUI._formatDate(exec.endedAt) : '—';
const resultBlock = exec.result const resultBlock = exec.result
? `<div class="execution-result" role="region" aria-label="Resultado da execução">${HistoryUI._escapeHtml(exec.result)}</div>` ? `<div class="execution-result" role="region" aria-label="Resultado da execução">${Utils.escapeHtml(exec.result)}</div>`
: ''; : '';
const errorBlock = exec.error const errorBlock = exec.error
? `<div class="execution-result execution-result--error" role="alert">${HistoryUI._escapeHtml(exec.error)}</div>` ? `<div class="execution-result execution-result--error" role="alert">${Utils.escapeHtml(exec.error)}</div>`
: ''; : '';
return ` return `
${exec.result ? `
<div class="report-actions">
<button class="btn btn-ghost btn-sm" data-action="download-result-md" type="button">
<i data-lucide="download"></i> Download .md
</button>
</div>` : ''}
<div class="execution-detail-meta"> <div class="execution-detail-meta">
<div class="execution-detail-row"> <div class="execution-detail-row">
<span class="execution-detail-label">Agente</span> <span class="execution-detail-label">Agente</span>
<span class="execution-detail-value">${HistoryUI._escapeHtml(exec.agentName || exec.agentId || '—')}</span> <span class="execution-detail-value">${Utils.escapeHtml(exec.agentName || exec.agentId || '—')}</span>
</div> </div>
<div class="execution-detail-row"> <div class="execution-detail-row">
<span class="execution-detail-label">Status</span> <span class="execution-detail-label">Status</span>
@@ -243,7 +267,7 @@ const HistoryUI = {
${exec.task ? ` ${exec.task ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
<h3 class="execution-detail-section-title">Tarefa</h3> <h3 class="execution-detail-section-title">Tarefa</h3>
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.task)}</p> <p class="execution-detail-task">${Utils.escapeHtml(exec.task)}</p>
</div>` : ''} </div>` : ''}
${resultBlock ? ` ${resultBlock ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
@@ -279,7 +303,7 @@ const HistoryUI = {
<div class="pipeline-step-detail"> <div class="pipeline-step-detail">
<div class="pipeline-step-header"> <div class="pipeline-step-header">
<div class="pipeline-step-identity"> <div class="pipeline-step-identity">
<span class="pipeline-step-agent">${HistoryUI._escapeHtml(step.agentName || step.agentId || 'Agente')}</span> <span class="pipeline-step-agent">${Utils.escapeHtml(step.agentName || step.agentId || 'Agente')}</span>
${HistoryUI._statusBadge(step.status)} ${HistoryUI._statusBadge(step.status)}
</div> </div>
<span class="pipeline-step-meta-group"> <span class="pipeline-step-meta-group">
@@ -297,13 +321,13 @@ const HistoryUI = {
Prompt utilizado Prompt utilizado
</button> </button>
<div class="pipeline-step-prompt-body" hidden> <div class="pipeline-step-prompt-body" hidden>
<div class="execution-result execution-result--prompt">${HistoryUI._escapeHtml(step.prompt)}</div> <div class="execution-result execution-result--prompt">${Utils.escapeHtml(step.prompt)}</div>
</div> </div>
</div>` : ''} </div>` : ''}
${step.result ? ` ${step.result ? `
<div class="pipeline-step-result"> <div class="pipeline-step-result">
<span class="pipeline-step-result-label">Resultado</span> <span class="pipeline-step-result-label">Resultado</span>
<div class="execution-result">${HistoryUI._escapeHtml(step.result)}</div> <div class="execution-result">${Utils.escapeHtml(step.result)}</div>
</div>` : ''} </div>` : ''}
${step.status === 'error' ? ` ${step.status === 'error' ? `
<div class="execution-result execution-result--error">Passo falhou.</div>` : ''} <div class="execution-result execution-result--error">Passo falhou.</div>` : ''}
@@ -312,11 +336,18 @@ const HistoryUI = {
`; `;
}).join(''); }).join('');
const hasResults = steps.some(s => s.result);
return ` return `
${hasResults ? `
<div class="report-actions">
<button class="btn btn-ghost btn-sm" data-action="download-result-md" type="button">
<i data-lucide="download"></i> Download .md
</button>
</div>` : ''}
<div class="execution-detail-meta"> <div class="execution-detail-meta">
<div class="execution-detail-row"> <div class="execution-detail-row">
<span class="execution-detail-label">Pipeline</span> <span class="execution-detail-label">Pipeline</span>
<span class="execution-detail-value">${HistoryUI._escapeHtml(exec.pipelineName || exec.pipelineId || '—')}</span> <span class="execution-detail-value">${Utils.escapeHtml(exec.pipelineName || exec.pipelineId || '—')}</span>
</div> </div>
<div class="execution-detail-row"> <div class="execution-detail-row">
<span class="execution-detail-label">Status</span> <span class="execution-detail-label">Status</span>
@@ -343,7 +374,7 @@ const HistoryUI = {
${exec.input ? ` ${exec.input ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
<h3 class="execution-detail-section-title">Input Inicial</h3> <h3 class="execution-detail-section-title">Input Inicial</h3>
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.input)}</p> <p class="execution-detail-task">${Utils.escapeHtml(exec.input)}</p>
</div>` : ''} </div>` : ''}
${steps.length > 0 ? ` ${steps.length > 0 ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
@@ -355,11 +386,51 @@ const HistoryUI = {
${exec.error ? ` ${exec.error ? `
<div class="execution-detail-section"> <div class="execution-detail-section">
<h3 class="execution-detail-section-title">Erro</h3> <h3 class="execution-detail-section-title">Erro</h3>
<div class="execution-result execution-result--error">${HistoryUI._escapeHtml(exec.error)}</div> <div class="execution-result execution-result--error">${Utils.escapeHtml(exec.error)}</div>
</div>` : ''} </div>` : ''}
`; `;
}, },
_downloadResultMd(exec) {
let md = '';
const name = exec.type === 'pipeline'
? (exec.pipelineName || 'Pipeline')
: (exec.agentName || 'Agente');
if (exec.type === 'pipeline') {
md += `# ${name}\n\n`;
const steps = Array.isArray(exec.steps) ? exec.steps : [];
steps.forEach((step, i) => {
md += `## Passo ${i + 1}${step.agentName || 'Agente'}\n\n`;
if (step.result) md += `${step.result}\n\n`;
});
} else {
md += exec.result || '';
}
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const filename = `${slug}-${new Date(exec.startedAt || Date.now()).toISOString().slice(0, 10)}.md`;
const blob = new Blob([md], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
Toast.success('Download iniciado');
},
async retryExecution(id) {
try {
await API.executions.retry(id);
Toast.success('Execução reiniciada');
App.navigateTo('terminal');
} catch (err) {
Toast.error(`Erro ao reexecutar: ${err.message}`);
}
},
async deleteExecution(id) { async deleteExecution(id) {
const confirmed = await Modal.confirm( const confirmed = await Modal.confirm(
'Excluir execução', 'Excluir execução',
@@ -435,15 +506,6 @@ const HistoryUI = {
}); });
}, },
_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; window.HistoryUI = HistoryUI;

View File

@@ -0,0 +1,153 @@
const NotificationsUI = {
notifications: [],
unreadCount: 0,
pollInterval: null,
init() {
this.setupEventListeners();
this.startPolling();
},
setupEventListeners() {
const bell = document.getElementById('notification-bell');
const panel = document.getElementById('notification-panel');
if (bell) {
bell.addEventListener('click', (e) => {
e.stopPropagation();
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden')) this.load();
});
}
document.addEventListener('click', (e) => {
if (panel && !panel.contains(e.target) && e.target !== bell) {
panel.classList.add('hidden');
}
});
const markAllBtn = document.getElementById('mark-all-read');
if (markAllBtn) {
markAllBtn.addEventListener('click', () => this.markAllRead());
}
const clearBtn = document.getElementById('clear-notifications');
if (clearBtn) {
clearBtn.addEventListener('click', () => this.clearAll());
}
},
startPolling() {
this.pollInterval = setInterval(() => this.loadCount(), 15000);
this.loadCount();
},
async loadCount() {
try {
const data = await API.request('GET', '/notifications');
this.unreadCount = data.unreadCount || 0;
this.updateBadge();
} catch (e) {}
},
async load() {
try {
const data = await API.request('GET', '/notifications');
this.notifications = data.notifications || [];
this.unreadCount = data.unreadCount || 0;
this.updateBadge();
this.render();
} catch (e) {
console.error('Erro ao carregar notificações:', e);
}
},
updateBadge() {
const badge = document.getElementById('notification-badge');
if (!badge) return;
if (this.unreadCount > 0) {
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
},
render() {
const list = document.getElementById('notification-list');
if (!list) return;
if (this.notifications.length === 0) {
list.innerHTML = '<div class="notification-empty">Nenhuma notificação</div>';
return;
}
list.innerHTML = this.notifications.map(n => {
const iconClass = n.type === 'success' ? 'success' : n.type === 'error' ? 'error' : 'info';
const icon = n.type === 'success' ? '✓' : n.type === 'error' ? '✕' : '';
const time = this.timeAgo(n.createdAt);
const unread = n.read ? '' : ' unread';
return `<div class="notification-item${unread}" data-id="${n.id}">
<div class="notification-item-icon ${iconClass}">${icon}</div>
<div class="notification-item-content">
<div class="notification-item-title">${Utils.escapeHtml(n.title)}</div>
<div class="notification-item-message">${Utils.escapeHtml(n.message)}</div>
<div class="notification-item-time">${time}</div>
</div>
</div>`;
}).join('');
list.querySelectorAll('.notification-item').forEach(item => {
item.addEventListener('click', () => this.markAsRead(item.dataset.id));
});
},
async markAsRead(id) {
try {
await API.request('POST', `/notifications/${id}/read`);
const n = this.notifications.find(n => n.id === id);
if (n) n.read = true;
this.unreadCount = Math.max(0, this.unreadCount - 1);
this.updateBadge();
this.render();
} catch (e) {}
},
async markAllRead() {
try {
await API.request('POST', '/notifications/read-all');
this.notifications.forEach(n => n.read = true);
this.unreadCount = 0;
this.updateBadge();
this.render();
} catch (e) {}
},
async clearAll() {
try {
await API.request('DELETE', '/notifications');
this.notifications = [];
this.unreadCount = 0;
this.updateBadge();
this.render();
} catch (e) {}
},
timeAgo(dateStr) {
const now = new Date();
const date = new Date(dateStr);
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'agora';
if (diff < 3600) return `${Math.floor(diff / 60)}min atrás`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h atrás`;
return `${Math.floor(diff / 86400)}d atrás`;
},
showBrowserNotification(title, body) {
if (Notification.permission === 'granted') {
new Notification(title, { body, icon: '/favicon.ico' });
}
}
};
window.NotificationsUI = NotificationsUI;

View File

@@ -44,7 +44,7 @@ const PipelinesUI = {
if (!emptyState) { if (!emptyState) {
grid.insertAdjacentHTML('beforeend', PipelinesUI.renderEmpty()); grid.insertAdjacentHTML('beforeend', PipelinesUI.renderEmpty());
} }
if (window.lucide) lucide.createIcons({ nodes: [grid] }); Utils.refreshIcons(grid);
return; return;
} }
@@ -59,7 +59,7 @@ const PipelinesUI = {
grid.appendChild(fragment); grid.appendChild(fragment);
if (window.lucide) lucide.createIcons({ nodes: [grid] }); Utils.refreshIcons(grid);
}, },
renderEmpty() { renderEmpty() {
@@ -83,7 +83,7 @@ const PipelinesUI = {
const stepCount = steps.length; const stepCount = steps.length;
const flowHtml = steps.map((step, index) => { const flowHtml = steps.map((step, index) => {
const agentName = step.agentName || step.agentId || 'Agente'; const agentName = Utils.escapeHtml(step.agentName || step.agentId || 'Agente');
const isLast = index === steps.length - 1; const isLast = index === steps.length - 1;
const approvalIcon = step.requiresApproval && index > 0 const approvalIcon = step.requiresApproval && index > 0
? '<i data-lucide="shield-check" style="width:10px;height:10px;color:var(--warning)"></i> ' ? '<i data-lucide="shield-check" style="width:10px;height:10px;color:var(--warning)"></i> '
@@ -102,12 +102,12 @@ const PipelinesUI = {
<div class="agent-card-body"> <div class="agent-card-body">
<div class="agent-card-top"> <div class="agent-card-top">
<div class="agent-info"> <div class="agent-info">
<h3 class="agent-name">${pipeline.name || 'Sem nome'}</h3> <h3 class="agent-name">${Utils.escapeHtml(pipeline.name || 'Sem nome')}</h3>
<span class="badge badge-active">${stepCount} ${stepCount === 1 ? 'passo' : 'passos'}</span> <span class="badge badge-active">${stepCount} ${stepCount === 1 ? 'passo' : 'passos'}</span>
</div> </div>
</div> </div>
${pipeline.description ? `<p class="agent-description">${pipeline.description}</p>` : ''} ${pipeline.description ? `<p class="agent-description">${Utils.escapeHtml(pipeline.description)}</p>` : ''}
<div class="pipeline-flow"> <div class="pipeline-flow">
${flowHtml || '<span class="agent-description">Nenhum passo configurado</span>'} ${flowHtml || '<span class="agent-description">Nenhum passo configurado</span>'}
@@ -119,13 +119,14 @@ const PipelinesUI = {
<i data-lucide="play"></i> <i data-lucide="play"></i>
Executar Executar
</button> </button>
<button class="btn btn-ghost btn-sm" data-action="edit-pipeline" data-id="${pipeline.id}"> <div class="agent-actions-icons">
<i data-lucide="pencil"></i> <button class="btn btn-ghost btn-icon btn-sm" data-action="edit-pipeline" data-id="${pipeline.id}" title="Editar pipeline">
Editar <i data-lucide="pencil"></i>
</button> </button>
<button class="btn btn-ghost btn-icon btn-sm btn-danger" data-action="delete-pipeline" data-id="${pipeline.id}" title="Excluir pipeline"> <button class="btn btn-ghost btn-icon btn-sm btn-danger" data-action="delete-pipeline" data-id="${pipeline.id}" title="Excluir pipeline">
<i data-lucide="trash-2"></i> <i data-lucide="trash-2"></i>
</button> </button>
</div>
</div> </div>
</div> </div>
`; `;
@@ -192,7 +193,7 @@ const PipelinesUI = {
} }
const agentOptions = PipelinesUI.agents const agentOptions = PipelinesUI.agents
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`) .map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
.join(''); .join('');
container.innerHTML = PipelinesUI._steps.map((step, index) => { container.innerHTML = PipelinesUI._steps.map((step, index) => {
@@ -225,7 +226,7 @@ const PipelinesUI = {
placeholder="{{input}} será substituído pelo output anterior" placeholder="{{input}} será substituído pelo output anterior"
data-step-field="inputTemplate" data-step-field="inputTemplate"
data-step-index="${index}" data-step-index="${index}"
>${step.inputTemplate || ''}</textarea> >${Utils.escapeHtml(step.inputTemplate || '')}</textarea>
${approvalHtml} ${approvalHtml}
</div> </div>
<div class="pipeline-step-actions"> <div class="pipeline-step-actions">
@@ -249,7 +250,7 @@ const PipelinesUI = {
select.value = PipelinesUI._steps[index].agentId || ''; select.value = PipelinesUI._steps[index].agentId || '';
}); });
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
}, },
_syncStepsFromDOM() { _syncStepsFromDOM() {
@@ -369,6 +370,8 @@ const PipelinesUI = {
const workdirEl = document.getElementById('pipeline-execute-workdir'); const workdirEl = document.getElementById('pipeline-execute-workdir');
if (workdirEl) workdirEl.value = ''; if (workdirEl) workdirEl.value = '';
if (App._pipelineDropzone) App._pipelineDropzone.reset();
Modal.open('pipeline-execute-modal-overlay'); Modal.open('pipeline-execute-modal-overlay');
}, },
@@ -383,7 +386,16 @@ const PipelinesUI = {
} }
try { try {
await API.pipelines.execute(pipelineId, input, workingDirectory); let contextFiles = null;
const dropzone = App._pipelineDropzone;
if (dropzone && dropzone.getFiles().length > 0) {
Toast.info('Fazendo upload dos arquivos...');
const uploadResult = await API.uploads.send(dropzone.getFiles());
contextFiles = uploadResult.files;
}
await API.pipelines.execute(pipelineId, input, workingDirectory, contextFiles);
if (dropzone) dropzone.reset();
Modal.close('pipeline-execute-modal-overlay'); Modal.close('pipeline-execute-modal-overlay');
App.navigateTo('terminal'); App.navigateTo('terminal');
Toast.info('Pipeline iniciado'); Toast.info('Pipeline iniciado');

View File

@@ -28,7 +28,7 @@ const SchedulesUI = {
</td> </td>
</tr> </tr>
`; `;
if (window.lucide) lucide.createIcons({ nodes: [tbody] }); Utils.refreshIcons(tbody);
return; return;
} }
@@ -44,8 +44,8 @@ const SchedulesUI = {
return ` return `
<tr> <tr>
<td>${schedule.agentName || '—'}</td> <td>${Utils.escapeHtml(schedule.agentName || '—')}</td>
<td class="schedule-task-cell" title="${schedule.taskDescription || ''}">${schedule.taskDescription || '—'}</td> <td class="schedule-task-cell" title="${Utils.escapeHtml(schedule.taskDescription || '')}">${Utils.escapeHtml(schedule.taskDescription || '—')}</td>
<td> <td>
<code class="font-mono">${cronExpr}</code> <code class="font-mono">${cronExpr}</code>
</td> </td>
@@ -77,7 +77,7 @@ const SchedulesUI = {
`; `;
}).join(''); }).join('');
if (window.lucide) lucide.createIcons({ nodes: [tbody] }); Utils.refreshIcons(tbody);
}, },
filter(searchText, statusFilter) { filter(searchText, statusFilter) {
@@ -106,7 +106,7 @@ const SchedulesUI = {
select.innerHTML = '<option value="">Selecionar agente...</option>' + select.innerHTML = '<option value="">Selecionar agente...</option>' +
agents agents
.filter((a) => a.status === 'active') .filter((a) => a.status === 'active')
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`) .map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
.join(''); .join('');
} }
@@ -208,7 +208,11 @@ const SchedulesUI = {
if (!container) return; if (!container) return;
if (history.length === 0) { if (history.length === 0) {
container.innerHTML = '<p class="empty-state-desc">Nenhum disparo registrado</p>'; const hasSchedules = SchedulesUI.schedules.length > 0;
const msg = hasSchedules
? 'Nenhum disparo registrado ainda. As tarefas agendadas aparecerão aqui após a próxima execução.'
: 'Nenhum disparo registrado. Crie um agendamento para começar.';
container.innerHTML = `<p class="empty-state-desc">${msg}</p>`;
return; return;
} }
@@ -233,12 +237,12 @@ const SchedulesUI = {
const duration = SchedulesUI._formatDuration(exec.startedAt, exec.endedAt); const duration = SchedulesUI._formatDuration(exec.startedAt, exec.endedAt);
const cost = exec.costUsd || exec.totalCostUsd || 0; const cost = exec.costUsd || exec.totalCostUsd || 0;
const costStr = cost > 0 ? `$${cost.toFixed(4)}` : '—'; const costStr = cost > 0 ? `$${cost.toFixed(4)}` : '—';
const taskStr = SchedulesUI._escapeHtml(SchedulesUI._truncate(exec.task || '', 60)); const taskStr = Utils.escapeHtml(Utils.truncate(exec.task || '', 60));
return ` return `
<tr> <tr>
<td>${SchedulesUI._escapeHtml(exec.agentName || '—')}</td> <td>${Utils.escapeHtml(exec.agentName || '—')}</td>
<td title="${SchedulesUI._escapeHtml(exec.task || '')}">${taskStr}</td> <td title="${Utils.escapeHtml(exec.task || '')}">${taskStr}</td>
<td>${status}</td> <td>${status}</td>
<td>${date}</td> <td>${date}</td>
<td>${duration}</td> <td>${duration}</td>
@@ -256,7 +260,7 @@ const SchedulesUI = {
</div> </div>
`; `;
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
}, },
_statusBadge(status) { _statusBadge(status) {
@@ -283,16 +287,6 @@ const SchedulesUI = {
return `${minutes}m ${seconds}s`; return `${minutes}m ${seconds}s`;
}, },
_escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
},
_truncate(str, max) {
if (!str || str.length <= max) return str;
return str.slice(0, max) + '…';
},
cronToHuman(expression) { cronToHuman(expression) {
if (!expression) return '—'; if (!expression) return '—';

View File

@@ -8,11 +8,20 @@ const SettingsUI = {
SettingsUI.populateForm(settings); SettingsUI.populateForm(settings);
SettingsUI.populateSystemInfo(info); SettingsUI.populateSystemInfo(info);
SettingsUI.updateThemeInfo();
} catch (err) { } catch (err) {
Toast.error(`Erro ao carregar configurações: ${err.message}`); Toast.error(`Erro ao carregar configurações: ${err.message}`);
} }
}, },
updateThemeInfo() {
const themeEl = document.getElementById('info-current-theme');
if (themeEl) {
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
themeEl.textContent = theme === 'dark' ? 'Escuro' : 'Claro';
}
},
populateForm(settings) { populateForm(settings) {
const fields = { const fields = {
'settings-default-model': settings.defaultModel || 'claude-sonnet-4-6', 'settings-default-model': settings.defaultModel || 'claude-sonnet-4-6',

View File

@@ -39,7 +39,7 @@ const TasksUI = {
container.appendChild(fragment); container.appendChild(fragment);
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
}, },
filter(searchText, categoryFilter) { filter(searchText, categoryFilter) {
@@ -65,10 +65,10 @@ const TasksUI = {
return ` return `
<div class="task-card" data-task-id="${task.id}"> <div class="task-card" data-task-id="${task.id}">
<div class="task-card-header"> <div class="task-card-header">
<h4 class="task-card-name">${task.name}</h4> <h4 class="task-card-name">${Utils.escapeHtml(task.name)}</h4>
<span class="badge ${categoryClass}">${categoryLabel}</span> <span class="badge ${categoryClass}">${Utils.escapeHtml(categoryLabel)}</span>
</div> </div>
${task.description ? `<p class="task-card-description">${task.description}</p>` : ''} ${task.description ? `<p class="task-card-description">${Utils.escapeHtml(task.description)}</p>` : ''}
<div class="task-card-footer"> <div class="task-card-footer">
<span class="task-card-date"> <span class="task-card-date">
<i data-lucide="calendar"></i> <i data-lucide="calendar"></i>
@@ -117,7 +117,7 @@ const TasksUI = {
<div class="task-card task-card--form" id="task-inline-form"> <div class="task-card task-card--form" id="task-inline-form">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="task-inline-name">${title}</label> <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 || ''}"> <input type="text" id="task-inline-name" class="input" placeholder="Ex: Code Review de PR" required autocomplete="off" value="${Utils.escapeHtml(task.name || '')}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="task-inline-category">Categoria</label> <label class="form-label" for="task-inline-category">Categoria</label>
@@ -133,7 +133,7 @@ const TasksUI = {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="task-inline-description">Descrição</label> <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...">${task.description || ''}</textarea> <textarea id="task-inline-description" class="textarea" rows="2" placeholder="Descreva o objetivo desta tarefa...">${Utils.escapeHtml(task.description || '')}</textarea>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button class="btn btn--primary" id="btn-save-inline-task" type="button">${btnLabel}</button> <button class="btn btn--primary" id="btn-save-inline-task" type="button">${btnLabel}</button>
@@ -228,7 +228,7 @@ const TasksUI = {
const selectEl = document.getElementById('execute-agent-select'); const selectEl = document.getElementById('execute-agent-select');
if (selectEl) { if (selectEl) {
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' + selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
activeAgents.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`).join(''); activeAgents.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`).join('');
selectEl.value = ''; selectEl.value = '';
} }

View File

@@ -5,6 +5,9 @@ const Terminal = {
executionFilter: null, executionFilter: null,
_processingInterval: null, _processingInterval: null,
_chatSession: null, _chatSession: null,
searchMatches: [],
searchIndex: -1,
_toolbarInitialized: false,
enableChat(agentId, agentName, sessionId) { enableChat(agentId, agentName, sessionId) {
Terminal._chatSession = { agentId, agentName, sessionId }; Terminal._chatSession = { agentId, agentName, sessionId };
@@ -83,7 +86,121 @@ const Terminal = {
if (output) output.scrollTop = output.scrollHeight; if (output) output.scrollTop = output.scrollHeight;
}, },
initToolbar() {
if (Terminal._toolbarInitialized) return;
Terminal._toolbarInitialized = true;
const searchToggle = document.getElementById('terminal-search-toggle');
const searchBar = document.getElementById('terminal-search-bar');
const searchInput = document.getElementById('terminal-search-input');
const searchClose = document.getElementById('terminal-search-close');
const searchPrev = document.getElementById('terminal-search-prev');
const searchNext = document.getElementById('terminal-search-next');
const downloadBtn = document.getElementById('terminal-download');
const copyBtn = document.getElementById('terminal-copy');
const autoScrollCheck = document.getElementById('terminal-autoscroll');
if (searchToggle && searchBar) {
searchToggle.addEventListener('click', () => {
searchBar.classList.toggle('hidden');
if (!searchBar.classList.contains('hidden') && searchInput) searchInput.focus();
});
}
if (searchInput) {
searchInput.addEventListener('input', () => Terminal.search(searchInput.value));
}
if (searchClose && searchBar) {
searchClose.addEventListener('click', () => {
searchBar.classList.add('hidden');
Terminal.clearSearch();
});
}
if (searchPrev) searchPrev.addEventListener('click', () => Terminal.searchPrev());
if (searchNext) searchNext.addEventListener('click', () => Terminal.searchNext());
if (downloadBtn) {
downloadBtn.addEventListener('click', () => Terminal.downloadOutput());
}
if (copyBtn) {
copyBtn.addEventListener('click', () => Terminal.copyOutput());
}
if (autoScrollCheck) {
autoScrollCheck.addEventListener('change', (e) => {
Terminal.autoScroll = e.target.checked;
});
}
},
search(query) {
const output = document.getElementById('terminal-output');
if (!output || !query) { Terminal.clearSearch(); return; }
const text = output.textContent;
Terminal.searchMatches = [];
Terminal.searchIndex = -1;
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
let match;
while ((match = regex.exec(text)) !== null) {
Terminal.searchMatches.push(match.index);
}
const countEl = document.getElementById('terminal-search-count');
if (countEl) countEl.textContent = Terminal.searchMatches.length > 0 ? `0/${Terminal.searchMatches.length}` : '0/0';
if (Terminal.searchMatches.length > 0) Terminal.searchNext();
},
searchNext() {
if (Terminal.searchMatches.length === 0) return;
Terminal.searchIndex = (Terminal.searchIndex + 1) % Terminal.searchMatches.length;
const countEl = document.getElementById('terminal-search-count');
if (countEl) countEl.textContent = `${Terminal.searchIndex + 1}/${Terminal.searchMatches.length}`;
},
searchPrev() {
if (Terminal.searchMatches.length === 0) return;
Terminal.searchIndex = Terminal.searchIndex <= 0 ? Terminal.searchMatches.length - 1 : Terminal.searchIndex - 1;
const countEl = document.getElementById('terminal-search-count');
if (countEl) countEl.textContent = `${Terminal.searchIndex + 1}/${Terminal.searchMatches.length}`;
},
clearSearch() {
Terminal.searchMatches = [];
Terminal.searchIndex = -1;
const countEl = document.getElementById('terminal-search-count');
if (countEl) countEl.textContent = '0/0';
},
downloadOutput() {
const output = document.getElementById('terminal-output');
if (!output) return;
const text = output.textContent;
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `terminal_${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
a.click();
URL.revokeObjectURL(url);
if (typeof Toast !== 'undefined') Toast.success('Saída baixada');
},
copyOutput() {
const output = document.getElementById('terminal-output');
if (!output) return;
navigator.clipboard.writeText(output.textContent).then(() => {
if (typeof Toast !== 'undefined') Toast.success('Saída copiada');
});
},
render() { render() {
Terminal.initToolbar();
const output = document.getElementById('terminal-output'); const output = document.getElementById('terminal-output');
if (!output) return; if (!output) return;
@@ -102,7 +219,7 @@ const Terminal = {
const html = lines.map((line) => { const html = lines.map((line) => {
const typeClass = line.type && line.type !== 'default' ? ' ' + line.type : ''; const typeClass = line.type && line.type !== 'default' ? ' ' + line.type : '';
const escaped = Terminal._escapeHtml(line.content); const escaped = Utils.escapeHtml(line.content);
const formatted = escaped.replace(/\n/g, '<br>'); const formatted = escaped.replace(/\n/g, '<br>');
return `<div class="terminal-line${typeClass}"> return `<div class="terminal-line${typeClass}">
@@ -120,14 +237,6 @@ const Terminal = {
if (Terminal.autoScroll) Terminal.scrollToBottom(); if (Terminal.autoScroll) Terminal.scrollToBottom();
}, },
_escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
}; };
window.Terminal = Terminal; window.Terminal = Terminal;

View File

@@ -1,9 +1,9 @@
const Toast = { const Toast = {
iconMap: { iconMap: {
success: 'check-circle', success: 'circle-check',
error: 'x-circle', error: 'circle-x',
info: 'info', info: 'info',
warning: 'alert-triangle', warning: 'triangle-alert',
}, },
colorMap: { colorMap: {
@@ -35,9 +35,7 @@ const Toast = {
container.appendChild(toast); container.appendChild(toast);
if (window.lucide) { Utils.refreshIcons(toast);
lucide.createIcons({ nodes: [toast] });
}
requestAnimationFrame(() => { requestAnimationFrame(() => {
toast.classList.add('toast-show'); toast.classList.add('toast-show');

View File

@@ -44,12 +44,12 @@ const WebhooksUI = {
<p class="empty-state-desc">Crie webhooks para disparar agentes ou pipelines via HTTP.</p> <p class="empty-state-desc">Crie webhooks para disparar agentes ou pipelines via HTTP.</p>
</div> </div>
`; `;
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
return; return;
} }
container.innerHTML = webhooks.map((w) => WebhooksUI._renderCard(w)).join(''); container.innerHTML = webhooks.map((w) => WebhooksUI._renderCard(w)).join('');
if (window.lucide) lucide.createIcons({ nodes: [container] }); Utils.refreshIcons(container);
}, },
_renderCard(webhook) { _renderCard(webhook) {
@@ -71,7 +71,7 @@ const WebhooksUI = {
<article class="webhook-card"> <article class="webhook-card">
<div class="webhook-card-header"> <div class="webhook-card-header">
<div class="webhook-card-identity"> <div class="webhook-card-identity">
<span class="webhook-card-name">${WebhooksUI._escapeHtml(webhook.name)}</span> <span class="webhook-card-name">${Utils.escapeHtml(webhook.name)}</span>
${typeBadge} ${typeBadge}
${statusBadge} ${statusBadge}
</div> </div>
@@ -79,6 +79,12 @@ const WebhooksUI = {
<button class="btn btn-ghost btn-sm btn-icon" data-action="toggle-webhook" data-id="${webhook.id}" title="${webhook.active ? 'Desativar' : 'Ativar'}"> <button class="btn btn-ghost btn-sm btn-icon" data-action="toggle-webhook" data-id="${webhook.id}" title="${webhook.active ? 'Desativar' : 'Ativar'}">
<i data-lucide="${webhook.active ? 'pause' : 'play'}"></i> <i data-lucide="${webhook.active ? 'pause' : 'play'}"></i>
</button> </button>
<button class="btn btn-ghost btn-sm btn-icon" data-action="edit-webhook" data-id="${webhook.id}" title="Editar">
<i data-lucide="pencil"></i>
</button>
<button class="btn btn-ghost btn-sm btn-icon" data-action="test-webhook" data-id="${webhook.id}" title="Testar">
<i data-lucide="zap"></i>
</button>
<button class="btn btn-ghost btn-sm btn-icon btn-danger" data-action="delete-webhook" data-id="${webhook.id}" title="Excluir"> <button class="btn btn-ghost btn-sm btn-icon btn-danger" data-action="delete-webhook" data-id="${webhook.id}" title="Excluir">
<i data-lucide="trash-2"></i> <i data-lucide="trash-2"></i>
</button> </button>
@@ -87,7 +93,7 @@ const WebhooksUI = {
<div class="webhook-card-body"> <div class="webhook-card-body">
<div class="webhook-card-target"> <div class="webhook-card-target">
<span class="webhook-card-label">Destino</span> <span class="webhook-card-label">Destino</span>
<span class="webhook-card-value">${WebhooksUI._escapeHtml(targetName)}</span> <span class="webhook-card-value">${Utils.escapeHtml(targetName)}</span>
</div> </div>
<div class="webhook-card-url"> <div class="webhook-card-url">
<span class="webhook-card-label">URL</span> <span class="webhook-card-label">URL</span>
@@ -141,19 +147,59 @@ const WebhooksUI = {
WebhooksUI._updateTargetSelect('agent'); WebhooksUI._updateTargetSelect('agent');
} }
const submitBtn = document.getElementById('webhook-form-submit');
if (submitBtn) submitBtn.dataset.editId = '';
Modal.open('webhook-modal-overlay'); Modal.open('webhook-modal-overlay');
}, },
openEditModal(webhookId) {
const webhook = WebhooksUI.webhooks.find(w => w.id === webhookId);
if (!webhook) return;
const titleEl = document.getElementById('webhook-modal-title');
if (titleEl) titleEl.textContent = 'Editar Webhook';
const nameEl = document.getElementById('webhook-name');
if (nameEl) nameEl.value = webhook.name || '';
const typeEl = document.getElementById('webhook-target-type');
if (typeEl) {
typeEl.value = webhook.targetType || 'agent';
WebhooksUI._updateTargetSelect(webhook.targetType || 'agent');
}
const targetEl = document.getElementById('webhook-target-id');
if (targetEl) targetEl.value = webhook.targetId || '';
const submitBtn = document.getElementById('webhook-form-submit');
if (submitBtn) submitBtn.dataset.editId = webhookId;
Modal.open('webhook-modal-overlay');
},
async test(webhookId) {
try {
const result = await API.webhooks.test(webhookId);
Toast.success(result.message || 'Webhook disparado com sucesso');
if (result.executionId || result.pipelineId) {
App.navigateTo('terminal');
}
} catch (err) {
Toast.error(`Erro ao testar webhook: ${err.message}`);
}
},
_updateTargetSelect(targetType) { _updateTargetSelect(targetType) {
const selectEl = document.getElementById('webhook-target-id'); const selectEl = document.getElementById('webhook-target-id');
if (!selectEl) return; if (!selectEl) return;
if (targetType === 'agent') { if (targetType === 'agent') {
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' + selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
WebhooksUI.agents.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`).join(''); WebhooksUI.agents.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`).join('');
} else { } else {
selectEl.innerHTML = '<option value="">Selecionar pipeline...</option>' + selectEl.innerHTML = '<option value="">Selecionar pipeline...</option>' +
WebhooksUI.pipelines.map((p) => `<option value="${p.id}">${p.name}</option>`).join(''); WebhooksUI.pipelines.map((p) => `<option value="${p.id}">${Utils.escapeHtml(p.name)}</option>`).join('');
} }
}, },
@@ -161,17 +207,25 @@ const WebhooksUI = {
const name = document.getElementById('webhook-name')?.value.trim(); const name = document.getElementById('webhook-name')?.value.trim();
const targetType = document.getElementById('webhook-target-type')?.value; const targetType = document.getElementById('webhook-target-type')?.value;
const targetId = document.getElementById('webhook-target-id')?.value; const targetId = document.getElementById('webhook-target-id')?.value;
const submitBtn = document.getElementById('webhook-form-submit');
const editId = submitBtn?.dataset.editId || '';
if (!name) { Toast.warning('Nome do webhook é obrigatório'); return; } if (!name) { Toast.warning('Nome do webhook é obrigatório'); return; }
if (!targetId) { Toast.warning('Selecione um destino'); return; } if (!targetId) { Toast.warning('Selecione um destino'); return; }
try { try {
await API.webhooks.create({ name, targetType, targetId }); if (editId) {
Modal.close('webhook-modal-overlay'); await API.webhooks.update(editId, { name, targetType, targetId });
Toast.success('Webhook criado com sucesso'); Modal.close('webhook-modal-overlay');
Toast.success('Webhook atualizado com sucesso');
} else {
await API.webhooks.create({ name, targetType, targetId });
Modal.close('webhook-modal-overlay');
Toast.success('Webhook criado com sucesso');
}
await WebhooksUI.load(); await WebhooksUI.load();
} catch (err) { } catch (err) {
Toast.error(`Erro ao criar webhook: ${err.message}`); Toast.error(`Erro ao salvar webhook: ${err.message}`);
} }
}, },
@@ -243,15 +297,6 @@ const WebhooksUI = {
} }
}, },
_escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
}; };
window.WebhooksUI = WebhooksUI; window.WebhooksUI = WebhooksUI;

16688
public/js/lucide.js Normal file

File diff suppressed because it is too large Load Diff

105
public/js/utils.js Normal file
View File

@@ -0,0 +1,105 @@
const Utils = {
escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
},
formatDuration(ms) {
if (!ms || ms < 0) return '—';
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60000);
const s = Math.floor((ms % 60000) / 1000);
return `${m}m ${s}s`;
},
formatCost(usd) {
if (!usd || usd === 0) return '$0.0000';
return `$${Number(usd).toFixed(4)}`;
},
truncate(str, max = 80) {
if (!str) return '';
return str.length > max ? str.slice(0, max) + '…' : str;
},
refreshIcons(container) {
if (!window.lucide) return;
const target = container || document;
const pending = target.querySelectorAll('i[data-lucide]');
if (pending.length === 0) return;
lucide.createIcons();
},
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
},
initDropzone(dropzoneId, fileInputId, fileListId) {
const zone = document.getElementById(dropzoneId);
const input = document.getElementById(fileInputId);
const list = document.getElementById(fileListId);
if (!zone || !input || !list) return null;
const state = { files: [] };
function render() {
list.innerHTML = state.files.map((f, i) => `
<li class="dropzone-file">
<span class="dropzone-file-name">${Utils.escapeHtml(f.name)}</span>
<span class="dropzone-file-size">${Utils.formatFileSize(f.size)}</span>
<button type="button" class="dropzone-file-remove" data-index="${i}" title="Remover">&times;</button>
</li>
`).join('');
const content = zone.querySelector('.dropzone-content');
if (content) content.style.display = state.files.length > 0 ? 'none' : '';
}
function addFiles(fileList) {
for (const f of fileList) {
if (state.files.length >= 20) break;
if (f.size > 10 * 1024 * 1024) continue;
const dupe = state.files.some(x => x.name === f.name && x.size === f.size);
if (!dupe) state.files.push(f);
}
render();
}
zone.addEventListener('click', (e) => {
if (e.target.closest('.dropzone-file-remove')) {
const idx = parseInt(e.target.closest('.dropzone-file-remove').dataset.index);
state.files.splice(idx, 1);
render();
return;
}
if (!e.target.closest('.dropzone-file')) input.click();
});
input.addEventListener('change', () => {
if (input.files.length > 0) addFiles(input.files);
input.value = '';
});
zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
});
state.reset = () => { state.files = []; render(); };
state.getFiles = () => state.files;
return state;
},
};
window.Utils = Utils;

131
server.js
View File

@@ -4,6 +4,10 @@ import { WebSocketServer } from 'ws';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import compression from 'compression';
import apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/routes/api.js'; import apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/routes/api.js';
import * as manager from './src/agents/manager.js'; import * as manager from './src/agents/manager.js';
import { setGlobalBroadcast } from './src/agents/manager.js'; import { setGlobalBroadcast } from './src/agents/manager.js';
@@ -12,36 +16,101 @@ import { flushAllStores } from './src/store/db.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '127.0.0.1';
const AUTH_TOKEN = process.env.AUTH_TOKEN || ''; const AUTH_TOKEN = process.env.AUTH_TOKEN || '';
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || ''; const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'http://localhost:3000';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
function timingSafeCompare(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const hashA = crypto.createHash('sha256').update(a).digest();
const hashB = crypto.createHash('sha256').update(b).digest();
return crypto.timingSafeEqual(hashA, hashB);
}
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Limite de requisições excedido. Tente novamente em breve.' },
});
const hookLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Limite de requisições de webhook excedido.' },
});
function verifyWebhookSignature(req, res, next) {
if (!WEBHOOK_SECRET) return next();
const sig = req.headers['x-hub-signature-256'];
if (!sig) return res.status(401).json({ error: 'Assinatura ausente' });
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
hmac.update(req.rawBody || '');
const expected = 'sha256=' + hmac.digest('hex');
try {
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).json({ error: 'Assinatura inválida' });
}
} catch {
return res.status(401).json({ error: 'Assinatura inválida' });
}
next();
}
const app = express(); const app = express();
const httpServer = createServer(app); const httpServer = createServer(app);
const wss = new WebSocketServer({ server: httpServer }); const wss = new WebSocketServer({ server: httpServer });
app.use((req, res, next) => { app.use((req, res, next) => {
const origin = ALLOWED_ORIGIN || req.headers.origin || '*'; res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGIN);
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Client-Id'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Client-Id, X-Correlation-ID');
if (req.method === 'OPTIONS') return res.sendStatus(204); if (req.method === 'OPTIONS') return res.sendStatus(204);
next(); next();
}); });
if (AUTH_TOKEN) { app.use((req, res, next) => {
app.use('/api', (req, res, next) => { req.correlationId = req.headers['x-correlation-id'] || crypto.randomUUID();
const header = req.headers.authorization || ''; res.setHeader('X-Correlation-ID', req.correlationId);
const token = header.startsWith('Bearer ') ? header.slice(7) : req.query.token; next();
if (token !== AUTH_TOKEN) { });
return res.status(401).json({ error: 'Token de autenticação inválido' });
}
next();
});
}
app.use(express.json()); app.get('/api/health', (req, res) => {
app.use('/hook', hookRouter); res.json({
app.use(express.static(join(__dirname, 'public'))); status: 'ok',
timestamp: new Date().toISOString(),
uptime: Math.floor(process.uptime()),
});
});
app.use(helmet({
contentSecurityPolicy: false,
}));
app.use(compression());
app.use('/api', apiLimiter);
app.use('/api', (req, res, next) => {
if (!AUTH_TOKEN) return next();
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : req.query.token;
if (!timingSafeCompare(token, AUTH_TOKEN)) {
return res.status(401).json({ error: 'Token de autenticação inválido' });
}
next();
});
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf || Buffer.alloc(0); },
}));
app.use('/hook', hookLimiter, verifyWebhookSignature, hookRouter);
app.use(express.static(join(__dirname, 'public'), { maxAge: '1h', etag: true }));
app.use('/api', apiRouter); app.use('/api', apiRouter);
const connectedClients = new Map(); const connectedClients = new Map();
@@ -51,20 +120,30 @@ wss.on('connection', (ws, req) => {
if (AUTH_TOKEN) { if (AUTH_TOKEN) {
const token = new URL(req.url, 'http://localhost').searchParams.get('token'); const token = new URL(req.url, 'http://localhost').searchParams.get('token');
if (token !== AUTH_TOKEN) { if (!timingSafeCompare(token, AUTH_TOKEN)) {
ws.close(4001, 'Token inválido'); ws.close(4001, 'Token inválido');
return; return;
} }
} }
ws.clientId = clientId; ws.clientId = clientId;
ws.isAlive = true;
connectedClients.set(clientId, ws); connectedClients.set(clientId, ws);
ws.on('pong', () => { ws.isAlive = true; });
ws.on('close', () => connectedClients.delete(clientId)); ws.on('close', () => connectedClients.delete(clientId));
ws.on('error', () => connectedClients.delete(clientId)); ws.on('error', () => connectedClients.delete(clientId));
ws.send(JSON.stringify({ type: 'connected', clientId })); ws.send(JSON.stringify({ type: 'connected', clientId }));
}); });
const wsHeartbeat = setInterval(() => {
wss.clients.forEach(ws => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
function broadcast(message) { function broadcast(message) {
const payload = JSON.stringify(message); const payload = JSON.stringify(message);
for (const [, client] of connectedClients) { for (const [, client] of connectedClients) {
@@ -92,6 +171,8 @@ function gracefulShutdown(signal) {
flushAllStores(); flushAllStores();
console.log('Dados persistidos.'); console.log('Dados persistidos.');
clearInterval(wsHeartbeat);
httpServer.close(() => { httpServer.close(() => {
console.log('Servidor HTTP encerrado.'); console.log('Servidor HTTP encerrado.');
process.exit(0); process.exit(0);
@@ -106,10 +187,18 @@ function gracefulShutdown(signal) {
process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT')); process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('uncaughtException', (err) => {
console.error('[FATAL] Exceção não capturada:', err.message);
console.error(err.stack);
});
process.on('unhandledRejection', (reason) => {
console.error('[WARN] Promise rejeitada não tratada:', reason);
});
manager.restoreSchedules(); manager.restoreSchedules();
httpServer.listen(PORT, () => { httpServer.listen(PORT, HOST, () => {
console.log(`Painel administrativo disponível em http://localhost:${PORT}`); console.log(`Painel administrativo disponível em http://${HOST}:${PORT}`);
console.log(`WebSocket server ativo na mesma porta.`); console.log(`WebSocket server ativo na mesma porta.`);
if (AUTH_TOKEN) console.log('Autenticação por token ativada.');
}); });

View File

@@ -1,10 +1,14 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import path from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { settingsStore } from '../store/db.js'; import { settingsStore } from '../store/db.js';
const CLAUDE_BIN = resolveBin(); const CLAUDE_BIN = resolveBin();
const activeExecutions = new Map(); const activeExecutions = new Map();
const MAX_OUTPUT_SIZE = 512 * 1024;
const MAX_ERROR_SIZE = 100 * 1024;
const ALLOWED_DIRECTORIES = (process.env.ALLOWED_DIRECTORIES || '').split(',').map(d => d.trim()).filter(Boolean);
let maxConcurrent = settingsStore.get().maxConcurrent || 5; let maxConcurrent = settingsStore.get().maxConcurrent || 5;
@@ -12,6 +16,12 @@ export function updateMaxConcurrent(value) {
maxConcurrent = Math.max(1, Math.min(20, parseInt(value) || 5)); maxConcurrent = Math.max(1, Math.min(20, parseInt(value) || 5));
} }
function isDirectoryAllowed(dir) {
if (ALLOWED_DIRECTORIES.length === 0) return true;
const resolved = path.resolve(dir);
return ALLOWED_DIRECTORIES.some(allowed => resolved.startsWith(path.resolve(allowed)));
}
function resolveBin() { function resolveBin() {
if (process.env.CLAUDE_BIN) return process.env.CLAUDE_BIN; if (process.env.CLAUDE_BIN) return process.env.CLAUDE_BIN;
const home = process.env.HOME || ''; const home = process.env.HOME || '';
@@ -34,13 +44,16 @@ function sanitizeText(str) {
.slice(0, 50000); .slice(0, 50000);
} }
function cleanEnv() { function cleanEnv(agentSecrets) {
const env = { ...process.env }; const env = { ...process.env };
delete env.CLAUDECODE; delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY; delete env.ANTHROPIC_API_KEY;
if (!env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) { if (!env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '128000'; env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '128000';
} }
if (agentSecrets && typeof agentSecrets === 'object') {
Object.assign(env, agentSecrets);
}
return env; return env;
} }
@@ -111,58 +124,101 @@ function extractText(event) {
return null; return null;
} }
export function execute(agentConfig, task, callbacks = {}) { function extractToolInfo(event) {
if (activeExecutions.size >= maxConcurrent) { if (!event) return null;
const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`);
if (callbacks.onError) callbacks.onError(err, uuidv4());
return null;
}
const executionId = uuidv4(); if (event.type === 'assistant' && event.message?.content) {
const { onData, onError, onComplete } = callbacks; const toolBlocks = event.message.content.filter((b) => b.type === 'tool_use');
if (toolBlocks.length > 0) {
const prompt = buildPrompt(task.description || task, task.instructions); return toolBlocks.map((b) => {
const args = buildArgs(agentConfig, prompt); const name = b.name || 'unknown';
const input = b.input || {};
const spawnOptions = { let detail = '';
env: cleanEnv(), if (input.command) detail = input.command.slice(0, 120);
stdio: ['ignore', 'pipe', 'pipe'], else if (input.file_path) detail = input.file_path;
}; else if (input.pattern) detail = input.pattern;
else if (input.query) detail = input.query;
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) { else if (input.path) detail = input.path;
if (!existsSync(agentConfig.workingDirectory)) { else if (input.prompt) detail = input.prompt.slice(0, 80);
const err = new Error(`Diretório de trabalho não encontrado: ${agentConfig.workingDirectory}`); else if (input.description) detail = input.description.slice(0, 80);
if (onError) onError(err, executionId); return { name, detail };
return executionId; });
} }
spawnOptions.cwd = agentConfig.workingDirectory;
} }
console.log(`[executor] Iniciando: ${executionId} | Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`); if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
return [{ name: event.content_block.name || 'tool', detail: '' }];
}
const child = spawn(CLAUDE_BIN, args, spawnOptions); return null;
let hadError = false; }
activeExecutions.set(executionId, { function extractSystemInfo(event) {
process: child, if (!event) return null;
agentConfig,
task,
startedAt: new Date().toISOString(),
executionId,
});
if (event.type === 'system' && event.message) return event.message;
if (event.type === 'error') return event.error?.message || event.message || 'Erro desconhecido';
if (event.type === 'result') {
const parts = [];
if (event.num_turns) parts.push(`${event.num_turns} turnos`);
if (event.cost_usd) parts.push(`custo: $${event.cost_usd.toFixed(4)}`);
if (event.duration_ms) {
const s = (event.duration_ms / 1000).toFixed(1);
parts.push(`duração: ${s}s`);
}
if (event.session_id) parts.push(`sessão: ${event.session_id.slice(0, 8)}...`);
return parts.length > 0 ? `Resultado: ${parts.join(' | ')}` : null;
}
return null;
}
function processChildOutput(child, executionId, callbacks, options = {}) {
const { onData, onError, onComplete } = callbacks;
const timeoutMs = options.timeout || 1800000;
const sessionIdOverride = options.sessionIdOverride || null;
let outputBuffer = ''; let outputBuffer = '';
let errorBuffer = ''; let errorBuffer = '';
let fullText = ''; let fullText = '';
let resultMeta = null; let resultMeta = null;
let turnCount = 0;
let hadError = false;
const timeout = setTimeout(() => {
child.kill('SIGTERM');
setTimeout(() => { if (!child.killed) child.kill('SIGKILL'); }, 5000);
}, timeoutMs);
function processEvent(parsed) { function processEvent(parsed) {
if (!parsed) return; if (!parsed) return;
const tools = extractToolInfo(parsed);
if (tools) {
for (const t of tools) {
const msg = t.detail ? `${t.name}: ${t.detail}` : t.name;
if (onData) onData({ type: 'tool', content: msg, toolName: t.name }, executionId);
}
}
const text = extractText(parsed); const text = extractText(parsed);
if (text) { if (text) {
fullText += text; if (fullText.length < MAX_OUTPUT_SIZE) {
fullText += text;
}
if (onData) onData({ type: 'chunk', content: text }, executionId); if (onData) onData({ type: 'chunk', content: text }, executionId);
} }
const sysInfo = extractSystemInfo(parsed);
if (sysInfo) {
if (onData) onData({ type: 'system', content: sysInfo }, executionId);
}
if (parsed.type === 'assistant') {
turnCount++;
if (onData) onData({ type: 'turn', content: `Turno ${turnCount}`, turn: turnCount }, executionId);
}
if (parsed.type === 'result') { if (parsed.type === 'result') {
resultMeta = { resultMeta = {
costUsd: parsed.cost_usd || 0, costUsd: parsed.cost_usd || 0,
@@ -170,7 +226,7 @@ export function execute(agentConfig, task, callbacks = {}) {
durationMs: parsed.duration_ms || 0, durationMs: parsed.duration_ms || 0,
durationApiMs: parsed.duration_api_ms || 0, durationApiMs: parsed.duration_api_ms || 0,
numTurns: parsed.num_turns || 0, numTurns: parsed.num_turns || 0,
sessionId: parsed.session_id || '', sessionId: parsed.session_id || sessionIdOverride || '',
}; };
} }
} }
@@ -182,10 +238,18 @@ export function execute(agentConfig, task, callbacks = {}) {
}); });
child.stderr.on('data', (chunk) => { child.stderr.on('data', (chunk) => {
errorBuffer += chunk.toString(); const str = chunk.toString();
if (errorBuffer.length < MAX_ERROR_SIZE) {
errorBuffer += str;
}
const lines = str.split('\n').filter(l => l.trim());
for (const line of lines) {
if (onData) onData({ type: 'stderr', content: line.trim() }, executionId);
}
}); });
child.on('error', (err) => { child.on('error', (err) => {
clearTimeout(timeout);
console.log(`[executor][error] ${err.message}`); console.log(`[executor][error] ${err.message}`);
hadError = true; hadError = true;
activeExecutions.delete(executionId); activeExecutions.delete(executionId);
@@ -193,21 +257,81 @@ export function execute(agentConfig, task, callbacks = {}) {
}); });
child.on('close', (code) => { child.on('close', (code) => {
clearTimeout(timeout);
const wasCanceled = activeExecutions.get(executionId)?.canceled || false;
activeExecutions.delete(executionId); activeExecutions.delete(executionId);
if (hadError) return; if (hadError) return;
if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer)); if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer));
if (onComplete) { if (onComplete) {
onComplete({ onComplete({
executionId, executionId,
exitCode: code, exitCode: code,
result: fullText, result: fullText,
stderr: errorBuffer, stderr: errorBuffer,
canceled: wasCanceled,
...(resultMeta || {}), ...(resultMeta || {}),
}, executionId); }, executionId);
} }
}); });
}
function validateWorkingDirectory(agentConfig, executionId, onError) {
if (!agentConfig.workingDirectory || !agentConfig.workingDirectory.trim()) return true;
if (!isDirectoryAllowed(agentConfig.workingDirectory)) {
const err = new Error(`Diretório de trabalho não permitido: ${agentConfig.workingDirectory}`);
if (onError) onError(err, executionId);
return false;
}
if (!existsSync(agentConfig.workingDirectory)) {
const err = new Error(`Diretório de trabalho não encontrado: ${agentConfig.workingDirectory}`);
if (onError) onError(err, executionId);
return false;
}
return true;
}
export function execute(agentConfig, task, callbacks = {}, secrets = null) {
if (activeExecutions.size >= maxConcurrent) {
const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`);
if (callbacks.onError) callbacks.onError(err, uuidv4());
return null;
}
const executionId = uuidv4();
const { onData, onError, onComplete } = callbacks;
if (!validateWorkingDirectory(agentConfig, executionId, onError)) return null;
const prompt = buildPrompt(task.description || task, task.instructions);
const args = buildArgs(agentConfig, prompt);
const spawnOptions = {
env: cleanEnv(secrets),
stdio: ['ignore', 'pipe', 'pipe'],
};
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) {
spawnOptions.cwd = agentConfig.workingDirectory;
}
console.log(`[executor] Iniciando: ${executionId} | Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`);
const child = spawn(CLAUDE_BIN, args, spawnOptions);
activeExecutions.set(executionId, {
process: child,
agentConfig,
task,
startedAt: new Date().toISOString(),
executionId,
});
processChildOutput(child, executionId, { onData, onError, onComplete }, {
timeout: agentConfig.timeout || 1800000,
});
return executionId; return executionId;
} }
@@ -222,6 +346,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
const executionId = uuidv4(); const executionId = uuidv4();
const { onData, onError, onComplete } = callbacks; const { onData, onError, onComplete } = callbacks;
if (!validateWorkingDirectory(agentConfig, executionId, onError)) return null;
const model = agentConfig.model || 'claude-sonnet-4-6'; const model = agentConfig.model || 'claude-sonnet-4-6';
const args = [ const args = [
'--resume', sessionId, '--resume', sessionId,
@@ -242,18 +368,12 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
}; };
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) { if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) {
if (!existsSync(agentConfig.workingDirectory)) {
const err = new Error(`Diretório de trabalho não encontrado: ${agentConfig.workingDirectory}`);
if (onError) onError(err, executionId);
return executionId;
}
spawnOptions.cwd = agentConfig.workingDirectory; spawnOptions.cwd = agentConfig.workingDirectory;
} }
console.log(`[executor] Resumindo sessão: ${sessionId} | Execução: ${executionId}`); console.log(`[executor] Resumindo sessão: ${sessionId} | Execução: ${executionId}`);
const child = spawn(CLAUDE_BIN, args, spawnOptions); const child = spawn(CLAUDE_BIN, args, spawnOptions);
let hadError = false;
activeExecutions.set(executionId, { activeExecutions.set(executionId, {
process: child, process: child,
@@ -263,60 +383,9 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
executionId, executionId,
}); });
let outputBuffer = ''; processChildOutput(child, executionId, { onData, onError, onComplete }, {
let errorBuffer = ''; timeout: agentConfig.timeout || 1800000,
let fullText = ''; sessionIdOverride: sessionId,
let resultMeta = null;
function processEvent(parsed) {
if (!parsed) return;
const text = extractText(parsed);
if (text) {
fullText += text;
if (onData) onData({ type: 'chunk', content: text }, executionId);
}
if (parsed.type === 'result') {
resultMeta = {
costUsd: parsed.cost_usd || 0,
totalCostUsd: parsed.total_cost_usd || 0,
durationMs: parsed.duration_ms || 0,
durationApiMs: parsed.duration_api_ms || 0,
numTurns: parsed.num_turns || 0,
sessionId: parsed.session_id || sessionId,
};
}
}
child.stdout.on('data', (chunk) => {
const lines = (outputBuffer + chunk.toString()).split('\n');
outputBuffer = lines.pop();
for (const line of lines) processEvent(parseStreamLine(line));
});
child.stderr.on('data', (chunk) => {
errorBuffer += chunk.toString();
});
child.on('error', (err) => {
console.log(`[executor][error] ${err.message}`);
hadError = true;
activeExecutions.delete(executionId);
if (onError) onError(err, executionId);
});
child.on('close', (code) => {
activeExecutions.delete(executionId);
if (hadError) return;
if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer));
if (onComplete) {
onComplete({
executionId,
exitCode: code,
result: fullText,
stderr: errorBuffer,
...(resultMeta || {}),
}, executionId);
}
}); });
return executionId; return executionId;
@@ -325,8 +394,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
export function cancel(executionId) { export function cancel(executionId) {
const execution = activeExecutions.get(executionId); const execution = activeExecutions.get(executionId);
if (!execution) return false; if (!execution) return false;
execution.canceled = true;
execution.process.kill('SIGTERM'); execution.process.kill('SIGTERM');
activeExecutions.delete(executionId);
return true; return true;
} }

View File

@@ -1,7 +1,9 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { agentsStore, schedulesStore, executionsStore } from '../store/db.js'; import cron from 'node-cron';
import { agentsStore, schedulesStore, executionsStore, notificationsStore, secretsStore, agentVersionsStore, withLock } from '../store/db.js';
import * as executor from './executor.js'; import * as executor from './executor.js';
import * as scheduler from './scheduler.js'; import * as scheduler from './scheduler.js';
import { generateAgentReport } from '../reports/generator.js';
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
model: 'claude-sonnet-4-6', model: 'claude-sonnet-4-6',
@@ -25,6 +27,14 @@ function getWsCallback(wsCallback) {
return wsCallback || globalBroadcast || null; return wsCallback || globalBroadcast || null;
} }
function createNotification(type, title, message, metadata = {}) {
notificationsStore.create({
type, title, message, metadata,
read: false,
createdAt: new Date().toISOString(),
});
}
let dailyExecutionCount = 0; let dailyExecutionCount = 0;
let dailyCountDate = new Date().toDateString(); let dailyCountDate = new Date().toDateString();
@@ -90,6 +100,13 @@ export function createAgent(data) {
export function updateAgent(id, data) { export function updateAgent(id, data) {
const existing = agentsStore.getById(id); const existing = agentsStore.getById(id);
if (!existing) return null; if (!existing) return null;
agentVersionsStore.create({
agentId: id,
version: existing,
changedFields: Object.keys(data).filter(k => k !== 'id'),
});
const updateData = {}; const updateData = {};
if (data.agent_name !== undefined) updateData.agent_name = data.agent_name; if (data.agent_name !== undefined) updateData.agent_name = data.agent_name;
if (data.description !== undefined) updateData.description = data.description; if (data.description !== undefined) updateData.description = data.description;
@@ -105,25 +122,44 @@ export function deleteAgent(id) {
return agentsStore.delete(id); return agentsStore.delete(id);
} }
function loadAgentSecrets(agentId) {
const all = secretsStore.getAll();
const agentSecrets = all.filter(s => s.agentId === agentId);
if (agentSecrets.length === 0) return null;
const env = {};
for (const s of agentSecrets) env[s.name] = s.value;
return env;
}
export function executeTask(agentId, task, instructions, wsCallback, metadata = {}) { export function executeTask(agentId, task, instructions, wsCallback, metadata = {}) {
const agent = agentsStore.getById(agentId); const agent = agentsStore.getById(agentId);
if (!agent) throw new Error(`Agente ${agentId} não encontrado`); if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
if (agent.status !== 'active') throw new Error(`Agente ${agentId} está inativo`); if (agent.status !== 'active') throw new Error(`Agente ${agentId} está inativo`);
const retryEnabled = agent.config?.retryOnFailure === true;
const maxRetries = Math.min(Math.max(parseInt(agent.config?.maxRetries) || 1, 1), 3);
const attempt = metadata._retryAttempt || 1;
const cb = getWsCallback(wsCallback); const cb = getWsCallback(wsCallback);
const taskText = typeof task === 'string' ? task : task.description; const taskText = typeof task === 'string' ? task : task.description;
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const historyRecord = executionsStore.create({ const historyRecord = metadata._historyRecordId
type: 'agent', ? { id: metadata._historyRecordId }
...metadata, : executionsStore.create({
agentId, type: 'agent',
agentName: agent.agent_name, ...metadata,
task: taskText, agentId,
instructions: instructions || '', agentName: agent.agent_name,
status: 'running', task: taskText,
startedAt, instructions: instructions || '',
}); status: 'running',
startedAt,
});
if (metadata._retryAttempt) {
executionsStore.update(historyRecord.id, { status: 'running', error: null });
}
const execRecord = { const execRecord = {
executionId: null, executionId: null,
@@ -134,6 +170,8 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
status: 'running', status: 'running',
}; };
const agentSecrets = loadAgentSecrets(agentId);
const executionId = executor.execute( const executionId = executor.execute(
agent.config, agent.config,
{ description: task, instructions }, { description: task, instructions },
@@ -144,7 +182,33 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
onError: (err, execId) => { onError: (err, execId) => {
const endedAt = new Date().toISOString(); const endedAt = new Date().toISOString();
updateExecutionRecord(agentId, execId, { status: 'error', error: err.message, endedAt }); updateExecutionRecord(agentId, execId, { status: 'error', error: err.message, endedAt });
if (retryEnabled && attempt < maxRetries) {
const delayMs = attempt * 5000;
executionsStore.update(historyRecord.id, { status: 'retrying', error: err.message, attempt, endedAt });
if (cb) cb({
type: 'execution_retry',
executionId: execId,
agentId,
data: { attempt, maxRetries, nextRetryIn: delayMs / 1000, reason: err.message },
});
setTimeout(() => {
try {
executeTask(agentId, task, instructions, wsCallback, {
...metadata,
_retryAttempt: attempt + 1,
_historyRecordId: historyRecord.id,
});
} catch (retryErr) {
executionsStore.update(historyRecord.id, { status: 'error', error: retryErr.message, endedAt: new Date().toISOString() });
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: retryErr.message } });
}
}, delayMs);
return;
}
executionsStore.update(historyRecord.id, { status: 'error', error: err.message, endedAt }); executionsStore.update(historyRecord.id, { status: 'error', error: err.message, endedAt });
createNotification('error', 'Execução falhou', `Agente "${agent.agent_name}" encontrou um erro`, { agentId, executionId: execId });
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: err.message } }); if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: err.message } });
}, },
onComplete: (result, execId) => { onComplete: (result, execId) => {
@@ -161,9 +225,18 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
numTurns: result.numTurns || 0, numTurns: result.numTurns || 0,
sessionId: result.sessionId || '', sessionId: result.sessionId || '',
}); });
createNotification('success', 'Execução concluída', `Agente "${agent.agent_name}" finalizou a tarefa`, { agentId, executionId: execId });
try {
const updated = executionsStore.getById(historyRecord.id);
if (updated) {
const report = generateAgentReport(updated);
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
}
} catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); }
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result }); if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
}, },
} },
agentSecrets
); );
if (!executionId) { if (!executionId) {
@@ -185,18 +258,15 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
return executionId; return executionId;
} }
function updateRecentBuffer(executionId, updates) { async function updateExecutionRecord(agentId, executionId, updates) {
const entry = recentExecBuffer.find((e) => e.executionId === executionId); await withLock(`agent:${agentId}`, () => {
if (entry) Object.assign(entry, updates); const agent = agentsStore.getById(agentId);
} if (!agent) return;
const executions = (agent.executions || []).map((exec) =>
function updateExecutionRecord(agentId, executionId, updates) { exec.executionId === executionId ? { ...exec, ...updates } : exec
const agent = agentsStore.getById(agentId); );
if (!agent) return; agentsStore.update(agentId, { executions });
const executions = (agent.executions || []).map((exec) => });
exec.executionId === executionId ? { ...exec, ...updates } : exec
);
agentsStore.update(agentId, { executions });
} }
export function getRecentExecutions(limit = 20) { export function getRecentExecutions(limit = 20) {
@@ -207,6 +277,10 @@ export function scheduleTask(agentId, taskDescription, cronExpression, wsCallbac
const agent = agentsStore.getById(agentId); const agent = agentsStore.getById(agentId);
if (!agent) throw new Error(`Agente ${agentId} não encontrado`); if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
if (!cron.validate(cronExpression)) {
throw new Error(`Expressão cron inválida: ${cronExpression}`);
}
const scheduleId = uuidv4(); const scheduleId = uuidv4();
const items = schedulesStore.getAll(); const items = schedulesStore.getAll();
items.push({ items.push({
@@ -290,6 +364,13 @@ export function continueConversation(agentId, sessionId, message, wsCallback) {
numTurns: result.numTurns || 0, numTurns: result.numTurns || 0,
sessionId: result.sessionId || sessionId, sessionId: result.sessionId || sessionId,
}); });
try {
const updated = executionsStore.getById(historyRecord.id);
if (updated) {
const report = generateAgentReport(updated);
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
}
} catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); }
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result }); if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
}, },
} }

View File

@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
import { pipelinesStore, agentsStore, executionsStore } from '../store/db.js'; import { pipelinesStore, agentsStore, executionsStore } from '../store/db.js';
import * as executor from './executor.js'; import * as executor from './executor.js';
import { mem } from '../cache/index.js'; import { mem } from '../cache/index.js';
import { generatePipelineReport } from '../reports/generator.js';
const activePipelines = new Map(); const activePipelines = new Map();
const AGENT_MAP_TTL = 30_000; const AGENT_MAP_TTL = 30_000;
@@ -99,9 +100,9 @@ function executeStepAsPromise(agentConfig, prompt, pipelineState, wsCallback, pi
}); });
} }
function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCallback) { function waitForApproval(executionId, pipelineId, stepIndex, previousOutput, agentName, wsCallback) {
return new Promise((resolve) => { return new Promise((resolve) => {
const state = activePipelines.get(pipelineId); const state = activePipelines.get(executionId);
if (!state) { resolve(false); return; } if (!state) { resolve(false); return; }
state.pendingApproval = { state.pendingApproval = {
@@ -115,6 +116,7 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal
wsCallback({ wsCallback({
type: 'pipeline_approval_required', type: 'pipeline_approval_required',
pipelineId, pipelineId,
executionId,
stepIndex, stepIndex,
agentName, agentName,
previousOutput: previousOutput.slice(0, 3000), previousOutput: previousOutput.slice(0, 3000),
@@ -123,8 +125,16 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal
}); });
} }
export function approvePipelineStep(pipelineId) { function findPipelineState(idOrExecId) {
const state = activePipelines.get(pipelineId); if (activePipelines.has(idOrExecId)) return activePipelines.get(idOrExecId);
for (const [, state] of activePipelines) {
if (state.pipelineId === idOrExecId) return state;
}
return null;
}
export function approvePipelineStep(id) {
const state = findPipelineState(id);
if (!state?.pendingApproval) return false; if (!state?.pendingApproval) return false;
const { resolve } = state.pendingApproval; const { resolve } = state.pendingApproval;
state.pendingApproval = null; state.pendingApproval = null;
@@ -132,8 +142,8 @@ export function approvePipelineStep(pipelineId) {
return true; return true;
} }
export function rejectPipelineStep(pipelineId) { export function rejectPipelineStep(id) {
const state = activePipelines.get(pipelineId); const state = findPipelineState(id);
if (!state?.pendingApproval) return false; if (!state?.pendingApproval) return false;
const { resolve } = state.pendingApproval; const { resolve } = state.pendingApproval;
state.pendingApproval = null; state.pendingApproval = null;
@@ -144,9 +154,11 @@ export function rejectPipelineStep(pipelineId) {
export async function executePipeline(pipelineId, initialInput, wsCallback, options = {}) { export async function executePipeline(pipelineId, initialInput, wsCallback, options = {}) {
const pl = pipelinesStore.getById(pipelineId); const pl = pipelinesStore.getById(pipelineId);
if (!pl) throw new Error(`Pipeline ${pipelineId} não encontrado`); if (!pl) throw new Error(`Pipeline ${pipelineId} não encontrado`);
if (pl.status !== 'active') throw new Error(`Pipeline "${pl.name}" está desativado`);
const pipelineState = { currentExecutionId: null, currentStep: 0, canceled: false, pendingApproval: null }; const executionId = uuidv4();
activePipelines.set(pipelineId, pipelineState); const pipelineState = { pipelineId, currentExecutionId: null, currentStep: 0, canceled: false, pendingApproval: null };
activePipelines.set(executionId, pipelineState);
const historyRecord = executionsStore.create({ const historyRecord = executionsStore.create({
type: 'pipeline', type: 'pipeline',
@@ -180,7 +192,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
wsCallback({ type: 'pipeline_status', pipelineId, status: 'awaiting_approval', stepIndex: i }); wsCallback({ type: 'pipeline_status', pipelineId, status: 'awaiting_approval', stepIndex: i });
} }
const approved = await waitForApproval(pipelineId, i, currentInput, prevAgentName, wsCallback); const approved = await waitForApproval(executionId, pipelineId, i, currentInput, prevAgentName, wsCallback);
if (!approved) { if (!approved) {
pipelineState.canceled = true; pipelineState.canceled = true;
@@ -256,7 +268,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
} }
} }
activePipelines.delete(pipelineId); activePipelines.delete(executionId);
const finalStatus = pipelineState.canceled ? 'canceled' : 'completed'; const finalStatus = pipelineState.canceled ? 'canceled' : 'completed';
executionsStore.update(historyRecord.id, { executionsStore.update(historyRecord.id, {
@@ -265,13 +277,20 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
totalCostUsd: totalCost, totalCostUsd: totalCost,
}); });
if (!pipelineState.canceled && wsCallback) { if (!pipelineState.canceled) {
wsCallback({ type: 'pipeline_complete', pipelineId, results, totalCostUsd: totalCost }); try {
const updated = executionsStore.getById(historyRecord.id);
if (updated) {
const report = generatePipelineReport(updated);
if (wsCallback) wsCallback({ type: 'report_generated', pipelineId, reportFile: report.filename });
}
} catch (e) { console.error('[pipeline] Erro ao gerar relatório:', e.message); }
if (wsCallback) wsCallback({ type: 'pipeline_complete', pipelineId, executionId, results, totalCostUsd: totalCost });
} }
return results; return { executionId, results };
} catch (err) { } catch (err) {
activePipelines.delete(pipelineId); activePipelines.delete(executionId);
executionsStore.update(historyRecord.id, { executionsStore.update(historyRecord.id, {
status: 'error', status: 'error',
error: err.message, error: err.message,
@@ -290,8 +309,14 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
} }
} }
export function cancelPipeline(pipelineId) { export function cancelPipeline(id) {
const state = activePipelines.get(pipelineId); let executionId = id;
let state = activePipelines.get(id);
if (!state) {
for (const [execId, s] of activePipelines) {
if (s.pipelineId === id) { state = s; executionId = execId; break; }
}
}
if (!state) return false; if (!state) return false;
state.canceled = true; state.canceled = true;
if (state.pendingApproval) { if (state.pendingApproval) {
@@ -299,13 +324,21 @@ export function cancelPipeline(pipelineId) {
state.pendingApproval = null; state.pendingApproval = null;
} }
if (state.currentExecutionId) executor.cancel(state.currentExecutionId); if (state.currentExecutionId) executor.cancel(state.currentExecutionId);
activePipelines.delete(pipelineId); activePipelines.delete(executionId);
const allExecs = executionsStore.getAll();
const exec = allExecs.find(e => e.pipelineId === state.pipelineId && (e.status === 'running' || e.status === 'awaiting_approval'));
if (exec) {
executionsStore.update(exec.id, { status: 'canceled', endedAt: new Date().toISOString() });
}
return true; return true;
} }
export function getActivePipelines() { export function getActivePipelines() {
return Array.from(activePipelines.entries()).map(([id, state]) => ({ return Array.from(activePipelines.entries()).map(([id, state]) => ({
pipelineId: id, executionId: id,
pipelineId: state.pipelineId,
currentStep: state.currentStep, currentStep: state.currentStep,
currentExecutionId: state.currentExecutionId, currentExecutionId: state.currentExecutionId,
pendingApproval: !!state.pendingApproval, pendingApproval: !!state.pendingApproval,

View File

@@ -17,7 +17,11 @@ function addToHistory(entry) {
function matchesCronPart(part, value) { function matchesCronPart(part, value) {
if (part === '*') return true; if (part === '*') return true;
if (part.startsWith('*/')) return value % parseInt(part.slice(2)) === 0; if (part.startsWith('*/')) {
const divisor = parseInt(part.slice(2));
if (!divisor || divisor <= 0) return false;
return value % divisor === 0;
}
if (part.includes(',')) return part.split(',').map(Number).includes(value); if (part.includes(',')) return part.split(',').map(Number).includes(value);
if (part.includes('-')) { if (part.includes('-')) {
const [start, end] = part.split('-').map(Number); const [start, end] = part.split('-').map(Number);
@@ -66,6 +70,17 @@ export function schedule(taskId, cronExpr, callback, persist = true) {
if (schedules.has(taskId)) unschedule(taskId, false); if (schedules.has(taskId)) unschedule(taskId, false);
if (!cron.validate(cronExpr)) throw new Error(`Expressão cron inválida: ${cronExpr}`); if (!cron.validate(cronExpr)) throw new Error(`Expressão cron inválida: ${cronExpr}`);
const MIN_INTERVAL_PARTS = cronExpr.split(' ');
if (MIN_INTERVAL_PARTS[0] === '*' && MIN_INTERVAL_PARTS[1] === '*') {
throw new Error('Intervalo mínimo de agendamento é 5 minutos. Use */5 ou maior.');
}
if (MIN_INTERVAL_PARTS[0].startsWith('*/')) {
const interval = parseInt(MIN_INTERVAL_PARTS[0].slice(2));
if (interval < 5 && MIN_INTERVAL_PARTS[1] === '*') {
throw new Error(`Intervalo mínimo de agendamento é 5 minutos. Recebido: ${cronExpr}`);
}
}
const task = cron.schedule( const task = cron.schedule(
cronExpr, cronExpr,
() => { () => {

194
src/reports/generator.js Normal file
View File

@@ -0,0 +1,194 @@
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPORTS_DIR = join(__dirname, '..', '..', 'data', 'reports');
const EXTERNAL_REPORTS_DIR = process.env.AGENT_REPORTS_DIR || join(process.env.HOME || '/home/fred', 'agent_reports');
function ensureDir() {
if (!existsSync(REPORTS_DIR)) mkdirSync(REPORTS_DIR, { recursive: true });
if (!existsSync(EXTERNAL_REPORTS_DIR)) mkdirSync(EXTERNAL_REPORTS_DIR, { recursive: true });
}
function sanitizeFilename(name) {
return name.replace(/[^a-zA-Z0-9À-ÿ_-]/g, '_').slice(0, 60);
}
function timestamp() {
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
}
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('pt-BR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
function formatDuration(startIso, endIso) {
if (!startIso || !endIso) return '—';
const ms = new Date(endIso) - new Date(startIso);
if (ms < 0) return '—';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
if (h > 0) return `${h}h ${m % 60}m ${s % 60}s`;
return `${m}m ${s % 60}s`;
}
export function generateAgentReport(execution) {
ensureDir();
const name = execution.agentName || 'Agente';
const filename = `agente_${sanitizeFilename(name)}_${timestamp()}.md`;
const filepath = join(REPORTS_DIR, filename);
const status = execution.status === 'completed' ? '✅ Concluído' : '❌ Erro';
const cost = (execution.costUsd || execution.totalCostUsd || 0).toFixed(4);
const lines = [
`# Relatório de Execução — ${name}`,
'',
`**Data:** ${formatDate(execution.startedAt)}`,
`**Status:** ${status}`,
`**Duração:** ${formatDuration(execution.startedAt, execution.endedAt)}`,
`**Custo:** $${cost}`,
`**Turnos:** ${execution.numTurns || '—'}`,
`**Session ID:** \`${execution.sessionId || '—'}\``,
'',
'---',
'',
'## Tarefa',
'',
execution.task || '_(sem tarefa definida)_',
'',
];
if (execution.instructions) {
lines.push('## Instruções Adicionais', '', execution.instructions, '');
}
lines.push('---', '', '## Resultado', '');
if (execution.status === 'error' && execution.error) {
lines.push('### Erro', '', '```', execution.error, '```', '');
}
if (execution.result) {
lines.push(execution.result);
} else {
lines.push('_(sem resultado textual)_');
}
lines.push('', '---', '', `_Relatório gerado automaticamente em ${formatDate(new Date().toISOString())}_`);
const content = lines.join('\n');
writeFileSync(filepath, content, 'utf-8');
try { writeFileSync(join(EXTERNAL_REPORTS_DIR, filename), content, 'utf-8'); } catch {}
return { filename, filepath };
}
export function generatePipelineReport(execution) {
ensureDir();
const name = execution.pipelineName || 'Pipeline';
const filename = `pipeline_${sanitizeFilename(name)}_${timestamp()}.md`;
const filepath = join(REPORTS_DIR, filename);
const status = execution.status === 'completed' ? '✅ Concluído'
: execution.status === 'error' ? '❌ Erro'
: execution.status === 'canceled' ? '⚠️ Cancelado'
: execution.status;
const totalCost = (execution.totalCostUsd || 0).toFixed(4);
const steps = Array.isArray(execution.steps) ? execution.steps : [];
const lines = [
`# Relatório de Pipeline — ${name}`,
'',
`**Data:** ${formatDate(execution.startedAt)}`,
`**Status:** ${status}`,
`**Duração:** ${formatDuration(execution.startedAt, execution.endedAt)}`,
`**Custo Total:** $${totalCost}`,
`**Passos:** ${steps.length}`,
'',
'---',
'',
'## Input Inicial',
'',
execution.input || '_(sem input)_',
'',
'---',
'',
'## Execução dos Passos',
'',
];
steps.forEach((step, i) => {
const stepStatus = step.status === 'completed' ? '✅' : step.status === 'error' ? '❌' : '⏳';
const stepCost = (step.costUsd || 0).toFixed(4);
const stepDuration = formatDuration(step.startedAt, step.endedAt);
lines.push(
`### Passo ${i + 1}${step.agentName || 'Agente'} ${stepStatus}`,
'',
`| Propriedade | Valor |`,
`|-------------|-------|`,
`| Status | ${step.status || '—'} |`,
`| Duração | ${stepDuration} |`,
`| Custo | $${stepCost} |`,
`| Turnos | ${step.numTurns || '—'} |`,
'',
);
if (step.prompt) {
lines.push(
'<details>',
'<summary>Prompt utilizado</summary>',
'',
'```',
step.prompt,
'```',
'',
'</details>',
'',
);
}
if (step.result) {
lines.push('**Resultado:**', '', step.result, '');
} else if (step.status === 'error') {
lines.push('**Erro:** Passo falhou durante a execução.', '');
}
if (i < steps.length - 1) {
lines.push('---', '');
}
});
if (execution.error) {
lines.push('---', '', '## Erro da Pipeline', '', '```', execution.error, '```', '');
}
const lastStep = steps[steps.length - 1];
if (execution.status === 'completed' && lastStep?.result) {
lines.push(
'---',
'',
'## Resultado Final',
'',
lastStep.result,
'',
);
}
lines.push('---', '', `_Relatório gerado automaticamente em ${formatDate(new Date().toISOString())}_`);
const content = lines.join('\n');
writeFileSync(filepath, content, 'utf-8');
try { writeFileSync(join(EXTERNAL_REPORTS_DIR, filename), content, 'utf-8'); } catch {}
return { filename, filepath };
}

View File

@@ -1,15 +1,40 @@
import { Router } from 'express'; import { Router } from 'express';
import { execSync } from 'child_process'; import { execFile } from 'child_process';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto'; import crypto from 'crypto';
import os from 'os'; import os from 'os';
import multer from 'multer';
import * as manager from '../agents/manager.js'; import * as manager from '../agents/manager.js';
import { tasksStore, settingsStore, executionsStore, webhooksStore } from '../store/db.js'; import { tasksStore, settingsStore, executionsStore, webhooksStore, notificationsStore, secretsStore, agentVersionsStore } from '../store/db.js';
import * as scheduler from '../agents/scheduler.js'; import * as scheduler from '../agents/scheduler.js';
import * as pipeline from '../agents/pipeline.js'; import * as pipeline from '../agents/pipeline.js';
import { getBinPath, updateMaxConcurrent } from '../agents/executor.js'; import { getBinPath, updateMaxConcurrent } from '../agents/executor.js';
import { invalidateAgentMapCache } from '../agents/pipeline.js'; import { invalidateAgentMapCache } from '../agents/pipeline.js';
import { cached } from '../cache/index.js'; import { cached } from '../cache/index.js';
import { readdirSync, readFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
import { join, dirname, resolve as pathResolve, extname } from 'path';
import { fileURLToPath } from 'url';
const __apiDirname = dirname(fileURLToPath(import.meta.url));
const REPORTS_DIR = join(__apiDirname, '..', '..', 'data', 'reports');
const UPLOADS_DIR = join(__apiDirname, '..', '..', 'data', 'uploads');
if (!existsSync(UPLOADS_DIR)) mkdirSync(UPLOADS_DIR, { recursive: true });
const upload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => {
const sessionDir = join(UPLOADS_DIR, req.uploadSessionId || 'tmp');
if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true });
cb(null, sessionDir);
},
filename: (req, file, cb) => {
const safe = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200);
cb(null, `${Date.now()}-${safe}`);
},
}),
limits: { fileSize: 10 * 1024 * 1024, files: 20 },
});
const router = Router(); const router = Router();
export const hookRouter = Router(); export const hookRouter = Router();
@@ -116,12 +141,36 @@ router.delete('/agents/:id', (req, res) => {
} }
}); });
router.post('/uploads', (req, res, next) => {
req.uploadSessionId = uuidv4();
next();
}, upload.array('files', 20), (req, res) => {
try {
const files = (req.files || []).map(f => ({
originalName: f.originalname,
path: f.path,
size: f.size,
}));
res.json({ sessionId: req.uploadSessionId, files });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
function buildContextFilesPrompt(contextFiles) {
if (!Array.isArray(contextFiles) || contextFiles.length === 0) return '';
const lines = contextFiles.map(f => `- ${f.path} (${f.originalName})`);
return `\n\nArquivos de contexto anexados (leia cada um deles antes de iniciar):\n${lines.join('\n')}`;
}
router.post('/agents/:id/execute', (req, res) => { router.post('/agents/:id/execute', (req, res) => {
try { try {
const { task, instructions } = req.body; const { task, instructions, contextFiles } = req.body;
if (!task) return res.status(400).json({ error: 'task é obrigatório' }); if (!task) return res.status(400).json({ error: 'task é obrigatório' });
const clientId = req.headers['x-client-id'] || null; const clientId = req.headers['x-client-id'] || null;
const executionId = manager.executeTask(req.params.id, task, instructions, (msg) => wsCallback(msg, clientId)); const filesPrompt = buildContextFilesPrompt(contextFiles);
const fullTask = task + filesPrompt;
const executionId = manager.executeTask(req.params.id, fullTask, instructions, (msg) => wsCallback(msg, clientId));
res.status(202).json({ executionId, status: 'started' }); res.status(202).json({ executionId, status: 'started' });
} catch (err) { } catch (err) {
const status = err.message.includes('não encontrado') ? 404 : 400; const status = err.message.includes('não encontrado') ? 404 : 400;
@@ -163,6 +212,111 @@ router.get('/agents/:id/export', (req, res) => {
} }
}); });
router.get('/agents/:id/secrets', (req, res) => {
try {
const agent = manager.getAgentById(req.params.id);
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
const all = secretsStore.getAll();
const agentSecrets = all
.filter((s) => s.agentId === req.params.id)
.map((s) => ({ name: s.name, created_at: s.created_at }));
res.json(agentSecrets);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/agents/:id/secrets', (req, res) => {
try {
const agent = manager.getAgentById(req.params.id);
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
const { name, value } = req.body;
if (!name || !value) return res.status(400).json({ error: 'name e value são obrigatórios' });
const all = secretsStore.getAll();
const existing = all.find((s) => s.agentId === req.params.id && s.name === name);
if (existing) {
secretsStore.update(existing.id, { value });
return res.json({ name, updated: true });
}
secretsStore.create({ agentId: req.params.id, name, value });
res.status(201).json({ name, created: true });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.delete('/agents/:id/secrets/:name', (req, res) => {
try {
const secretName = decodeURIComponent(req.params.name);
const all = secretsStore.getAll();
const secret = all.find((s) => s.agentId === req.params.id && s.name === secretName);
if (!secret) return res.status(404).json({ error: 'Secret não encontrado' });
secretsStore.delete(secret.id);
res.status(204).send();
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/agents/:id/versions', (req, res) => {
try {
const agent = manager.getAgentById(req.params.id);
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
const all = agentVersionsStore.getAll();
const versions = all
.filter((v) => v.agentId === req.params.id)
.sort((a, b) => b.version - a.version);
res.json(versions);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/agents/:id/versions/:version/restore', (req, res) => {
try {
const agent = manager.getAgentById(req.params.id);
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
const versionNum = parseInt(req.params.version);
const all = agentVersionsStore.getAll();
const target = all.find((v) => v.agentId === req.params.id && v.version === versionNum);
if (!target) return res.status(404).json({ error: 'Versão não encontrada' });
if (!target.snapshot) return res.status(400).json({ error: 'Snapshot da versão não disponível' });
const { id, created_at, updated_at, ...snapshotData } = target.snapshot;
const restored = manager.updateAgent(req.params.id, snapshotData);
if (!restored) return res.status(500).json({ error: 'Falha ao restaurar versão' });
invalidateAgentMapCache();
agentVersionsStore.create({
agentId: req.params.id,
version: Math.max(...all.filter((v) => v.agentId === req.params.id).map((v) => v.version), 0) + 1,
changes: ['restore'],
changelog: `Restaurado para versão ${versionNum}`,
snapshot: structuredClone(restored),
});
res.json(restored);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/agents/:id/duplicate', async (req, res) => {
try {
const agent = manager.getAgentById(req.params.id);
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
const { id, created_at, updated_at, executions, ...rest } = agent;
const duplicate = {
...rest,
agent_name: `${agent.agent_name} (cópia)`,
executions: [],
status: 'active',
};
const created = manager.createAgent(duplicate);
invalidateAgentMapCache();
res.status(201).json(created);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/tasks', (req, res) => { router.get('/tasks', (req, res) => {
try { try {
res.json(tasksStore.getAll()); res.json(tasksStore.getAll());
@@ -304,17 +458,20 @@ router.delete('/pipelines/:id', (req, res) => {
} }
}); });
router.post('/pipelines/:id/execute', (req, res) => { router.post('/pipelines/:id/execute', async (req, res) => {
try { try {
const { input, workingDirectory } = req.body; const { input, workingDirectory, contextFiles } = req.body;
if (!input) return res.status(400).json({ error: 'input é obrigatório' }); if (!input) return res.status(400).json({ error: 'input é obrigatório' });
const clientId = req.headers['x-client-id'] || null; const clientId = req.headers['x-client-id'] || null;
const options = {}; const options = {};
if (workingDirectory) options.workingDirectory = workingDirectory; if (workingDirectory) options.workingDirectory = workingDirectory;
pipeline.executePipeline(req.params.id, input, (msg) => wsCallback(msg, clientId), options).catch(() => {}); const filesPrompt = buildContextFilesPrompt(contextFiles);
const fullInput = input + filesPrompt;
const result = pipeline.executePipeline(req.params.id, fullInput, (msg) => wsCallback(msg, clientId), options);
result.catch(() => {});
res.status(202).json({ pipelineId: req.params.id, status: 'started' }); res.status(202).json({ pipelineId: req.params.id, status: 'started' });
} catch (err) { } catch (err) {
const status = err.message.includes('não encontrado') ? 404 : 400; const status = err.message.includes('não encontrado') || err.message.includes('desativado') ? 400 : 500;
res.status(status).json({ error: err.message }); res.status(status).json({ error: err.message });
} }
}); });
@@ -388,11 +545,11 @@ router.put('/webhooks/:id', (req, res) => {
try { try {
const existing = webhooksStore.getById(req.params.id); const existing = webhooksStore.getById(req.params.id);
if (!existing) return res.status(404).json({ error: 'Webhook não encontrado' }); if (!existing) return res.status(404).json({ error: 'Webhook não encontrado' });
const allowed = ['name', 'targetType', 'targetId', 'active'];
const updateData = {}; const updateData = {};
if (req.body.name !== undefined) updateData.name = req.body.name; for (const key of allowed) {
if (req.body.active !== undefined) updateData.active = !!req.body.active; if (req.body[key] !== undefined) updateData[key] = req.body[key];
}
const updated = webhooksStore.update(req.params.id, updateData); const updated = webhooksStore.update(req.params.id, updateData);
res.json(updated); res.json(updated);
} catch (err) { } catch (err) {
@@ -400,6 +557,29 @@ router.put('/webhooks/:id', (req, res) => {
} }
}); });
router.post('/webhooks/:id/test', async (req, res) => {
try {
const wh = webhooksStore.getById(req.params.id);
if (!wh) return res.status(404).json({ error: 'Webhook não encontrado' });
if (wh.targetType === 'agent') {
const executionId = manager.executeTask(wh.targetId, 'Teste de webhook', '', (msg) => {
if (wsbroadcast) wsbroadcast(msg);
}, { source: 'webhook-test', webhookId: wh.id });
res.status(202).json({ success: true, message: 'Webhook disparado com sucesso', executionId });
} else if (wh.targetType === 'pipeline') {
pipeline.executePipeline(wh.targetId, 'Teste de webhook', (msg) => {
if (wsbroadcast) wsbroadcast(msg);
}).catch(() => {});
res.status(202).json({ success: true, message: 'Pipeline disparada com sucesso', pipelineId: wh.targetId });
} else {
return res.status(400).json({ error: `targetType inválido: ${wh.targetType}` });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/webhooks/:id', (req, res) => { router.delete('/webhooks/:id', (req, res) => {
try { try {
const deleted = webhooksStore.delete(req.params.id); const deleted = webhooksStore.delete(req.params.id);
@@ -434,12 +614,12 @@ hookRouter.post('/:token', (req, res) => {
res.status(202).json({ executionId, status: 'started', webhook: webhook.name }); res.status(202).json({ executionId, status: 'started', webhook: webhook.name });
} else if (webhook.targetType === 'pipeline') { } else if (webhook.targetType === 'pipeline') {
const input = payload.input || payload.task || payload.message || 'Webhook trigger'; const input = payload.input || payload.task || payload.message || 'Webhook trigger';
const options = {};
if (payload.workingDirectory) options.workingDirectory = payload.workingDirectory;
pipeline.executePipeline(webhook.targetId, input, (msg) => { pipeline.executePipeline(webhook.targetId, input, (msg) => {
if (wsbroadcast) wsbroadcast(msg); if (wsbroadcast) wsbroadcast(msg);
}, options).catch(() => {}); }).catch(() => {});
res.status(202).json({ pipelineId: webhook.targetId, status: 'started', webhook: webhook.name }); res.status(202).json({ pipelineId: webhook.targetId, status: 'started', webhook: webhook.name });
} else {
return res.status(400).json({ error: `targetType inválido: ${webhook.targetType}` });
} }
} catch (err) { } catch (err) {
const status = err.message.includes('não encontrado') ? 404 : 500; const status = err.message.includes('não encontrado') ? 404 : 500;
@@ -553,11 +733,16 @@ router.get('/system/status', (req, res) => {
let claudeVersionCache = null; let claudeVersionCache = null;
router.get('/system/info', (req, res) => { router.get('/system/info', async (req, res) => {
try { try {
if (claudeVersionCache === null) { if (claudeVersionCache === null) {
try { try {
claudeVersionCache = execSync(`${getBinPath()} --version`, { timeout: 5000 }).toString().trim(); claudeVersionCache = await new Promise((resolve, reject) => {
execFile(getBinPath(), ['--version'], { timeout: 5000 }, (err, stdout) => {
if (err) reject(err);
else resolve(stdout.toString().trim());
});
});
} catch { } catch {
claudeVersionCache = 'N/A'; claudeVersionCache = 'N/A';
} }
@@ -652,4 +837,175 @@ router.get('/executions/recent', (req, res) => {
} }
}); });
router.post('/executions/:id/retry', async (req, res) => {
try {
const execution = executionsStore.getById(req.params.id);
if (!execution) return res.status(404).json({ error: 'Execução não encontrada' });
if (!['error', 'canceled'].includes(execution.status)) {
return res.status(400).json({ error: 'Apenas execuções com erro ou canceladas podem ser reexecutadas' });
}
const clientId = req.headers['x-client-id'] || null;
if (execution.type === 'pipeline') {
pipeline.executePipeline(execution.pipelineId, execution.input, (msg) => wsCallback(msg, clientId)).catch(() => {});
return res.json({ success: true, message: 'Pipeline reexecutado' });
}
manager.executeTask(execution.agentId, execution.task, null, (msg) => wsCallback(msg, clientId));
res.json({ success: true, message: 'Execução reiniciada' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/executions/export', async (req, res) => {
try {
const executions = executionsStore.getAll();
const headers = ['ID', 'Tipo', 'Nome', 'Status', 'Início', 'Fim', 'Duração (ms)', 'Custo (USD)', 'Turnos'];
const rows = executions.map(e => [
e.id,
e.type || 'agent',
e.agentName || e.pipelineName || '',
e.status,
e.startedAt || '',
e.endedAt || '',
e.durationMs || '',
e.costUsd || e.totalCostUsd || '',
e.numTurns || '',
]);
const csv = [headers.join(','), ...rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename=executions_${new Date().toISOString().split('T')[0]}.csv`);
res.send('\uFEFF' + csv);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/stats/charts', async (req, res) => {
try {
const days = parseInt(req.query.days) || 7;
const executions = executionsStore.getAll();
const now = new Date();
const labels = [];
const executionCounts = [];
const costData = [];
const successCounts = [];
const errorCounts = [];
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
labels.push(dateStr);
const dayExecs = executions.filter(e => e.startedAt && e.startedAt.startsWith(dateStr));
executionCounts.push(dayExecs.length);
costData.push(+(dayExecs.reduce((sum, e) => sum + (e.costUsd || e.totalCostUsd || 0), 0)).toFixed(4));
successCounts.push(dayExecs.filter(e => e.status === 'completed').length);
errorCounts.push(dayExecs.filter(e => e.status === 'error').length);
}
const agentCounts = {};
executions.forEach(e => {
if (e.agentName) agentCounts[e.agentName] = (agentCounts[e.agentName] || 0) + 1;
});
const topAgents = Object.entries(agentCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([name, count]) => ({ name, count }));
const statusDist = {};
executions.forEach(e => { statusDist[e.status] = (statusDist[e.status] || 0) + 1; });
res.json({ labels, executionCounts, costData, successCounts, errorCounts, topAgents, statusDistribution: statusDist });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/notifications', async (req, res) => {
try {
const notifications = notificationsStore.getAll();
const unreadCount = notifications.filter(n => !n.read).length;
res.json({ notifications: notifications.slice(-50).reverse(), unreadCount });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.post('/notifications/:id/read', (req, res) => {
try {
const updated = notificationsStore.update(req.params.id, { read: true });
if (!updated) return res.status(404).json({ error: 'Notificação não encontrada' });
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.post('/notifications/read-all', (req, res) => {
try {
const notifications = notificationsStore.getAll();
for (const n of notifications) {
if (!n.read) notificationsStore.update(n.id, { read: true });
}
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.delete('/notifications', async (req, res) => {
try {
notificationsStore.save([]);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/reports', (req, res) => {
try {
if (!existsSync(REPORTS_DIR)) return res.json([]);
const files = readdirSync(REPORTS_DIR)
.filter(f => f.endsWith('.md'))
.sort((a, b) => b.localeCompare(a))
.slice(0, 100);
res.json(files);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/reports/:filename', (req, res) => {
try {
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
if (!filename.endsWith('.md')) return res.status(400).json({ error: 'Formato inválido' });
const filepath = join(REPORTS_DIR, filename);
const resolved = pathResolve(filepath);
if (!resolved.startsWith(pathResolve(REPORTS_DIR))) {
return res.status(400).json({ error: 'Caminho inválido' });
}
if (!existsSync(filepath)) return res.status(404).json({ error: 'Relatório não encontrado' });
const content = readFileSync(filepath, 'utf-8');
res.json({ filename, content });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/reports/:filename', (req, res) => {
try {
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
const filepath = join(REPORTS_DIR, filename);
const resolved = pathResolve(filepath);
if (!resolved.startsWith(pathResolve(REPORTS_DIR))) {
return res.status(400).json({ error: 'Caminho inválido' });
}
if (!existsSync(filepath)) return res.status(404).json({ error: 'Relatório não encontrado' });
unlinkSync(filepath);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router; export default router;

View File

@@ -1,4 +1,5 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'fs';
import { writeFile, rename } from 'fs/promises';
import { dirname } from 'path'; import { dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@@ -30,7 +31,16 @@ function readJson(path, fallback) {
function writeJson(path, data) { function writeJson(path, data) {
ensureDir(); ensureDir();
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8'); const tmpPath = path + '.tmp';
writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8');
renameSync(tmpPath, path);
}
async function writeJsonAsync(path, data) {
ensureDir();
const tmpPath = path + '.tmp';
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf8');
await rename(tmpPath, path);
} }
function clone(v) { function clone(v) {
@@ -41,6 +51,7 @@ function createStore(filePath) {
let mem = null; let mem = null;
let dirty = false; let dirty = false;
let timer = null; let timer = null;
let maxSize = Infinity;
function boot() { function boot() {
if (mem !== null) return; if (mem !== null) return;
@@ -54,7 +65,7 @@ function createStore(filePath) {
timer = setTimeout(() => { timer = setTimeout(() => {
timer = null; timer = null;
if (dirty) { if (dirty) {
writeJson(filePath, mem); writeJsonAsync(filePath, mem).catch((e) => console.error(`[db] Erro ao salvar ${filePath}:`, e.message));
dirty = false; dirty = false;
} }
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
@@ -72,6 +83,20 @@ function createStore(filePath) {
return item ? clone(item) : null; return item ? clone(item) : null;
}, },
findById(id) {
return store.getById(id);
},
count() {
boot();
return mem.length;
},
filter(predicate) {
boot();
return mem.filter(predicate).map((item) => clone(item));
},
create(data) { create(data) {
boot(); boot();
const item = { const item = {
@@ -81,6 +106,9 @@ function createStore(filePath) {
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}; };
mem.push(item); mem.push(item);
if (maxSize !== Infinity && mem.length > maxSize) {
mem.splice(0, mem.length - maxSize);
}
touch(); touch();
return clone(item); return clone(item);
}, },
@@ -104,7 +132,8 @@ function createStore(filePath) {
}, },
save(items) { save(items) {
mem = Array.isArray(items) ? items : mem; if (!Array.isArray(items)) return;
mem = items;
touch(); touch();
}, },
@@ -118,6 +147,10 @@ function createStore(filePath) {
dirty = false; dirty = false;
} }
}, },
setMaxSize(n) {
maxSize = n;
},
}; };
allStores.push(store); allStores.push(store);
@@ -176,6 +209,21 @@ function createSettingsStore(filePath) {
return store; return store;
} }
const locks = new Map();
export async function withLock(key, fn) {
while (locks.has(key)) await locks.get(key);
let resolve;
const promise = new Promise((r) => { resolve = r; });
locks.set(key, promise);
try {
return await fn();
} finally {
locks.delete(key);
resolve();
}
}
export function flushAllStores() { export function flushAllStores() {
for (const s of allStores) s.flush(); for (const s of allStores) s.flush();
} }
@@ -185,5 +233,10 @@ export const tasksStore = createStore(`${DATA_DIR}/tasks.json`);
export const pipelinesStore = createStore(`${DATA_DIR}/pipelines.json`); export const pipelinesStore = createStore(`${DATA_DIR}/pipelines.json`);
export const schedulesStore = createStore(`${DATA_DIR}/schedules.json`); export const schedulesStore = createStore(`${DATA_DIR}/schedules.json`);
export const executionsStore = createStore(`${DATA_DIR}/executions.json`); export const executionsStore = createStore(`${DATA_DIR}/executions.json`);
executionsStore.setMaxSize(5000);
export const webhooksStore = createStore(`${DATA_DIR}/webhooks.json`); export const webhooksStore = createStore(`${DATA_DIR}/webhooks.json`);
export const settingsStore = createSettingsStore(`${DATA_DIR}/settings.json`); export const settingsStore = createSettingsStore(`${DATA_DIR}/settings.json`);
export const secretsStore = createStore(`${DATA_DIR}/secrets.json`);
export const notificationsStore = createStore(`${DATA_DIR}/notifications.json`);
notificationsStore.setMaxSize(200);
export const agentVersionsStore = createStore(`${DATA_DIR}/agent_versions.json`);