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
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
- **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.
- **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).
- **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.
- **Agendamento cron** — Agende tarefas recorrentes com expressões cron. Presets incluídos (horário, diário, semanal, mensal).
- **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.
- **Exportação** — Exporte a configuração completa de qualquer agente em JSON.
### Gerenciamento de Agentes
- Crie agentes com nome, system prompt, modelo (Sonnet/Opus/Haiku), diretório de trabalho, ferramentas permitidas, modo de permissão e tags
- Ative, desative, edite, **duplique** ou exclua a qualquer momento
- Exporte/importe configurações completas em JSON
### 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)
- **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
@@ -37,7 +100,18 @@ npm run dev
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
@@ -57,7 +131,8 @@ Acesse **http://localhost:3000** no navegador. A porta pode ser alterada via var
1. Vá em **Pipelines****Novo Pipeline**
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
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
@@ -68,32 +143,40 @@ Acesse **http://localhost:3000** no navegador. A porta pode ser alterada via var
## Arquitetura
```
server.js Express + WebSocket na mesma porta
server.js Express + WebSocket + rate limiting + auth
src/
routes/api.js API REST (/api/*)
routes/api.js API REST (/api/*) — 30+ endpoints
agents/
manager.js CRUD + orquestração de agentes
manager.js CRUD + orquestração + notificações
executor.js Spawna o CLI claude como child_process
scheduler.js Agendamento cron (in-memory)
pipeline.js Execução sequencial de steps
store/db.js Persistência em JSON (data/*.json)
scheduler.js Agendamento cron (in-memory + persistido)
pipeline.js Execução sequencial com aprovação humana
store/db.js Persistência em JSON com escrita atômica
cache/index.js Cache em 2 níveis (memória + Redis opcional)
public/
index.html SPA single-page
css/styles.css Estilos (Inter, JetBrains Mono, Lucide)
index.html SPA single-page com hash routing
css/styles.css Design system (dark/light themes)
js/
app.js Controlador principal + WebSocket client
app.js Controlador principal + WebSocket + tema + routing
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/
agents.json Agentes cadastrados
tasks.json Templates de tarefas
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.
## API REST
### Agentes
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `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 |
| `DELETE` | `/api/agents/:id` | Excluir 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) |
| `POST` | `/api/agents/:id/duplicate` | Duplicar agente |
### Tarefas
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `GET` | `/api/tasks` | Listar tarefas |
| `POST` | `/api/tasks` | Criar tarefa |
| `PUT` | `/api/tasks/:id` | Atualizar tarefa |
| `DELETE` | `/api/tasks/:id` | Excluir tarefa |
### Agendamentos
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `GET` | `/api/schedules` | Listar agendamentos |
| `POST` | `/api/schedules` | Criar agendamento |
| `PUT` | `/api/schedules/:taskId` | Atualizar 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 |
| `POST` | `/api/pipelines` | Criar 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 |
| `POST` | `/api/pipelines/:id/execute` | Executar 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/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
@@ -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_complete` | Pipeline finalizado |
| `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
- **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)
- **Gráficos**: Chart.js 4.x
- **Ícones**: Lucide
- **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

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",
"version": "1.0.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agents-orchestrator",
"version": "1.0.0",
"version": "1.1.0",
"dependencies": {
"compression": "^1.8.1",
"express": "^4.21.0",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"multer": "^2.0.2",
"node-cron": "^3.0.3",
"uuid": "^10.0.0",
"ws": "^8.18.0"
@@ -27,6 +31,12 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -57,6 +67,23 @@
"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": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -95,6 +122,60 @@
"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": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -279,6 +360,24 @@
"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": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -397,6 +496,15 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -435,6 +543,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"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": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -513,12 +630,51 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"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": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -549,6 +705,15 @@
"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": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -573,6 +738,15 @@
"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": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -640,6 +814,20 @@
"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": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -798,6 +986,23 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -820,6 +1025,12 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -829,6 +1040,12 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -880,6 +1097,15 @@
"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"
},
"dependencies": {
"compression": "^1.8.1",
"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",
"uuid": "^10.0.0"
"uuid": "^10.0.0",
"ws": "^8.18.0"
}
}

View File

@@ -447,6 +447,8 @@ textarea {
border-radius: 12px;
overflow: hidden;
transition: all 0.3s;
display: flex;
flex-direction: column;
animation: fadeInUp 0.3s ease both;
}
@@ -461,6 +463,7 @@ textarea {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
.agent-card-top {
@@ -526,9 +529,17 @@ textarea {
.agent-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
padding: 12px 16px;
border-top: 1px solid var(--border-primary);
margin-top: auto;
}
.agent-actions-icons {
display: flex;
gap: 4px;
margin-left: auto;
}
.badge {
@@ -664,7 +675,7 @@ textarea {
color: #ffffff;
}
.btn-icon {
.btn.btn-icon {
width: 36px;
height: 36px;
padding: 0;
@@ -679,6 +690,9 @@ textarea {
.btn-sm.btn-icon {
width: 30px;
height: 30px;
min-width: 30px;
padding: 0;
flex-shrink: 0;
}
.btn-lg {
@@ -1053,6 +1067,24 @@ textarea {
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 {
display: inline-block;
width: 8px;
@@ -1967,6 +1999,7 @@ tbody tr:hover td {
flex: 1;
padding: 24px 32px;
overflow-y: auto;
overflow-x: hidden;
}
.section {
@@ -2780,6 +2813,118 @@ tbody tr:hover td {
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 {
display: flex;
gap: 8px;
@@ -3219,6 +3364,7 @@ tbody tr:hover td {
.agent-card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border-primary);
@@ -3395,6 +3541,10 @@ tbody tr:hover td {
color: #8b5cf6;
}
#history-list {
overflow: hidden;
}
.history-card {
background-color: var(--bg-card);
border: 1px solid var(--border-primary);
@@ -3406,6 +3556,8 @@ tbody tr:hover td {
transition: all 0.2s;
animation: fadeInUp 0.2s ease both;
margin-bottom: 8px;
overflow: hidden;
min-width: 0;
}
.history-card:hover {
@@ -3419,6 +3571,7 @@ tbody tr:hover td {
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
min-width: 0;
}
.history-card-identity {
@@ -3435,26 +3588,7 @@ tbody tr:hover td {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-card-status {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.history-card-date {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
}
.history-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
max-width: 300px;
}
.history-card-task {
@@ -3463,24 +3597,31 @@ tbody tr:hover td {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
max-width: 100%;
}
.history-card-info {
display: flex;
align-items: center;
gap: 16px;
font-size: 12px;
color: var(--text-muted);
}
.history-card-date,
.history-card-duration {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
gap: 5px;
white-space: nowrap;
}
.history-card-date i,
.history-card-duration i {
width: 12px;
height: 12px;
width: 13px;
height: 13px;
flex-shrink: 0;
opacity: 0.6;
}
.history-card-actions {
@@ -3986,12 +4127,6 @@ tbody tr:hover td {
height: 12px;
}
.history-card-duration-group {
display: flex;
align-items: center;
gap: 12px;
}
.pipeline-step-meta-group {
display: flex;
align-items: center;
@@ -4042,3 +4177,448 @@ tbody tr:hover td {
display: block;
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 name="viewport" content="width=device-width, initial-scale=1.0" />
<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="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -104,13 +105,35 @@
<span>Sistema</span>
</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>
</button>
<button class="btn btn--primary btn--icon-text" id="new-agent-btn" type="button">
<i data-lucide="plus"></i>
<span>Novo Agente</span>
</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>
</header>
@@ -138,7 +161,7 @@
</article>
<article class="metric-card">
<div class="metric-card-icon metric-card-icon--purple">
<i data-lucide="play-circle"></i>
<i data-lucide="circle-play"></i>
</div>
<div class="metric-card-body">
<span class="metric-card-label">Execuções Hoje</span>
@@ -174,6 +197,46 @@
</article>
</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="card">
<div class="card-header">
@@ -424,6 +487,32 @@
</button>
</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="terminal-output" id="terminal-output" role="log" aria-live="polite" aria-label="Saída do terminal">
<div class="terminal-welcome">
@@ -471,6 +560,10 @@
</select>
</div>
<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">
<i data-lucide="trash-2"></i>
Limpar Histórico
@@ -552,6 +645,64 @@
<span class="system-info-label">Tempo Online</span>
<span class="system-info-value font-mono" id="info-uptime">Carregando...</span>
</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>
</div>
</div>
@@ -687,6 +838,28 @@
</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">
<label class="form-label" for="agent-tags-input">Tags</label>
<div class="tags-input-wrapper" id="agent-tags-wrapper">
@@ -701,6 +874,24 @@
</div>
<input type="hidden" id="agent-tags" name="tags" value="[]" />
</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>
</div>
<div class="modal-footer">
@@ -710,6 +901,23 @@
</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 modal--md">
<div class="modal-header">
@@ -766,6 +974,19 @@
></textarea>
</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">
<p class="quick-templates-label">Templates rápidos</p>
<div class="quick-templates-grid">
@@ -778,11 +999,11 @@
<span>Revisão OWASP</span>
</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.">
<i data-lucide="wand-2"></i>
<i data-lucide="wand"></i>
<span>Refatorar Código</span>
</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.">
<i data-lucide="test-tube-2"></i>
<i data-lucide="test-tube"></i>
<span>Escrever Testes</span>
</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.">
@@ -966,6 +1187,18 @@
</label>
<textarea class="textarea" id="pipeline-execute-input" rows="4" placeholder="Descreva a tarefa inicial para o pipeline..."></textarea>
</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 class="modal-footer">
<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-header">
<div class="confirm-modal-icon" id="confirm-modal-icon">
<i data-lucide="alert-triangle"></i>
<i data-lucide="triangle-alert"></i>
</div>
<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">
@@ -1060,7 +1293,7 @@
<div class="modal-footer">
<button class="btn btn--ghost" type="button" data-modal-close="import-modal-overlay">Cancelar</button>
<button class="btn btn--primary btn--icon-text" type="button" id="import-confirm-btn">
<i data-lucide="upload"></i>
<i data-lucide="file-down"></i>
<span>Importar</span>
</button>
</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>
<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/utils.js"></script>
<script src="js/components/toast.js"></script>
<script src="js/components/modal.js"></script>
<script src="js/components/terminal.js"></script>
@@ -1129,9 +1363,10 @@
<script src="js/components/settings.js"></script>
<script src="js/components/history.js"></script>
<script src="js/components/webhooks.js"></script>
<script src="js/components/notifications.js"></script>
<script src="js/app.js"></script>
<script>
lucide.createIcons();
Utils.refreshIcons();
App.init();
</script>
</body>

View File

@@ -38,11 +38,27 @@ const API = {
create(data) { return API.request('POST', '/agents', data); },
update(id, data) { return API.request('PUT', `/agents/${id}`, data); },
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}`); },
continue(id, sessionId, message) { return API.request('POST', `/agents/${id}/continue`, { sessionId, message }); },
export(id) { return API.request('GET', `/agents/${id}/export`); },
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: {
@@ -66,9 +82,10 @@ const API = {
create(data) { return API.request('POST', '/pipelines', data); },
update(id, data) { return API.request('PUT', `/pipelines/${id}`, data); },
delete(id) { return API.request('DELETE', `/pipelines/${id}`); },
execute(id, input, workingDirectory) {
execute(id, input, workingDirectory, contextFiles) {
const body = { input };
if (workingDirectory) body.workingDirectory = workingDirectory;
if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
return API.request('POST', `/pipelines/${id}/execute`, body);
},
cancel(id) { return API.request('POST', `/pipelines/${id}/cancel`); },
@@ -81,10 +98,19 @@ const API = {
create(data) { return API.request('POST', '/webhooks', data); },
update(id, data) { return API.request('PUT', `/webhooks/${id}`, data); },
delete(id) { return API.request('DELETE', `/webhooks/${id}`); },
test(id) { return API.request('POST', `/webhooks/${id}/test`); },
},
stats: {
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: {
@@ -98,6 +124,27 @@ const API = {
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: {
recent(limit = 20) { return API.request('GET', `/executions/recent?limit=${limit}`); },
history(params = {}) {
@@ -107,6 +154,19 @@ const API = {
get(id) { return API.request('GET', `/executions/history/${id}`); },
delete(id) { return API.request('DELETE', `/executions/history/${id}`); },
clearAll() { return API.request('DELETE', '/executions/history'); },
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,
_initialized: false,
_lastAgentName: '',
_executeDropzone: null,
_pipelineDropzone: null,
sectionTitles: {
dashboard: 'Dashboard',
@@ -18,18 +20,50 @@ const App = {
settings: 'Configurações',
},
sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'settings'],
init() {
if (App._initialized) return;
App._initialized = true;
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
App.setupNavigation();
App.setupWebSocket();
App.setupEventListeners();
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();
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() {
@@ -47,6 +81,10 @@ const App = {
},
navigateTo(section) {
if (location.hash !== `#${section}`) {
history.pushState(null, '', `#${section}`);
}
document.querySelectorAll('.section').forEach((el) => {
const isActive = el.id === section;
el.classList.toggle('active', isActive);
@@ -138,8 +176,18 @@ const App = {
case 'execution_output': {
Terminal.stopProcessing();
const evtType = data.data?.type || 'chunk';
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);
}
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');
App.refreshCurrentSection();
App._updateActiveBadge();
@@ -183,14 +236,39 @@ const App = {
case 'execution_error':
Terminal.stopProcessing();
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'}`);
App._updateActiveBadge();
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': {
Terminal.stopProcessing();
const stepEvtType = data.data?.type || 'chunk';
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);
}
break;
@@ -241,7 +319,56 @@ const App = {
case 'pipeline_status':
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) {
@@ -253,7 +380,7 @@ const App = {
<div class="approval-icon"><i data-lucide="shield-alert"></i></div>
<div class="approval-text">
<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 class="approval-actions">
<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.dataset.pipelineId = pipelineId;
if (window.lucide) lucide.createIcons({ nodes: [container] });
Utils.refreshIcons(container);
document.getElementById('approval-approve-btn')?.addEventListener('click', () => {
App._handleApproval(pipelineId, true);
@@ -538,6 +665,8 @@ const App = {
case 'edit': AgentsUI.openEditModal(id); break;
case 'export': AgentsUI.export(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) {
case 'view-execution': HistoryUI.viewDetail(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 'copy-webhook-url': WebhooksUI.copyUrl(url); 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);
chips.innerHTML = tags.map((t) => `
<span class="tag-chip">
${t}
<button type="button" class="tag-remove" data-tag="${t}" aria-label="Remover tag ${t}">×</button>
${Utils.escapeHtml(t)}
<button type="button" class="tag-remove" data-tag="${Utils.escapeHtml(t)}" aria-label="Remover tag ${Utils.escapeHtml(t)}">×</button>
</span>
`).join('');
};
@@ -714,11 +846,20 @@ const App = {
const selectEl = document.getElementById('execute-agent-select');
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();
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');
App.navigateTo('terminal');
Toast.info('Execução iniciada');
@@ -768,14 +909,32 @@ const App = {
return;
}
const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName);
if (isTyping) return;
const isInInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName);
if (isInInput) return;
if (e.key === 'n' || e.key === 'N') {
if (App.currentSection === 'agents') {
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);
if (window.lucide) lucide.createIcons({ nodes: [grid] });
Utils.refreshIcons(grid);
},
filter(searchText, statusFilter) {
@@ -76,7 +76,7 @@ const AgentsUI = {
const model = (agent.config && agent.config.model) || agent.model || 'claude-sonnet-4-6';
const updatedAt = AgentsUI.formatDate(agent.updated_at || agent.updatedAt || agent.created_at || agent.createdAt);
const tags = Array.isArray(agent.tags) && agent.tags.length > 0
? `<div class="agent-tags">${agent.tags.map((t) => `<span class="tag-chip tag-chip--sm">${t}</span>`).join('')}</div>`
? `<div class="agent-tags">${agent.tags.map((t) => `<span class="tag-chip tag-chip--sm">${Utils.escapeHtml(t)}</span>`).join('')}</div>`
: '';
return `
@@ -87,12 +87,12 @@ const AgentsUI = {
<span>${initials}</span>
</div>
<div class="agent-info">
<h3 class="agent-name">${name}</h3>
<h3 class="agent-name">${Utils.escapeHtml(name)}</h3>
<span class="badge ${statusClass}">${statusLabel}</span>
</div>
</div>
${agent.description ? `<p class="agent-description">${agent.description}</p>` : ''}
${agent.description ? `<p class="agent-description">${Utils.escapeHtml(agent.description)}</p>` : ''}
${tags}
<div class="agent-meta">
@@ -112,18 +112,25 @@ const AgentsUI = {
<i data-lucide="play"></i>
Executar
</button>
<button class="btn btn-ghost btn-sm" data-action="edit" data-id="${agent.id}">
<div class="agent-actions-icons">
<button class="btn btn-ghost btn-icon btn-sm" data-action="edit" data-id="${agent.id}" title="Editar agente">
<i data-lucide="pencil"></i>
Editar
</button>
<button class="btn btn-ghost btn-icon btn-sm" data-action="duplicate" data-id="${agent.id}" title="Duplicar agente">
<i data-lucide="copy"></i>
</button>
<button class="btn btn-ghost btn-icon btn-sm" data-action="export" data-id="${agent.id}" title="Exportar agente">
<i data-lucide="download"></i>
</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>
`;
},
@@ -155,7 +162,23 @@ const AgentsUI = {
const permissionMode = document.getElementById('agent-permission-mode');
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');
AgentsUI._setupModalListeners();
},
async openEditModal(agentId) {
@@ -196,7 +219,23 @@ const AgentsUI = {
).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');
AgentsUI._setupModalListeners();
} catch (err) {
Toast.error(`Erro ao carregar agente: ${err.message}`);
}
@@ -234,6 +273,8 @@ const AgentsUI = {
allowedTools: document.getElementById('agent-allowed-tools')?.value.trim() || '',
maxTurns: parseInt(document.getElementById('agent-max-turns')?.value) || 0,
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>' +
allAgents
.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('');
selectEl.value = agentId;
@@ -294,6 +335,8 @@ const AgentsUI = {
const instructionsEl = document.getElementById('execute-instructions');
if (instructionsEl) instructionsEl.value = '';
if (App._executeDropzone) App._executeDropzone.reset();
AgentsUI._loadSavedTasks();
Modal.open('execute-modal-overlay');
@@ -311,7 +354,7 @@ const AgentsUI = {
savedTaskSelect.innerHTML = '<option value="">Digitar manualmente...</option>' +
tasks.map((t) => {
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('');
AgentsUI._savedTasksCache = tasks;
} catch {
@@ -322,6 +365,16 @@ const AgentsUI = {
_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) {
try {
const data = await API.agents.export(agentId);
@@ -393,6 +446,223 @@ const AgentsUI = {
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;

View File

@@ -1,4 +1,6 @@
const DashboardUI = {
charts: {},
async load() {
try {
const [status, recentExecs] = await Promise.all([
@@ -9,11 +11,253 @@ const DashboardUI = {
DashboardUI.updateMetrics(status);
DashboardUI.updateRecentActivity(recentExecs || []);
DashboardUI.updateSystemStatus(status);
DashboardUI.setupChartPeriod();
DashboardUI.loadCharts();
} catch (err) {
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) {
const metrics = {
'metric-total-agents': status.agents?.total ?? 0,
@@ -71,15 +315,15 @@ const DashboardUI = {
<span>Nenhuma execução recente</span>
</li>
`;
if (window.lucide) lucide.createIcons({ nodes: [list] });
Utils.refreshIcons(list);
return;
}
list.innerHTML = executions.map((exec) => {
const statusClass = DashboardUI._statusBadgeClass(exec.status);
const statusLabel = DashboardUI._statusLabel(exec.status);
const name = exec.agentName || exec.pipelineName || exec.agentId || 'Execução';
const taskText = exec.task || exec.input || '';
const name = Utils.escapeHtml(exec.agentName || exec.pipelineName || exec.agentId || 'Execução');
const taskText = Utils.escapeHtml(exec.task || exec.input || '');
const typeBadge = exec.type === 'pipeline'
? '<span class="badge badge--purple" style="font-size:0.6rem;padding:1px 5px;">Pipeline</span> '
: '';
@@ -110,6 +354,14 @@ const DashboardUI = {
}).join('');
},
setupChartPeriod() {
const chartPeriod = document.getElementById('chart-period');
if (chartPeriod && !chartPeriod._listenerAdded) {
chartPeriod._listenerAdded = true;
chartPeriod.addEventListener('change', () => DashboardUI.loadCharts());
}
},
updateSystemStatus(status) {
const wsBadge = document.getElementById('system-ws-status-badge');
if (wsBadge) {
@@ -117,6 +369,18 @@ const DashboardUI = {
wsBadge.textContent = wsConnected ? 'Conectado' : 'Desconectado';
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) {

View File

@@ -7,7 +7,17 @@ const HistoryUI = {
_currentType: '',
_currentStatus: '',
_exportListenerAdded: false,
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 };
if (HistoryUI._currentType) params.type = HistoryUI._currentType;
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>
</div>
`;
if (window.lucide) lucide.createIcons({ nodes: [container] });
Utils.refreshIcons(container);
return;
}
container.innerHTML = HistoryUI.executions.map((exec) => HistoryUI._renderCard(exec)).join('');
if (window.lucide) lucide.createIcons({ nodes: [container] });
Utils.refreshIcons(container);
},
_renderCard(exec) {
@@ -55,9 +65,10 @@ const HistoryUI = {
const name = exec.type === 'pipeline'
? (exec.pipelineName || 'Pipeline')
: (exec.agentName || 'Agente');
const task = exec.type === 'pipeline'
const taskRaw = exec.type === 'pipeline'
? (exec.input || '')
: (exec.task || '');
const task = taskRaw.length > 150 ? taskRaw.slice(0, 150) + '…' : taskRaw;
const date = HistoryUI._formatDate(exec.startedAt);
const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt);
const cost = exec.costUsd || exec.totalCostUsd || 0;
@@ -70,28 +81,31 @@ const HistoryUI = {
<div class="history-card-header">
<div class="history-card-identity">
${typeBadge}
<span class="history-card-name">${HistoryUI._escapeHtml(name)}</span>
</div>
<div class="history-card-status">
<span class="history-card-name">${Utils.escapeHtml(name)}</span>
${statusBadge}
<span class="history-card-date">${date}</span>
</div>
</div>
<div class="history-card-meta">
<span class="history-card-task">${HistoryUI._escapeHtml(task)}</span>
<span class="history-card-duration-group">
<div class="history-card-task" title="${Utils.escapeHtml(taskRaw)}">${Utils.escapeHtml(task)}</div>
<div class="history-card-info">
<span class="history-card-date">
<i data-lucide="calendar" aria-hidden="true"></i>
${date}
</span>
<span class="history-card-duration">
<i data-lucide="clock" aria-hidden="true"></i>
${duration}
</span>
${costHtml}
</span>
</div>
<div class="history-card-actions">
<button class="btn btn-ghost btn-sm" data-action="view-execution" data-id="${exec.id}" type="button">
<i data-lucide="eye"></i>
Ver detalhes
</button>
${(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">
<i data-lucide="trash-2"></i>
</button>
@@ -132,7 +146,7 @@ const HistoryUI = {
</div>
`;
if (window.lucide) lucide.createIcons({ nodes: [container] });
Utils.refreshIcons(container);
document.getElementById('history-prev-btn')?.addEventListener('click', () => {
HistoryUI.page--;
@@ -172,7 +186,11 @@ const HistoryUI = {
: HistoryUI._renderAgentDetail(exec);
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) => {
btn.addEventListener('click', () => {
@@ -195,18 +213,24 @@ const HistoryUI = {
const endDate = exec.endedAt ? HistoryUI._formatDate(exec.endedAt) : '—';
const resultBlock = exec.result
? `<div class="execution-result" role="region" aria-label="Resultado da execução">${HistoryUI._escapeHtml(exec.result)}</div>`
? `<div class="execution-result" role="region" aria-label="Resultado da execução">${Utils.escapeHtml(exec.result)}</div>`
: '';
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 `
${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-row">
<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 class="execution-detail-row">
<span class="execution-detail-label">Status</span>
@@ -243,7 +267,7 @@ const HistoryUI = {
${exec.task ? `
<div class="execution-detail-section">
<h3 class="execution-detail-section-title">Tarefa</h3>
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.task)}</p>
<p class="execution-detail-task">${Utils.escapeHtml(exec.task)}</p>
</div>` : ''}
${resultBlock ? `
<div class="execution-detail-section">
@@ -279,7 +303,7 @@ const HistoryUI = {
<div class="pipeline-step-detail">
<div class="pipeline-step-header">
<div class="pipeline-step-identity">
<span class="pipeline-step-agent">${HistoryUI._escapeHtml(step.agentName || step.agentId || 'Agente')}</span>
<span class="pipeline-step-agent">${Utils.escapeHtml(step.agentName || step.agentId || 'Agente')}</span>
${HistoryUI._statusBadge(step.status)}
</div>
<span class="pipeline-step-meta-group">
@@ -297,13 +321,13 @@ const HistoryUI = {
Prompt utilizado
</button>
<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>` : ''}
${step.result ? `
<div class="pipeline-step-result">
<span class="pipeline-step-result-label">Resultado</span>
<div class="execution-result">${HistoryUI._escapeHtml(step.result)}</div>
<div class="execution-result">${Utils.escapeHtml(step.result)}</div>
</div>` : ''}
${step.status === 'error' ? `
<div class="execution-result execution-result--error">Passo falhou.</div>` : ''}
@@ -312,11 +336,18 @@ const HistoryUI = {
`;
}).join('');
const hasResults = steps.some(s => s.result);
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-row">
<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 class="execution-detail-row">
<span class="execution-detail-label">Status</span>
@@ -343,7 +374,7 @@ const HistoryUI = {
${exec.input ? `
<div class="execution-detail-section">
<h3 class="execution-detail-section-title">Input Inicial</h3>
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.input)}</p>
<p class="execution-detail-task">${Utils.escapeHtml(exec.input)}</p>
</div>` : ''}
${steps.length > 0 ? `
<div class="execution-detail-section">
@@ -355,11 +386,51 @@ const HistoryUI = {
${exec.error ? `
<div class="execution-detail-section">
<h3 class="execution-detail-section-title">Erro</h3>
<div class="execution-result execution-result--error">${HistoryUI._escapeHtml(exec.error)}</div>
<div class="execution-result execution-result--error">${Utils.escapeHtml(exec.error)}</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) {
const confirmed = await Modal.confirm(
'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;

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) {
grid.insertAdjacentHTML('beforeend', PipelinesUI.renderEmpty());
}
if (window.lucide) lucide.createIcons({ nodes: [grid] });
Utils.refreshIcons(grid);
return;
}
@@ -59,7 +59,7 @@ const PipelinesUI = {
grid.appendChild(fragment);
if (window.lucide) lucide.createIcons({ nodes: [grid] });
Utils.refreshIcons(grid);
},
renderEmpty() {
@@ -83,7 +83,7 @@ const PipelinesUI = {
const stepCount = steps.length;
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 approvalIcon = step.requiresApproval && index > 0
? '<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-top">
<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>
</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">
${flowHtml || '<span class="agent-description">Nenhum passo configurado</span>'}
@@ -119,15 +119,16 @@ const PipelinesUI = {
<i data-lucide="play"></i>
Executar
</button>
<button class="btn btn-ghost btn-sm" data-action="edit-pipeline" data-id="${pipeline.id}">
<div class="agent-actions-icons">
<button class="btn btn-ghost btn-icon btn-sm" data-action="edit-pipeline" data-id="${pipeline.id}" title="Editar pipeline">
<i data-lucide="pencil"></i>
Editar
</button>
<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>
</button>
</div>
</div>
</div>
`;
},
@@ -192,7 +193,7 @@ const PipelinesUI = {
}
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('');
container.innerHTML = PipelinesUI._steps.map((step, index) => {
@@ -225,7 +226,7 @@ const PipelinesUI = {
placeholder="{{input}} será substituído pelo output anterior"
data-step-field="inputTemplate"
data-step-index="${index}"
>${step.inputTemplate || ''}</textarea>
>${Utils.escapeHtml(step.inputTemplate || '')}</textarea>
${approvalHtml}
</div>
<div class="pipeline-step-actions">
@@ -249,7 +250,7 @@ const PipelinesUI = {
select.value = PipelinesUI._steps[index].agentId || '';
});
if (window.lucide) lucide.createIcons({ nodes: [container] });
Utils.refreshIcons(container);
},
_syncStepsFromDOM() {
@@ -369,6 +370,8 @@ const PipelinesUI = {
const workdirEl = document.getElementById('pipeline-execute-workdir');
if (workdirEl) workdirEl.value = '';
if (App._pipelineDropzone) App._pipelineDropzone.reset();
Modal.open('pipeline-execute-modal-overlay');
},
@@ -383,7 +386,16 @@ const PipelinesUI = {
}
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');
App.navigateTo('terminal');
Toast.info('Pipeline iniciado');

View File

@@ -28,7 +28,7 @@ const SchedulesUI = {
</td>
</tr>
`;
if (window.lucide) lucide.createIcons({ nodes: [tbody] });
Utils.refreshIcons(tbody);
return;
}
@@ -44,8 +44,8 @@ const SchedulesUI = {
return `
<tr>
<td>${schedule.agentName || '—'}</td>
<td class="schedule-task-cell" title="${schedule.taskDescription || ''}">${schedule.taskDescription || '—'}</td>
<td>${Utils.escapeHtml(schedule.agentName || '—')}</td>
<td class="schedule-task-cell" title="${Utils.escapeHtml(schedule.taskDescription || '')}">${Utils.escapeHtml(schedule.taskDescription || '—')}</td>
<td>
<code class="font-mono">${cronExpr}</code>
</td>
@@ -77,7 +77,7 @@ const SchedulesUI = {
`;
}).join('');
if (window.lucide) lucide.createIcons({ nodes: [tbody] });
Utils.refreshIcons(tbody);
},
filter(searchText, statusFilter) {
@@ -106,7 +106,7 @@ const SchedulesUI = {
select.innerHTML = '<option value="">Selecionar agente...</option>' +
agents
.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('');
}
@@ -208,7 +208,11 @@ const SchedulesUI = {
if (!container) return;
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;
}
@@ -233,12 +237,12 @@ const SchedulesUI = {
const duration = SchedulesUI._formatDuration(exec.startedAt, exec.endedAt);
const cost = exec.costUsd || exec.totalCostUsd || 0;
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 `
<tr>
<td>${SchedulesUI._escapeHtml(exec.agentName || '—')}</td>
<td title="${SchedulesUI._escapeHtml(exec.task || '')}">${taskStr}</td>
<td>${Utils.escapeHtml(exec.agentName || '—')}</td>
<td title="${Utils.escapeHtml(exec.task || '')}">${taskStr}</td>
<td>${status}</td>
<td>${date}</td>
<td>${duration}</td>
@@ -256,7 +260,7 @@ const SchedulesUI = {
</div>
`;
if (window.lucide) lucide.createIcons({ nodes: [container] });
Utils.refreshIcons(container);
},
_statusBadge(status) {
@@ -283,16 +287,6 @@ const SchedulesUI = {
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) {
if (!expression) return '—';

View File

@@ -8,11 +8,20 @@ const SettingsUI = {
SettingsUI.populateForm(settings);
SettingsUI.populateSystemInfo(info);
SettingsUI.updateThemeInfo();
} catch (err) {
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) {
const fields = {
'settings-default-model': settings.defaultModel || 'claude-sonnet-4-6',

View File

@@ -39,7 +39,7 @@ const TasksUI = {
container.appendChild(fragment);
if (window.lucide) lucide.createIcons({ nodes: [container] });
Utils.refreshIcons(container);
},
filter(searchText, categoryFilter) {
@@ -65,10 +65,10 @@ const TasksUI = {
return `
<div class="task-card" data-task-id="${task.id}">
<div class="task-card-header">
<h4 class="task-card-name">${task.name}</h4>
<span class="badge ${categoryClass}">${categoryLabel}</span>
<h4 class="task-card-name">${Utils.escapeHtml(task.name)}</h4>
<span class="badge ${categoryClass}">${Utils.escapeHtml(categoryLabel)}</span>
</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">
<span class="task-card-date">
<i data-lucide="calendar"></i>
@@ -117,7 +117,7 @@ const TasksUI = {
<div class="task-card task-card--form" id="task-inline-form">
<div class="form-group">
<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 class="form-group">
<label class="form-label" for="task-inline-category">Categoria</label>
@@ -133,7 +133,7 @@ const TasksUI = {
</div>
<div class="form-group">
<label class="form-label" for="task-inline-description">Descrição</label>
<textarea id="task-inline-description" class="textarea" rows="2" placeholder="Descreva o objetivo desta tarefa...">${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 class="form-actions">
<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');
if (selectEl) {
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 = '';
}

View File

@@ -5,6 +5,9 @@ const Terminal = {
executionFilter: null,
_processingInterval: null,
_chatSession: null,
searchMatches: [],
searchIndex: -1,
_toolbarInitialized: false,
enableChat(agentId, agentName, sessionId) {
Terminal._chatSession = { agentId, agentName, sessionId };
@@ -83,7 +86,121 @@ const Terminal = {
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() {
Terminal.initToolbar();
const output = document.getElementById('terminal-output');
if (!output) return;
@@ -102,7 +219,7 @@ const Terminal = {
const html = lines.map((line) => {
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>');
return `<div class="terminal-line${typeClass}">
@@ -120,14 +237,6 @@ const Terminal = {
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;

View File

@@ -1,9 +1,9 @@
const Toast = {
iconMap: {
success: 'check-circle',
error: 'x-circle',
success: 'circle-check',
error: 'circle-x',
info: 'info',
warning: 'alert-triangle',
warning: 'triangle-alert',
},
colorMap: {
@@ -35,9 +35,7 @@ const Toast = {
container.appendChild(toast);
if (window.lucide) {
lucide.createIcons({ nodes: [toast] });
}
Utils.refreshIcons(toast);
requestAnimationFrame(() => {
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>
</div>
`;
if (window.lucide) lucide.createIcons({ nodes: [container] });
Utils.refreshIcons(container);
return;
}
container.innerHTML = webhooks.map((w) => WebhooksUI._renderCard(w)).join('');
if (window.lucide) lucide.createIcons({ nodes: [container] });
Utils.refreshIcons(container);
},
_renderCard(webhook) {
@@ -71,7 +71,7 @@ const WebhooksUI = {
<article class="webhook-card">
<div class="webhook-card-header">
<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}
${statusBadge}
</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'}">
<i data-lucide="${webhook.active ? 'pause' : 'play'}"></i>
</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">
<i data-lucide="trash-2"></i>
</button>
@@ -87,7 +93,7 @@ const WebhooksUI = {
<div class="webhook-card-body">
<div class="webhook-card-target">
<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 class="webhook-card-url">
<span class="webhook-card-label">URL</span>
@@ -141,19 +147,59 @@ const WebhooksUI = {
WebhooksUI._updateTargetSelect('agent');
}
const submitBtn = document.getElementById('webhook-form-submit');
if (submitBtn) submitBtn.dataset.editId = '';
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) {
const selectEl = document.getElementById('webhook-target-id');
if (!selectEl) return;
if (targetType === 'agent') {
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 {
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 targetType = document.getElementById('webhook-target-type')?.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 (!targetId) { Toast.warning('Selecione um destino'); return; }
try {
if (editId) {
await API.webhooks.update(editId, { name, targetType, targetId });
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();
} 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;

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;

117
server.js
View File

@@ -4,6 +4,10 @@ import { WebSocketServer } from 'ws';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
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 * as manager 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 PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '127.0.0.1';
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 httpServer = createServer(app);
const wss = new WebSocketServer({ server: httpServer });
app.use((req, res, next) => {
const origin = ALLOWED_ORIGIN || req.headers.origin || '*';
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGIN);
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);
next();
});
if (AUTH_TOKEN) {
app.use((req, res, next) => {
req.correlationId = req.headers['x-correlation-id'] || crypto.randomUUID();
res.setHeader('X-Correlation-ID', req.correlationId);
next();
});
app.get('/api/health', (req, res) => {
res.json({
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 (token !== AUTH_TOKEN) {
if (!timingSafeCompare(token, AUTH_TOKEN)) {
return res.status(401).json({ error: 'Token de autenticação inválido' });
}
next();
});
}
app.use(express.json());
app.use('/hook', hookRouter);
app.use(express.static(join(__dirname, 'public')));
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);
const connectedClients = new Map();
@@ -51,20 +120,30 @@ wss.on('connection', (ws, req) => {
if (AUTH_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');
return;
}
}
ws.clientId = clientId;
ws.isAlive = true;
connectedClients.set(clientId, ws);
ws.on('pong', () => { ws.isAlive = true; });
ws.on('close', () => connectedClients.delete(clientId));
ws.on('error', () => connectedClients.delete(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) {
const payload = JSON.stringify(message);
for (const [, client] of connectedClients) {
@@ -92,6 +171,8 @@ function gracefulShutdown(signal) {
flushAllStores();
console.log('Dados persistidos.');
clearInterval(wsHeartbeat);
httpServer.close(() => {
console.log('Servidor HTTP encerrado.');
process.exit(0);
@@ -106,10 +187,18 @@ function gracefulShutdown(signal) {
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
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();
httpServer.listen(PORT, () => {
console.log(`Painel administrativo disponível em http://localhost:${PORT}`);
httpServer.listen(PORT, HOST, () => {
console.log(`Painel administrativo disponível em http://${HOST}:${PORT}`);
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 { existsSync } from 'fs';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { settingsStore } from '../store/db.js';
const CLAUDE_BIN = resolveBin();
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;
@@ -12,6 +16,12 @@ export function updateMaxConcurrent(value) {
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() {
if (process.env.CLAUDE_BIN) return process.env.CLAUDE_BIN;
const home = process.env.HOME || '';
@@ -34,13 +44,16 @@ function sanitizeText(str) {
.slice(0, 50000);
}
function cleanEnv() {
function cleanEnv(agentSecrets) {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
if (!env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '128000';
}
if (agentSecrets && typeof agentSecrets === 'object') {
Object.assign(env, agentSecrets);
}
return env;
}
@@ -111,58 +124,101 @@ function extractText(event) {
return null;
}
export function execute(agentConfig, task, callbacks = {}) {
if (activeExecutions.size >= maxConcurrent) {
const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`);
if (callbacks.onError) callbacks.onError(err, uuidv4());
function extractToolInfo(event) {
if (!event) return null;
if (event.type === 'assistant' && event.message?.content) {
const toolBlocks = event.message.content.filter((b) => b.type === 'tool_use');
if (toolBlocks.length > 0) {
return toolBlocks.map((b) => {
const name = b.name || 'unknown';
const input = b.input || {};
let detail = '';
if (input.command) detail = input.command.slice(0, 120);
else if (input.file_path) detail = input.file_path;
else if (input.pattern) detail = input.pattern;
else if (input.query) detail = input.query;
else if (input.path) detail = input.path;
else if (input.prompt) detail = input.prompt.slice(0, 80);
else if (input.description) detail = input.description.slice(0, 80);
return { name, detail };
});
}
}
if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
return [{ name: event.content_block.name || 'tool', detail: '' }];
}
return null;
}
const executionId = uuidv4();
function extractSystemInfo(event) {
if (!event) return null;
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 prompt = buildPrompt(task.description || task, task.instructions);
const args = buildArgs(agentConfig, prompt);
const spawnOptions = {
env: cleanEnv(),
stdio: ['ignore', 'pipe', 'pipe'],
};
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;
}
console.log(`[executor] Iniciando: ${executionId} | Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`);
const child = spawn(CLAUDE_BIN, args, spawnOptions);
let hadError = false;
activeExecutions.set(executionId, {
process: child,
agentConfig,
task,
startedAt: new Date().toISOString(),
executionId,
});
const timeoutMs = options.timeout || 1800000;
const sessionIdOverride = options.sessionIdOverride || null;
let outputBuffer = '';
let errorBuffer = '';
let fullText = '';
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) {
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);
if (text) {
if (fullText.length < MAX_OUTPUT_SIZE) {
fullText += text;
}
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') {
resultMeta = {
costUsd: parsed.cost_usd || 0,
@@ -170,7 +226,7 @@ export function execute(agentConfig, task, callbacks = {}) {
durationMs: parsed.duration_ms || 0,
durationApiMs: parsed.duration_api_ms || 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) => {
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) => {
clearTimeout(timeout);
console.log(`[executor][error] ${err.message}`);
hadError = true;
activeExecutions.delete(executionId);
@@ -193,21 +257,81 @@ export function execute(agentConfig, task, callbacks = {}) {
});
child.on('close', (code) => {
clearTimeout(timeout);
const wasCanceled = activeExecutions.get(executionId)?.canceled || false;
activeExecutions.delete(executionId);
if (hadError) return;
if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer));
if (onComplete) {
onComplete({
executionId,
exitCode: code,
result: fullText,
stderr: errorBuffer,
canceled: wasCanceled,
...(resultMeta || {}),
}, 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;
}
@@ -222,6 +346,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
const executionId = uuidv4();
const { onData, onError, onComplete } = callbacks;
if (!validateWorkingDirectory(agentConfig, executionId, onError)) return null;
const model = agentConfig.model || 'claude-sonnet-4-6';
const args = [
'--resume', sessionId,
@@ -242,18 +368,12 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
};
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;
}
console.log(`[executor] Resumindo sessão: ${sessionId} | Execução: ${executionId}`);
const child = spawn(CLAUDE_BIN, args, spawnOptions);
let hadError = false;
activeExecutions.set(executionId, {
process: child,
@@ -263,60 +383,9 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
executionId,
});
let outputBuffer = '';
let errorBuffer = '';
let fullText = '';
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);
}
processChildOutput(child, executionId, { onData, onError, onComplete }, {
timeout: agentConfig.timeout || 1800000,
sessionIdOverride: sessionId,
});
return executionId;
@@ -325,8 +394,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
export function cancel(executionId) {
const execution = activeExecutions.get(executionId);
if (!execution) return false;
execution.canceled = true;
execution.process.kill('SIGTERM');
activeExecutions.delete(executionId);
return true;
}

View File

@@ -1,7 +1,9 @@
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 scheduler from './scheduler.js';
import { generateAgentReport } from '../reports/generator.js';
const DEFAULT_CONFIG = {
model: 'claude-sonnet-4-6',
@@ -25,6 +27,14 @@ function getWsCallback(wsCallback) {
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 dailyCountDate = new Date().toDateString();
@@ -90,6 +100,13 @@ export function createAgent(data) {
export function updateAgent(id, data) {
const existing = agentsStore.getById(id);
if (!existing) return null;
agentVersionsStore.create({
agentId: id,
version: existing,
changedFields: Object.keys(data).filter(k => k !== 'id'),
});
const updateData = {};
if (data.agent_name !== undefined) updateData.agent_name = data.agent_name;
if (data.description !== undefined) updateData.description = data.description;
@@ -105,16 +122,31 @@ export function deleteAgent(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 = {}) {
const agent = agentsStore.getById(agentId);
if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
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 taskText = typeof task === 'string' ? task : task.description;
const startedAt = new Date().toISOString();
const historyRecord = executionsStore.create({
const historyRecord = metadata._historyRecordId
? { id: metadata._historyRecordId }
: executionsStore.create({
type: 'agent',
...metadata,
agentId,
@@ -125,6 +157,10 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
startedAt,
});
if (metadata._retryAttempt) {
executionsStore.update(historyRecord.id, { status: 'running', error: null });
}
const execRecord = {
executionId: null,
agentId,
@@ -134,6 +170,8 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
status: 'running',
};
const agentSecrets = loadAgentSecrets(agentId);
const executionId = executor.execute(
agent.config,
{ description: task, instructions },
@@ -144,7 +182,33 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
onError: (err, execId) => {
const endedAt = new Date().toISOString();
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 });
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 } });
},
onComplete: (result, execId) => {
@@ -161,9 +225,18 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
numTurns: result.numTurns || 0,
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 });
},
}
},
agentSecrets
);
if (!executionId) {
@@ -185,18 +258,15 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
return executionId;
}
function updateRecentBuffer(executionId, updates) {
const entry = recentExecBuffer.find((e) => e.executionId === executionId);
if (entry) Object.assign(entry, updates);
}
function updateExecutionRecord(agentId, executionId, updates) {
async function updateExecutionRecord(agentId, executionId, updates) {
await withLock(`agent:${agentId}`, () => {
const agent = agentsStore.getById(agentId);
if (!agent) return;
const executions = (agent.executions || []).map((exec) =>
exec.executionId === executionId ? { ...exec, ...updates } : exec
);
agentsStore.update(agentId, { executions });
});
}
export function getRecentExecutions(limit = 20) {
@@ -207,6 +277,10 @@ export function scheduleTask(agentId, taskDescription, cronExpression, wsCallbac
const agent = agentsStore.getById(agentId);
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 items = schedulesStore.getAll();
items.push({
@@ -290,6 +364,13 @@ export function continueConversation(agentId, sessionId, message, wsCallback) {
numTurns: result.numTurns || 0,
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 });
},
}

View File

@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
import { pipelinesStore, agentsStore, executionsStore } from '../store/db.js';
import * as executor from './executor.js';
import { mem } from '../cache/index.js';
import { generatePipelineReport } from '../reports/generator.js';
const activePipelines = new Map();
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) => {
const state = activePipelines.get(pipelineId);
const state = activePipelines.get(executionId);
if (!state) { resolve(false); return; }
state.pendingApproval = {
@@ -115,6 +116,7 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal
wsCallback({
type: 'pipeline_approval_required',
pipelineId,
executionId,
stepIndex,
agentName,
previousOutput: previousOutput.slice(0, 3000),
@@ -123,8 +125,16 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal
});
}
export function approvePipelineStep(pipelineId) {
const state = activePipelines.get(pipelineId);
function findPipelineState(idOrExecId) {
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;
const { resolve } = state.pendingApproval;
state.pendingApproval = null;
@@ -132,8 +142,8 @@ export function approvePipelineStep(pipelineId) {
return true;
}
export function rejectPipelineStep(pipelineId) {
const state = activePipelines.get(pipelineId);
export function rejectPipelineStep(id) {
const state = findPipelineState(id);
if (!state?.pendingApproval) return false;
const { resolve } = state.pendingApproval;
state.pendingApproval = null;
@@ -144,9 +154,11 @@ export function rejectPipelineStep(pipelineId) {
export async function executePipeline(pipelineId, initialInput, wsCallback, options = {}) {
const pl = pipelinesStore.getById(pipelineId);
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 };
activePipelines.set(pipelineId, pipelineState);
const executionId = uuidv4();
const pipelineState = { pipelineId, currentExecutionId: null, currentStep: 0, canceled: false, pendingApproval: null };
activePipelines.set(executionId, pipelineState);
const historyRecord = executionsStore.create({
type: 'pipeline',
@@ -180,7 +192,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
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) {
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';
executionsStore.update(historyRecord.id, {
@@ -265,13 +277,20 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
totalCostUsd: totalCost,
});
if (!pipelineState.canceled && wsCallback) {
wsCallback({ type: 'pipeline_complete', pipelineId, results, totalCostUsd: totalCost });
if (!pipelineState.canceled) {
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) {
activePipelines.delete(pipelineId);
activePipelines.delete(executionId);
executionsStore.update(historyRecord.id, {
status: 'error',
error: err.message,
@@ -290,8 +309,14 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
}
}
export function cancelPipeline(pipelineId) {
const state = activePipelines.get(pipelineId);
export function cancelPipeline(id) {
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;
state.canceled = true;
if (state.pendingApproval) {
@@ -299,13 +324,21 @@ export function cancelPipeline(pipelineId) {
state.pendingApproval = null;
}
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;
}
export function getActivePipelines() {
return Array.from(activePipelines.entries()).map(([id, state]) => ({
pipelineId: id,
executionId: id,
pipelineId: state.pipelineId,
currentStep: state.currentStep,
currentExecutionId: state.currentExecutionId,
pendingApproval: !!state.pendingApproval,

View File

@@ -17,7 +17,11 @@ function addToHistory(entry) {
function matchesCronPart(part, value) {
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('-')) {
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 (!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(
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 { execSync } from 'child_process';
import { execFile } from 'child_process';
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
import os from 'os';
import multer from 'multer';
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 pipeline from '../agents/pipeline.js';
import { getBinPath, updateMaxConcurrent } from '../agents/executor.js';
import { invalidateAgentMapCache } from '../agents/pipeline.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();
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) => {
try {
const { task, instructions } = req.body;
const { task, instructions, contextFiles } = req.body;
if (!task) return res.status(400).json({ error: 'task é obrigatório' });
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' });
} catch (err) {
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) => {
try {
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 {
const { input, workingDirectory } = req.body;
const { input, workingDirectory, contextFiles } = req.body;
if (!input) return res.status(400).json({ error: 'input é obrigatório' });
const clientId = req.headers['x-client-id'] || null;
const options = {};
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' });
} 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 });
}
});
@@ -388,11 +545,11 @@ router.put('/webhooks/:id', (req, res) => {
try {
const existing = webhooksStore.getById(req.params.id);
if (!existing) return res.status(404).json({ error: 'Webhook não encontrado' });
const allowed = ['name', 'targetType', 'targetId', 'active'];
const updateData = {};
if (req.body.name !== undefined) updateData.name = req.body.name;
if (req.body.active !== undefined) updateData.active = !!req.body.active;
for (const key of allowed) {
if (req.body[key] !== undefined) updateData[key] = req.body[key];
}
const updated = webhooksStore.update(req.params.id, updateData);
res.json(updated);
} 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) => {
try {
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 });
} else if (webhook.targetType === 'pipeline') {
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) => {
if (wsbroadcast) wsbroadcast(msg);
}, options).catch(() => {});
}).catch(() => {});
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) {
const status = err.message.includes('não encontrado') ? 404 : 500;
@@ -553,11 +733,16 @@ router.get('/system/status', (req, res) => {
let claudeVersionCache = null;
router.get('/system/info', (req, res) => {
router.get('/system/info', async (req, res) => {
try {
if (claudeVersionCache === null) {
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 {
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;

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 { fileURLToPath } from 'url';
import { v4 as uuidv4 } from 'uuid';
@@ -30,7 +31,16 @@ function readJson(path, fallback) {
function writeJson(path, data) {
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) {
@@ -41,6 +51,7 @@ function createStore(filePath) {
let mem = null;
let dirty = false;
let timer = null;
let maxSize = Infinity;
function boot() {
if (mem !== null) return;
@@ -54,7 +65,7 @@ function createStore(filePath) {
timer = setTimeout(() => {
timer = null;
if (dirty) {
writeJson(filePath, mem);
writeJsonAsync(filePath, mem).catch((e) => console.error(`[db] Erro ao salvar ${filePath}:`, e.message));
dirty = false;
}
}, DEBOUNCE_MS);
@@ -72,6 +83,20 @@ function createStore(filePath) {
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) {
boot();
const item = {
@@ -81,6 +106,9 @@ function createStore(filePath) {
updated_at: new Date().toISOString(),
};
mem.push(item);
if (maxSize !== Infinity && mem.length > maxSize) {
mem.splice(0, mem.length - maxSize);
}
touch();
return clone(item);
},
@@ -104,7 +132,8 @@ function createStore(filePath) {
},
save(items) {
mem = Array.isArray(items) ? items : mem;
if (!Array.isArray(items)) return;
mem = items;
touch();
},
@@ -118,6 +147,10 @@ function createStore(filePath) {
dirty = false;
}
},
setMaxSize(n) {
maxSize = n;
},
};
allStores.push(store);
@@ -176,6 +209,21 @@ function createSettingsStore(filePath) {
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() {
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 schedulesStore = createStore(`${DATA_DIR}/schedules.json`);
export const executionsStore = createStore(`${DATA_DIR}/executions.json`);
executionsStore.setMaxSize(5000);
export const webhooksStore = createStore(`${DATA_DIR}/webhooks.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`);