Versão inicial do Agents Orchestrator

Painel administrativo web para orquestração de agentes Claude Code
com suporte a execução de tarefas, agendamento cron, pipelines
sequenciais e terminal com streaming em tempo real via WebSocket.
This commit is contained in:
Frederico Castro
2026-02-26 00:23:56 -03:00
commit 723a08d2e1
24 changed files with 8433 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
data/
.env
*.log
.DS_Store

70
CLAUDE.md Normal file
View File

@@ -0,0 +1,70 @@
# 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`

148
README.md Normal file
View File

@@ -0,0 +1,148 @@
# 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.
## 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.
## Pré-requisitos
- **Node.js** 18+
- **Claude Code CLI** instalado e autenticado (`claude` disponível no PATH)
## Instalação
```bash
git clone <repo-url>
cd agents-orchestrator
npm install
```
## Uso
```bash
# Produção
npm start
# Desenvolvimento (hot reload)
npm run dev
```
Acesse **http://localhost:3000** no navegador. A porta pode ser alterada via variável de ambiente `PORT`.
## Como funciona
### Criando um agente
1. Clique em **Novo Agente** no header ou na seção Agentes
2. Configure nome, system prompt, modelo e diretório de trabalho
3. Salve — o agente aparecerá como card na listagem
### Executando uma tarefa
1. No card do agente, clique em **Executar**
2. Descreva a tarefa ou use um template rápido
3. Opcionalmente adicione instruções extras
4. A execução inicia e o terminal abre automaticamente com streaming da saída
### Criando um pipeline
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
### Agendando uma tarefa
1. Vá em **Agendamentos****Novo Agendamento**
2. Selecione o agente, descreva a tarefa e defina a expressão cron
3. A tarefa será executada automaticamente nos horários configurados
## Arquitetura
```
server.js Express + WebSocket na mesma porta
src/
routes/api.js API REST (/api/*)
agents/
manager.js CRUD + orquestração de agentes
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)
public/
index.html SPA single-page
css/styles.css Estilos (Inter, JetBrains Mono, Lucide)
js/
app.js Controlador principal + WebSocket client
api.js Client HTTP para a API
components/ UI por seção (dashboard, agents, tasks, etc.)
data/
agents.json Agentes cadastrados
tasks.json Templates de tarefas
pipelines.json Pipelines configurados
```
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
| Método | Endpoint | Descrição |
|--------|----------|-----------|
| `GET` | `/api/agents` | Listar agentes |
| `POST` | `/api/agents` | Criar agente |
| `GET` | `/api/agents/:id` | Obter agente |
| `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 |
| `GET` | `/api/agents/:id/export` | Exportar agente (JSON) |
| `GET` | `/api/tasks` | Listar tarefas |
| `POST` | `/api/tasks` | Criar tarefa |
| `PUT` | `/api/tasks/:id` | Atualizar tarefa |
| `DELETE` | `/api/tasks/:id` | Excluir tarefa |
| `GET` | `/api/schedules` | Listar agendamentos |
| `POST` | `/api/schedules` | Criar agendamento |
| `DELETE` | `/api/schedules/:taskId` | Remover agendamento |
| `GET` | `/api/pipelines` | Listar pipelines |
| `POST` | `/api/pipelines` | Criar pipeline |
| `GET` | `/api/pipelines/:id` | Obter pipeline |
| `PUT` | `/api/pipelines/:id` | Atualizar pipeline |
| `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 |
| `GET` | `/api/executions/active` | Execuções em andamento |
## Eventos WebSocket
O servidor envia eventos tipados via WebSocket que o frontend renderiza no terminal:
| Evento | Descrição |
|--------|-----------|
| `execution_output` | Chunk de texto da saída do agente |
| `execution_complete` | Execução finalizada com resultado |
| `execution_error` | Erro durante execução |
| `pipeline_step_start` | Início de um passo do pipeline |
| `pipeline_step_complete` | Passo do pipeline concluído |
| `pipeline_complete` | Pipeline finalizado |
| `pipeline_error` | Erro em um passo do pipeline |
## Stack
- **Backend**: Node.js, Express, WebSocket (ws), node-cron, uuid
- **Frontend**: HTML, CSS, JavaScript vanilla (sem framework, sem bundler)
- **Ícones**: Lucide
- **Fontes**: Inter (UI), JetBrains Mono (código/terminal)
- **Persistência**: Arquivos JSON em disco
## Licença
MIT

885
package-lock.json generated Normal file
View File

@@ -0,0 +1,885 @@
{
"name": "agents-orchestrator",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agents-orchestrator",
"version": "1.0.0",
"dependencies": {
"express": "^4.21.0",
"node-cron": "^3.0.3",
"uuid": "^10.0.0",
"ws": "^8.18.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"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/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/node-cron": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
"license": "ISC",
"dependencies": {
"uuid": "8.3.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-cron/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "agents-orchestrator",
"version": "1.0.0",
"description": "Painel administrativo para orquestração de agentes Claude Code",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"express": "^4.21.0",
"ws": "^8.18.0",
"node-cron": "^3.0.3",
"uuid": "^10.0.0"
}
}

3279
public/css/styles.css Normal file

File diff suppressed because it is too large Load Diff

873
public/index.html Normal file
View File

@@ -0,0 +1,873 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agents Orchestrator</title>
<link rel="stylesheet" href="css/styles.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-logo">
<div class="sidebar-logo-icon">
<i data-lucide="bot"></i>
</div>
<span class="sidebar-logo-text">Agents Orchestrator</span>
</div>
<nav class="sidebar-nav">
<ul class="sidebar-nav-list">
<li class="sidebar-nav-item">
<a href="#" class="sidebar-nav-link" data-section="dashboard">
<i data-lucide="layout-dashboard"></i>
<span>Dashboard</span>
</a>
</li>
<li class="sidebar-nav-item">
<a href="#" class="sidebar-nav-link" data-section="agents">
<i data-lucide="cpu"></i>
<span>Agentes</span>
</a>
</li>
<li class="sidebar-nav-item">
<a href="#" class="sidebar-nav-link" data-section="tasks">
<i data-lucide="list-checks"></i>
<span>Tarefas</span>
</a>
</li>
<li class="sidebar-nav-item">
<a href="#" class="sidebar-nav-link" data-section="schedules">
<i data-lucide="clock"></i>
<span>Agendamentos</span>
</a>
</li>
<li class="sidebar-nav-item" data-section="pipelines">
<a class="sidebar-nav-link" href="#" data-section="pipelines">
<i data-lucide="git-merge" class="nav-icon"></i>
<span>Pipelines</span>
</a>
</li>
<li class="sidebar-nav-item">
<a href="#" class="sidebar-nav-link" data-section="terminal">
<i data-lucide="terminal"></i>
<span>Terminal</span>
</a>
</li>
<li class="sidebar-nav-item">
<a href="#" class="sidebar-nav-link" data-section="settings">
<i data-lucide="settings"></i>
<span>Configurações</span>
</a>
</li>
</ul>
</nav>
<div class="sidebar-footer">
<div class="ws-status" id="ws-status">
<span class="ws-indicator ws-indicator--disconnected" id="ws-indicator"></span>
<span class="ws-label" id="ws-label">Desconectado</span>
</div>
</div>
</aside>
<div class="main-wrapper">
<header class="main-header">
<div class="header-breadcrumb">
<h1 class="header-title" id="header-title">Dashboard</h1>
</div>
<div class="header-actions">
<div class="header-badge" id="active-executions-badge" aria-label="Execuções ativas">
<i data-lucide="activity"></i>
<span id="active-executions-count">0</span>
<span class="header-badge-label">ativas</span>
</div>
<button class="btn btn--ghost btn--icon-text" id="system-status-btn" type="button" aria-label="Status do sistema">
<i data-lucide="server"></i>
<span>Sistema</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>
</div>
</header>
<main class="main-content">
<section id="dashboard" class="section active" aria-label="Dashboard">
<div class="metrics-grid">
<article class="metric-card">
<div class="metric-card-icon metric-card-icon--indigo">
<i data-lucide="cpu"></i>
</div>
<div class="metric-card-body">
<span class="metric-card-label">Total de Agentes</span>
<span class="metric-card-value" id="metric-total-agents">0</span>
</div>
</article>
<article class="metric-card">
<div class="metric-card-icon metric-card-icon--green">
<i data-lucide="zap"></i>
</div>
<div class="metric-card-body">
<span class="metric-card-label">Agentes Ativos</span>
<span class="metric-card-value" id="metric-active-agents">0</span>
</div>
</article>
<article class="metric-card">
<div class="metric-card-icon metric-card-icon--purple">
<i data-lucide="play-circle"></i>
</div>
<div class="metric-card-body">
<span class="metric-card-label">Execuções Hoje</span>
<span class="metric-card-value" id="metric-executions-today">0</span>
</div>
</article>
<article class="metric-card">
<div class="metric-card-icon metric-card-icon--orange">
<i data-lucide="clock"></i>
</div>
<div class="metric-card-body">
<span class="metric-card-label">Agendamentos</span>
<span class="metric-card-value" id="metric-schedules">0</span>
</div>
</article>
</div>
<div class="dashboard-grid">
<div class="card">
<div class="card-header">
<h2 class="card-title">Atividade Recente</h2>
<button class="btn btn--ghost btn--sm" type="button" id="refresh-activity-btn">
<i data-lucide="refresh-cw"></i>
</button>
</div>
<div class="card-body">
<ul class="activity-list" id="activity-list">
<li class="activity-empty">
<i data-lucide="inbox"></i>
<span>Nenhuma execução recente</span>
</li>
</ul>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Status do Sistema</h2>
</div>
<div class="card-body">
<ul class="system-status-list" id="system-status-list">
<li class="system-status-item">
<div class="system-status-info">
<i data-lucide="server"></i>
<span>Servidor HTTP</span>
</div>
<span class="badge badge--green">Online</span>
</li>
<li class="system-status-item">
<div class="system-status-info">
<i data-lucide="radio"></i>
<span>WebSocket</span>
</div>
<span class="badge badge--red" id="system-ws-status-badge">Desconectado</span>
</li>
<li class="system-status-item">
<div class="system-status-info">
<i data-lucide="database"></i>
<span>Armazenamento</span>
</div>
<span class="badge badge--green">OK</span>
</li>
<li class="system-status-item">
<div class="system-status-info">
<i data-lucide="terminal"></i>
<span>Claude CLI</span>
</div>
<span class="badge badge--gray" id="system-claude-status-badge">Verificando...</span>
</li>
</ul>
</div>
</div>
</div>
</section>
<section id="agents" class="section" aria-label="Agentes" hidden>
<div class="section-toolbar">
<div class="search-field">
<i data-lucide="search"></i>
<input type="search" placeholder="Buscar agentes..." id="agents-search" aria-label="Buscar agentes" />
</div>
<div class="section-toolbar-actions">
<select class="select" id="agents-filter-status" aria-label="Filtrar por status">
<option value="">Todos os status</option>
<option value="active">Ativo</option>
<option value="inactive">Inativo</option>
</select>
</div>
</div>
<div class="agents-grid" id="agents-grid">
<div class="empty-state" id="agents-empty-state">
<div class="empty-state-icon">
<i data-lucide="bot"></i>
</div>
<h3 class="empty-state-title">Nenhum agente cadastrado</h3>
<p class="empty-state-desc">Crie seu primeiro agente para começar a orquestrar tarefas com Claude.</p>
<button class="btn btn--primary btn--icon-text" type="button" id="agents-empty-new-btn">
<i data-lucide="plus"></i>
<span>Criar Agente</span>
</button>
</div>
</div>
</section>
<section id="tasks" class="section" aria-label="Tarefas" hidden>
<div class="section-toolbar">
<div class="search-field">
<i data-lucide="search"></i>
<input type="search" placeholder="Buscar tarefas..." id="tasks-search" aria-label="Buscar tarefas" />
</div>
<div class="section-toolbar-actions">
<select class="select" id="tasks-filter-category" aria-label="Filtrar por categoria">
<option value="">Todas as categorias</option>
<option value="code-review">Code Review</option>
<option value="security">Segurança</option>
<option value="refactor">Refatoração</option>
<option value="tests">Testes</option>
<option value="docs">Documentação</option>
<option value="performance">Performance</option>
</select>
<button class="btn btn--primary btn--icon-text" type="button" id="tasks-new-btn">
<i data-lucide="plus"></i>
<span>Nova Tarefa</span>
</button>
</div>
</div>
<div class="tasks-grid" id="tasks-grid">
<div class="empty-state" id="tasks-empty-state">
<div class="empty-state-icon">
<i data-lucide="list-checks"></i>
</div>
<h3 class="empty-state-title">Nenhuma tarefa cadastrada</h3>
<p class="empty-state-desc">Crie templates de tarefas para reutilizar em diferentes agentes.</p>
<button class="btn btn--primary btn--icon-text" type="button" id="tasks-empty-new-btn">
<i data-lucide="plus"></i>
<span>Criar Tarefa</span>
</button>
</div>
</div>
</section>
<section id="schedules" class="section" aria-label="Agendamentos" hidden>
<div class="section-toolbar">
<div class="search-field">
<i data-lucide="search"></i>
<input type="search" placeholder="Buscar agendamentos..." id="schedules-search" aria-label="Buscar agendamentos" />
</div>
<div class="section-toolbar-actions">
<select class="select" id="schedules-filter-status" aria-label="Filtrar por status">
<option value="">Todos</option>
<option value="active">Ativo</option>
<option value="paused">Pausado</option>
</select>
<button class="btn btn--primary btn--icon-text" type="button" id="schedules-new-btn">
<i data-lucide="plus"></i>
<span>Novo Agendamento</span>
</button>
</div>
</div>
<div class="card">
<div class="table-wrapper">
<table class="table" id="schedules-table">
<thead>
<tr>
<th scope="col">Agente</th>
<th scope="col">Tarefa</th>
<th scope="col">Expressão Cron</th>
<th scope="col">Próxima Execução</th>
<th scope="col">Status</th>
<th scope="col" aria-label="Ações"></th>
</tr>
</thead>
<tbody id="schedules-tbody">
<tr class="table-empty-row">
<td colspan="6">
<div class="empty-state empty-state--inline">
<i data-lucide="clock"></i>
<span>Nenhum agendamento configurado</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section id="pipelines" class="section" aria-label="Pipelines" hidden>
<div class="page-title">Pipelines</div>
<div class="page-subtitle">Encadeie agentes para fluxos de trabalho automatizados</div>
<div class="section-toolbar">
<div class="search-field">
<i data-lucide="search" class="search-icon" style="width:16px;height:16px"></i>
<input type="text" placeholder="Buscar pipelines..." id="pipelines-search">
</div>
<div class="section-toolbar-actions">
<button class="btn btn--primary btn--icon-text" type="button" id="pipelines-new-btn">
<i data-lucide="plus"></i>
<span>Novo Pipeline</span>
</button>
</div>
</div>
<div id="pipelines-grid" class="agents-grid">
</div>
</section>
<section id="terminal" class="section" aria-label="Terminal" hidden>
<div class="terminal-wrapper">
<div class="terminal-toolbar">
<div class="terminal-toolbar-left">
<div class="terminal-dot terminal-dot--red"></div>
<div class="terminal-dot terminal-dot--yellow"></div>
<div class="terminal-dot terminal-dot--green"></div>
<span class="terminal-title">Output de Execução</span>
</div>
<div class="terminal-toolbar-right">
<select class="select select--sm" id="terminal-execution-select" aria-label="Selecionar execução">
<option value="">Selecionar execução...</option>
</select>
<div class="terminal-ws-indicator" id="terminal-ws-indicator">
<span class="ws-indicator ws-indicator--disconnected" id="terminal-ws-dot"></span>
<span id="terminal-ws-label">Desconectado</span>
</div>
<button class="btn btn--ghost btn--sm btn--icon-text" type="button" id="terminal-clear-btn" aria-label="Limpar terminal">
<i data-lucide="trash-2"></i>
<span>Limpar</span>
</button>
</div>
</div>
<div class="terminal-output" id="terminal-output" role="log" aria-live="polite" aria-label="Saída do terminal">
<div class="terminal-welcome">
<span class="terminal-prompt">$</span>
<span class="terminal-text">Aguardando execução de agente...</span>
</div>
</div>
</div>
</section>
<section id="settings" class="section" aria-label="Configurações" hidden>
<div class="settings-grid">
<div class="card">
<div class="card-header">
<h2 class="card-title">Configurações Gerais</h2>
</div>
<div class="card-body">
<form class="settings-form" id="settings-form" novalidate>
<div class="form-group">
<label class="form-label" for="settings-default-model">Modelo Padrão</label>
<select class="select" id="settings-default-model" name="defaultModel">
<option value="claude-sonnet-4-6">claude-sonnet-4-6</option>
<option value="claude-opus-4-6">claude-opus-4-6</option>
<option value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="settings-default-workdir">Diretório de Trabalho Padrão</label>
<input
type="text"
class="input"
id="settings-default-workdir"
name="defaultWorkdir"
placeholder="/home/fred/projetos"
/>
</div>
<div class="form-group">
<label class="form-label" for="settings-max-concurrent">Execuções Simultâneas Máximas</label>
<input
type="number"
class="input"
id="settings-max-concurrent"
name="maxConcurrent"
min="1"
max="20"
placeholder="5"
/>
</div>
<div class="form-actions">
<button type="submit" class="btn btn--primary">Salvar Configurações</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Informações do Sistema</h2>
</div>
<div class="card-body">
<ul class="system-info-list" id="system-info-list">
<li class="system-info-item">
<span class="system-info-label">Versão do Servidor</span>
<span class="system-info-value" id="info-server-version">1.0.0</span>
</li>
<li class="system-info-item">
<span class="system-info-label">Versão do Node.js</span>
<span class="system-info-value font-mono" id="info-node-version">Carregando...</span>
</li>
<li class="system-info-item">
<span class="system-info-label">Versão do Claude CLI</span>
<span class="system-info-value font-mono" id="info-claude-version">Carregando...</span>
</li>
<li class="system-info-item">
<span class="system-info-label">Plataforma</span>
<span class="system-info-value font-mono" id="info-platform">Carregando...</span>
</li>
<li class="system-info-item">
<span class="system-info-label">Tempo Online</span>
<span class="system-info-value font-mono" id="info-uptime">Carregando...</span>
</li>
</ul>
</div>
</div>
</div>
</section>
</main>
</div>
</div>
<div class="modal-overlay" id="agent-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="agent-modal-title" hidden>
<div class="modal modal--lg">
<div class="modal-header">
<h2 class="modal-title" id="agent-modal-title">Novo Agente</h2>
<button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="agent-modal-overlay">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body">
<form class="modal-form" id="agent-form" novalidate>
<input type="hidden" id="agent-form-id" name="id" />
<div class="form-row">
<div class="form-group form-group--grow">
<label class="form-label" for="agent-name">
Nome do Agente
<span class="form-required" aria-hidden="true">*</span>
</label>
<input
type="text"
class="input"
id="agent-name"
name="name"
placeholder="Ex: Revisor de Código"
required
autocomplete="off"
/>
</div>
<div class="form-group">
<label class="form-label" for="agent-status-toggle">Status</label>
<div class="toggle-wrapper">
<input type="checkbox" class="toggle-input" id="agent-status-toggle" name="active" role="switch" />
<label class="toggle-label" for="agent-status-toggle">
<span class="toggle-thumb"></span>
<span class="toggle-text-on">Ativo</span>
<span class="toggle-text-off">Inativo</span>
</label>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label" for="agent-description">Descrição</label>
<textarea
class="textarea"
id="agent-description"
name="description"
rows="2"
placeholder="Descreva brevemente o propósito deste agente"
></textarea>
</div>
<div class="form-group">
<label class="form-label" for="agent-system-prompt">
System Prompt
</label>
<textarea
class="textarea textarea--code"
id="agent-system-prompt"
name="systemPrompt"
rows="6"
placeholder="Você é um especialista em... Seu objetivo é... Sempre responda em português..."
></textarea>
<p class="form-hint">Instruções base que definem o comportamento, personalidade e restrições do agente.</p>
</div>
<div class="form-row">
<div class="form-group form-group--grow">
<label class="form-label" for="agent-model">Modelo</label>
<select class="select" id="agent-model" name="model">
<option value="claude-sonnet-4-6">claude-sonnet-4-6</option>
<option value="claude-opus-4-6">claude-opus-4-6</option>
<option value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001</option>
</select>
</div>
<div class="form-group form-group--grow">
<label class="form-label" for="agent-workdir">Diretório de Trabalho</label>
<input
type="text"
class="input"
id="agent-workdir"
name="workdir"
placeholder="/home/fred/projetos"
autocomplete="off"
/>
</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">
<div class="tags-chips" id="agent-tags-chips"></div>
<input
type="text"
class="tags-input"
id="agent-tags-input"
placeholder="Digite e pressione Enter para adicionar"
autocomplete="off"
/>
</div>
<input type="hidden" id="agent-tags" name="tags" value="[]" />
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn--ghost" type="button" data-modal-close="agent-modal-overlay">Cancelar</button>
<button class="btn btn--primary" type="submit" form="agent-form" id="agent-form-submit">Salvar</button>
</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">
<h2 class="modal-title" id="execute-modal-title">Executar Tarefa</h2>
<button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="execute-modal-overlay">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body">
<form class="modal-form" id="execute-form" novalidate>
<input type="hidden" id="execute-agent-id" name="agentId" />
<div class="form-group" id="execute-agent-select-group">
<label class="form-label" for="execute-agent-select">
Agente
<span class="form-required" aria-hidden="true">*</span>
</label>
<select class="select" id="execute-agent-select" name="agentSelect" required>
<option value="">Selecionar agente...</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="execute-task-desc">
Descrição da Tarefa
<span class="form-required" aria-hidden="true">*</span>
</label>
<textarea
class="textarea"
id="execute-task-desc"
name="task"
rows="3"
placeholder="Descreva o que o agente deve fazer..."
required
></textarea>
</div>
<div class="form-group">
<label class="form-label" for="execute-instructions">Instruções Adicionais</label>
<textarea
class="textarea textarea--code"
id="execute-instructions"
name="instructions"
rows="3"
placeholder="Contexto adicional, restrições ou preferências..."
></textarea>
</div>
<div class="quick-templates">
<p class="quick-templates-label">Templates rápidos</p>
<div class="quick-templates-grid">
<button class="template-btn" type="button" data-template="Analisar código e detectar bugs, vulnerabilidades e problemas de qualidade. Forneça um relatório detalhado com sugestões de correção.">
<i data-lucide="bug"></i>
<span>Detectar Bugs</span>
</button>
<button class="template-btn" type="button" data-template="Realizar revisão de segurança seguindo as diretrizes OWASP Top 10. Identificar vulnerabilidades, inputs não validados e problemas de autenticação/autorização.">
<i data-lucide="shield"></i>
<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>
<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>
<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.">
<i data-lucide="book-open"></i>
<span>Documentar Código</span>
</button>
<button class="template-btn" type="button" data-template="Analisar e otimizar a performance do código. Identificar gargalos, reduzir complexidade algorítmica e melhorar uso de memória.">
<i data-lucide="gauge"></i>
<span>Otimizar Performance</span>
</button>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn--ghost" type="button" data-modal-close="execute-modal-overlay">Cancelar</button>
<button class="btn btn--primary btn--icon-text" type="submit" form="execute-form" id="execute-form-submit">
<i data-lucide="play"></i>
<span>Executar</span>
</button>
</div>
</div>
</div>
<div class="modal-overlay" id="schedule-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="schedule-modal-title" hidden>
<div class="modal modal--md">
<div class="modal-header">
<h2 class="modal-title" id="schedule-modal-title">Novo Agendamento</h2>
<button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="schedule-modal-overlay">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body">
<form class="modal-form" id="schedule-form" novalidate>
<input type="hidden" id="schedule-form-id" name="id" />
<div class="form-group">
<label class="form-label" for="schedule-agent">
Agente
<span class="form-required" aria-hidden="true">*</span>
</label>
<select class="select" id="schedule-agent" name="agentId" required>
<option value="">Selecionar agente...</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="schedule-task">
Descrição da Tarefa
<span class="form-required" aria-hidden="true">*</span>
</label>
<textarea
class="textarea"
id="schedule-task"
name="task"
rows="3"
placeholder="Descreva a tarefa que será executada automaticamente..."
required
></textarea>
</div>
<div class="form-group">
<label class="form-label" for="schedule-cron">
Expressão Cron
<span class="form-required" aria-hidden="true">*</span>
</label>
<input
type="text"
class="input input--mono"
id="schedule-cron"
name="cron"
placeholder="0 * * * *"
required
autocomplete="off"
aria-describedby="schedule-cron-hint"
/>
<p class="form-hint" id="schedule-cron-hint">Formato: minuto hora dia-mês mês dia-semana</p>
</div>
<div class="cron-presets">
<p class="cron-presets-label">Presets</p>
<div class="cron-presets-list">
<button class="cron-preset-btn" type="button" data-cron="0 * * * *">
<span class="cron-preset-name">A cada hora</span>
<span class="cron-preset-expr">0 * * * *</span>
</button>
<button class="cron-preset-btn" type="button" data-cron="0 9 * * *">
<span class="cron-preset-name">Diário (09:00)</span>
<span class="cron-preset-expr">0 9 * * *</span>
</button>
<button class="cron-preset-btn" type="button" data-cron="0 9 * * 1">
<span class="cron-preset-name">Semanal (seg 09:00)</span>
<span class="cron-preset-expr">0 9 * * 1</span>
</button>
<button class="cron-preset-btn" type="button" data-cron="0 0 1 * *">
<span class="cron-preset-name">Mensal (dia 1)</span>
<span class="cron-preset-expr">0 0 1 * *</span>
</button>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn--ghost" type="button" data-modal-close="schedule-modal-overlay">Cancelar</button>
<button class="btn btn--primary btn--icon-text" type="submit" form="schedule-form" id="schedule-form-submit">
<i data-lucide="clock"></i>
<span>Agendar</span>
</button>
</div>
</div>
</div>
<div class="modal-overlay" id="pipeline-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="pipeline-modal-title" hidden>
<div class="modal modal--lg">
<div class="modal-header">
<h2 class="modal-title" id="pipeline-modal-title">Novo Pipeline</h2>
<button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="pipeline-modal-overlay">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body">
<form class="modal-form" id="pipeline-form" novalidate>
<input type="hidden" id="pipeline-form-id">
<div class="form-group">
<label class="form-label" for="pipeline-name">
Nome
<span class="form-required" aria-hidden="true">*</span>
</label>
<input class="input" type="text" id="pipeline-name" placeholder="Ex: Análise e Correção de Bugs" required>
</div>
<div class="form-group">
<label class="form-label" for="pipeline-description">Descrição</label>
<textarea class="textarea" id="pipeline-description" rows="2" placeholder="Descreva o objetivo do pipeline..."></textarea>
</div>
<div class="form-group">
<label class="form-label">
Passos do Pipeline
<span class="form-required" aria-hidden="true">*</span>
</label>
<p class="form-hint mb-8">Cada passo usa um agente. O output de um passo vira o input do próximo.</p>
<div id="pipeline-steps-container"></div>
<button class="btn btn--ghost btn--sm mt-8" type="button" id="pipeline-add-step-btn">
<i data-lucide="plus" style="width:14px;height:14px"></i>
Adicionar Passo
</button>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn--ghost" type="button" data-modal-close="pipeline-modal-overlay">Cancelar</button>
<button class="btn btn--primary" type="button" id="pipeline-form-submit">Salvar Pipeline</button>
</div>
</div>
</div>
<div class="modal-overlay" id="pipeline-execute-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="pipeline-execute-title" hidden>
<div class="modal modal--md">
<div class="modal-header">
<h2 class="modal-title" id="pipeline-execute-title">Executar Pipeline</h2>
<button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="pipeline-execute-modal-overlay">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body">
<input type="hidden" id="pipeline-execute-id">
<div class="form-group">
<label class="form-label" for="pipeline-execute-input">
Input Inicial
<span class="form-required" aria-hidden="true">*</span>
</label>
<textarea class="textarea" id="pipeline-execute-input" rows="4" placeholder="Descreva a tarefa inicial para o pipeline..."></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn--ghost" type="button" data-modal-close="pipeline-execute-modal-overlay">Cancelar</button>
<button class="btn btn--primary btn--icon-text" type="button" id="pipeline-execute-submit">
<i data-lucide="play" style="width:14px;height:14px"></i>
<span>Executar</span>
</button>
</div>
</div>
</div>
<div class="modal-overlay" id="confirm-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-title" hidden>
<div class="modal modal--sm">
<div class="modal-header">
<div class="confirm-modal-icon" id="confirm-modal-icon">
<i data-lucide="alert-triangle"></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">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body">
<p class="confirm-modal-message" id="confirm-modal-message"></p>
</div>
<div class="modal-footer">
<button class="btn btn--ghost" type="button" data-modal-close="confirm-modal-overlay">Cancelar</button>
<button class="btn btn--danger" type="button" id="confirm-modal-confirm-btn">Confirmar</button>
</div>
</div>
</div>
<div class="modal-overlay" id="export-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="export-modal-title" hidden>
<div class="modal modal--md">
<div class="modal-header">
<h2 class="modal-title" id="export-modal-title">Exportar Agente</h2>
<button class="modal-close" type="button" aria-label="Fechar modal" data-modal-close="export-modal-overlay">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body">
<div class="export-code-wrapper">
<div class="export-code-toolbar">
<span class="export-code-label">JSON</span>
<button class="btn btn--ghost btn--sm btn--icon-text" type="button" id="export-copy-btn" aria-label="Copiar JSON">
<i data-lucide="copy"></i>
<span>Copiar</span>
</button>
</div>
<pre class="export-code" id="export-code-content" tabindex="0" aria-label="Dados do agente em formato JSON"></pre>
</div>
</div>
<div class="modal-footer">
<button class="btn btn--ghost" type="button" data-modal-close="export-modal-overlay">Fechar</button>
</div>
</div>
</div>
<div class="toast-container" id="toast-container" aria-live="assertive" aria-atomic="false" role="region" aria-label="Notificações"></div>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
<script src="js/api.js"></script>
<script src="js/components/toast.js"></script>
<script src="js/components/modal.js"></script>
<script src="js/components/terminal.js"></script>
<script src="js/components/agents.js"></script>
<script src="js/components/dashboard.js"></script>
<script src="js/components/tasks.js"></script>
<script src="js/components/schedules.js"></script>
<script src="js/components/pipelines.js"></script>
<script src="js/app.js"></script>
<script>
lucide.createIcons();
App.init();
</script>
</body>
</html>

67
public/js/api.js Normal file
View File

@@ -0,0 +1,67 @@
const API = {
baseUrl: '/api',
async request(method, path, body = null) {
const options = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (body !== null) {
options.body = JSON.stringify(body);
}
const response = await fetch(`${API.baseUrl}${path}`, options);
if (response.status === 204) return null;
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `Erro HTTP ${response.status}`);
}
return data;
},
agents: {
list() { return API.request('GET', '/agents'); },
get(id) { return API.request('GET', `/agents/${id}`); },
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 }); },
cancel(id, executionId) { return API.request('POST', `/agents/${id}/cancel/${executionId}`); },
export(id) { return API.request('GET', `/agents/${id}/export`); },
},
tasks: {
list() { return API.request('GET', '/tasks'); },
create(data) { return API.request('POST', '/tasks', data); },
update(id, data) { return API.request('PUT', `/tasks/${id}`, data); },
delete(id) { return API.request('DELETE', `/tasks/${id}`); },
},
schedules: {
list() { return API.request('GET', '/schedules'); },
create(data) { return API.request('POST', '/schedules', data); },
delete(taskId) { return API.request('DELETE', `/schedules/${taskId}`); },
},
pipelines: {
list() { return API.request('GET', '/pipelines'); },
get(id) { return API.request('GET', `/pipelines/${id}`); },
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) { return API.request('POST', `/pipelines/${id}/execute`, { input }); },
cancel(id) { return API.request('POST', `/pipelines/${id}/cancel`); },
},
system: {
status() { return API.request('GET', '/system/status'); },
activeExecutions() { return API.request('GET', '/executions/active'); },
},
};
window.API = API;

545
public/js/app.js Normal file
View File

@@ -0,0 +1,545 @@
const App = {
currentSection: 'dashboard',
ws: null,
wsReconnectAttempts: 0,
wsReconnectTimer: null,
_initialized: false,
sectionTitles: {
dashboard: 'Dashboard',
agents: 'Agentes',
tasks: 'Tarefas',
schedules: 'Agendamentos',
pipelines: 'Pipelines',
terminal: 'Terminal',
settings: 'Configurações',
},
init() {
if (App._initialized) return;
App._initialized = true;
App.setupNavigation();
App.setupWebSocket();
App.setupEventListeners();
App.setupKeyboardShortcuts();
App.navigateTo('dashboard');
App.startPeriodicRefresh();
if (window.lucide) lucide.createIcons();
},
setupNavigation() {
document.querySelectorAll('.sidebar-nav-link[data-section]').forEach((link) => {
link.addEventListener('click', (e) => {
e.preventDefault();
App.navigateTo(link.dataset.section);
});
});
const refreshBtn = document.getElementById('refresh-activity-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => DashboardUI.load());
}
},
navigateTo(section) {
document.querySelectorAll('.section').forEach((el) => {
const isActive = el.id === section;
el.classList.toggle('active', isActive);
el.hidden = !isActive;
});
document.querySelectorAll('.sidebar-nav-item').forEach((item) => {
const link = item.querySelector('.sidebar-nav-link');
item.classList.toggle('active', link && link.dataset.section === section);
});
const titleEl = document.getElementById('header-title');
if (titleEl) titleEl.textContent = App.sectionTitles[section] || section;
App.currentSection = section;
App._loadSection(section);
},
async _loadSection(section) {
try {
switch (section) {
case 'dashboard': await DashboardUI.load(); break;
case 'agents': await AgentsUI.load(); break;
case 'tasks': await TasksUI.load(); break;
case 'schedules': await SchedulesUI.load(); break;
case 'pipelines': await PipelinesUI.load(); break;
}
} catch (err) {
Toast.error(`Erro ao carregar seção: ${err.message}`);
}
},
setupWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${window.location.host}`;
try {
App.ws = new WebSocket(url);
App.ws.onopen = () => {
App.updateWsStatus('connected');
App.wsReconnectAttempts = 0;
if (App.wsReconnectTimer) {
clearTimeout(App.wsReconnectTimer);
App.wsReconnectTimer = null;
}
};
App.ws.onclose = () => {
App.updateWsStatus('disconnected');
App._scheduleWsReconnect();
};
App.ws.onerror = () => {
App.updateWsStatus('error');
};
App.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
App.handleWsMessage(data);
} catch {
//
}
};
} catch {
App.updateWsStatus('error');
App._scheduleWsReconnect();
}
},
_scheduleWsReconnect() {
const delay = Math.min(1000 * Math.pow(2, App.wsReconnectAttempts), 30000);
App.wsReconnectAttempts++;
App.wsReconnectTimer = setTimeout(() => {
App.setupWebSocket();
}, delay);
},
handleWsMessage(data) {
switch (data.type) {
case 'execution_output': {
Terminal.stopProcessing();
const content = data.data?.content || '';
if (content) {
Terminal.addLine(content, 'default');
}
App._updateActiveBadge();
break;
}
case 'execution_complete': {
Terminal.stopProcessing();
const result = data.data?.result || '';
if (result) {
Terminal.addLine(result, 'success');
} else {
Terminal.addLine('Execução concluída (sem resultado textual).', 'info');
}
if (data.data?.stderr) {
Terminal.addLine(data.data.stderr, 'error');
}
Toast.success('Execução concluída');
App.refreshCurrentSection();
App._updateActiveBadge();
break;
}
case 'execution_error':
Terminal.stopProcessing();
Terminal.addLine(data.data?.error || 'Erro na execução', 'error');
Toast.error(`Erro na execução: ${data.data?.error || 'desconhecido'}`);
App._updateActiveBadge();
break;
case 'pipeline_step_start':
Terminal.stopProcessing();
Terminal.addLine(`Pipeline passo ${data.stepIndex + 1}/${data.totalSteps}: Executando agente "${data.agentName}"...`, 'system');
Terminal.startProcessing(data.agentName);
break;
case 'pipeline_step_complete':
Terminal.stopProcessing();
Terminal.addLine(`Passo ${data.stepIndex + 1} concluído.`, 'info');
Terminal.addLine(data.result || '(sem output)', 'default');
break;
case 'pipeline_complete':
Terminal.stopProcessing();
Terminal.addLine('Pipeline concluído com sucesso.', 'success');
Toast.success('Pipeline concluído');
App.refreshCurrentSection();
break;
case 'pipeline_error':
Terminal.stopProcessing();
Terminal.addLine(`Erro no passo ${data.stepIndex + 1}: ${data.error}`, 'error');
Toast.error('Erro no pipeline');
break;
}
},
updateWsStatus(status) {
const indicator = document.getElementById('ws-indicator');
const label = document.getElementById('ws-label');
const terminalDot = document.getElementById('terminal-ws-dot');
const terminalLabel = document.getElementById('terminal-ws-label');
const wsBadge = document.getElementById('system-ws-status-badge');
const labels = {
connected: 'Conectado',
disconnected: 'Desconectado',
error: 'Erro de conexão',
};
const cssClass = {
connected: 'ws-indicator--connected',
disconnected: 'ws-indicator--disconnected',
error: 'ws-indicator--error',
};
const badgeClass = {
connected: 'badge--green',
disconnected: 'badge--red',
error: 'badge--red',
};
const displayLabel = labels[status] || status;
const dotClass = cssClass[status] || 'ws-indicator--disconnected';
[indicator, terminalDot].forEach((el) => {
if (!el) return;
el.className = `ws-indicator ${dotClass}`;
});
[label, terminalLabel].forEach((el) => {
if (el) el.textContent = displayLabel;
});
if (wsBadge) {
wsBadge.textContent = displayLabel;
wsBadge.className = `badge ${badgeClass[status] || 'badge--red'}`;
}
},
setupEventListeners() {
const on = (id, event, handler) => {
const el = document.getElementById(id);
if (el) el.addEventListener(event, handler);
};
on('new-agent-btn', 'click', () => AgentsUI.openCreateModal());
on('agents-empty-new-btn', 'click', () => AgentsUI.openCreateModal());
on('agent-form-submit', 'click', (e) => {
e.preventDefault();
AgentsUI.save();
});
on('agent-form', 'submit', (e) => {
e.preventDefault();
AgentsUI.save();
});
on('execute-form-submit', 'click', (e) => {
e.preventDefault();
App._handleExecute();
});
on('execute-form', 'submit', (e) => {
e.preventDefault();
App._handleExecute();
});
on('tasks-new-btn', 'click', () => TasksUI.openCreateModal());
on('tasks-empty-new-btn', 'click', () => TasksUI.openCreateModal());
on('schedules-new-btn', 'click', () => SchedulesUI.openCreateModal());
on('schedule-form-submit', 'click', (e) => {
e.preventDefault();
SchedulesUI.save();
});
on('schedule-form', 'submit', (e) => {
e.preventDefault();
SchedulesUI.save();
});
on('pipelines-new-btn', 'click', () => PipelinesUI.openCreateModal());
on('pipeline-form-submit', 'click', (e) => {
e.preventDefault();
PipelinesUI.save();
});
on('pipeline-add-step-btn', 'click', () => PipelinesUI.addStep());
on('pipeline-execute-submit', 'click', () => PipelinesUI._executeFromModal());
on('terminal-clear-btn', 'click', () => Terminal.clear());
on('export-copy-btn', 'click', () => App._copyExportJson());
on('system-status-btn', 'click', () => App.navigateTo('dashboard'));
on('terminal-execution-select', 'change', (e) => {
Terminal.setExecutionFilter(e.target.value || null);
});
on('settings-form', 'submit', (e) => {
e.preventDefault();
Toast.info('Configurações salvas');
});
document.getElementById('agents-grid')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const { action, id } = btn.dataset;
switch (action) {
case 'execute': AgentsUI.execute(id); break;
case 'edit': AgentsUI.openEditModal(id); break;
case 'export': AgentsUI.export(id); break;
case 'delete': AgentsUI.delete(id); break;
}
});
document.getElementById('tasks-grid')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const { action, id } = btn.dataset;
if (action === 'delete-task') TasksUI.delete(id);
});
document.getElementById('schedules-tbody')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const { action, id } = btn.dataset;
if (action === 'delete-schedule') SchedulesUI.delete(id);
});
document.getElementById('pipelines-grid')?.addEventListener('click', (e) => {
if (e.target.closest('#pipelines-empty-new-btn')) {
PipelinesUI.openCreateModal();
return;
}
const btn = e.target.closest('[data-action]');
if (!btn) return;
const { action, id } = btn.dataset;
switch (action) {
case 'execute-pipeline': PipelinesUI.execute(id); break;
case 'edit-pipeline': PipelinesUI.openEditModal(id); break;
case 'delete-pipeline': PipelinesUI.delete(id); break;
}
});
document.getElementById('pipeline-steps-container')?.addEventListener('click', (e) => {
const btn = e.target.closest('[data-step-action]');
if (!btn) return;
const stepAction = btn.dataset.stepAction;
const stepIndex = parseInt(btn.dataset.stepIndex, 10);
switch (stepAction) {
case 'move-up': PipelinesUI.moveStep(stepIndex, -1); break;
case 'move-down': PipelinesUI.moveStep(stepIndex, 1); break;
case 'remove': PipelinesUI.removeStep(stepIndex); break;
}
});
document.addEventListener('click', (e) => {
const template = e.target.closest('[data-template]');
if (template) {
const taskEl = document.getElementById('execute-task-desc');
if (taskEl) taskEl.value = template.dataset.template;
return;
}
const cronPreset = e.target.closest('[data-cron]');
if (cronPreset) {
const cronEl = document.getElementById('schedule-cron');
if (cronEl) cronEl.value = cronPreset.dataset.cron;
return;
}
});
App._setupTagsInput();
},
_setupTagsInput() {
const input = document.getElementById('agent-tags-input');
const chips = document.getElementById('agent-tags-chips');
const hidden = document.getElementById('agent-tags');
if (!input || !chips || !hidden) return;
const getTags = () => {
try { return JSON.parse(hidden.value || '[]'); } catch { return []; }
};
const setTags = (tags) => {
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>
</span>
`).join('');
};
input.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ',') return;
e.preventDefault();
const value = input.value.trim().replace(/,$/, '');
if (!value) return;
const tags = getTags();
if (!tags.includes(value)) {
tags.push(value);
setTags(tags);
}
input.value = '';
});
chips.addEventListener('click', (e) => {
const removeBtn = e.target.closest('.tag-remove');
if (!removeBtn) return;
const tag = removeBtn.dataset.tag;
const tags = getTags().filter((t) => t !== tag);
setTags(tags);
});
},
async _handleExecute() {
const agentId = document.getElementById('execute-agent-select')?.value
|| document.getElementById('execute-agent-id')?.value;
if (!agentId) {
Toast.warning('Selecione um agente para executar');
return;
}
const task = document.getElementById('execute-task-desc')?.value.trim();
if (!task) {
Toast.warning('Descreva a tarefa a ser executada');
return;
}
const instructions = document.getElementById('execute-instructions')?.value.trim() || '';
try {
const selectEl = document.getElementById('execute-agent-select');
const agentName = selectEl?.selectedOptions[0]?.text || 'Agente';
await API.agents.execute(agentId, task, instructions);
Modal.close('execute-modal-overlay');
App.navigateTo('terminal');
Toast.info('Execução iniciada');
Terminal.startProcessing(agentName);
} catch (err) {
Toast.error(`Erro ao iniciar execução: ${err.message}`);
}
},
async _copyExportJson() {
const jsonEl = document.getElementById('export-code-content');
if (!jsonEl) return;
try {
await navigator.clipboard.writeText(jsonEl.textContent);
Toast.success('JSON copiado para a área de transferência');
} catch {
Toast.error('Não foi possível copiar o JSON');
}
},
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
Modal.closeAll();
return;
}
const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName);
if (isTyping) return;
if (e.key === 'n' || e.key === 'N') {
if (App.currentSection === 'agents') {
AgentsUI.openCreateModal();
}
}
});
},
async refreshCurrentSection() {
await App._loadSection(App.currentSection);
},
async _updateActiveBadge() {
try {
const active = await API.system.activeExecutions();
const count = Array.isArray(active) ? active.length : 0;
const badge = document.getElementById('active-executions-badge');
const countEl = document.getElementById('active-executions-count');
if (countEl) countEl.textContent = count;
if (badge) badge.style.display = count > 0 ? 'flex' : 'none';
const terminalSelect = document.getElementById('terminal-execution-select');
if (terminalSelect && Array.isArray(active)) {
const existing = new Set(
Array.from(terminalSelect.options).map((o) => o.value).filter(Boolean)
);
active.forEach((exec) => {
const execId = exec.executionId || exec.id;
if (!existing.has(execId)) {
const option = document.createElement('option');
option.value = execId;
const agentName = (exec.agentConfig && exec.agentConfig.agent_name) || exec.agentId || 'Agente';
option.textContent = `${agentName}${new Date(exec.startedAt).toLocaleTimeString('pt-BR')}`;
terminalSelect.appendChild(option);
}
});
}
} catch {
//
}
},
startPeriodicRefresh() {
setInterval(async () => {
await App._updateActiveBadge();
if (App.currentSection === 'dashboard') {
await DashboardUI.load();
}
}, 30000);
},
};
document.addEventListener('DOMContentLoaded', () => App.init());
window.App = App;

View File

@@ -0,0 +1,309 @@
const AgentsUI = {
agents: [],
avatarColors: [
'#6366f1',
'#8b5cf6',
'#ec4899',
'#f59e0b',
'#10b981',
'#3b82f6',
'#ef4444',
'#14b8a6',
],
async load() {
try {
AgentsUI.agents = await API.agents.list();
AgentsUI.render();
} catch (err) {
Toast.error(`Erro ao carregar agentes: ${err.message}`);
}
},
render() {
const grid = document.getElementById('agents-grid');
const empty = document.getElementById('agents-empty-state');
if (!grid) return;
if (AgentsUI.agents.length === 0) {
if (empty) empty.style.display = 'flex';
return;
}
if (empty) empty.style.display = 'none';
const existingCards = grid.querySelectorAll('.agent-card');
existingCards.forEach((c) => c.remove());
const fragment = document.createDocumentFragment();
AgentsUI.agents.forEach((agent) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = AgentsUI.renderCard(agent);
fragment.appendChild(wrapper.firstElementChild);
});
grid.appendChild(fragment);
if (window.lucide) lucide.createIcons({ nodes: [grid] });
},
renderCard(agent) {
const name = agent.agent_name || agent.name || 'Sem nome';
const color = AgentsUI.getAvatarColor(name);
const initials = AgentsUI.getInitials(name);
const statusLabel = agent.status === 'active' ? 'Ativo' : 'Inativo';
const statusClass = agent.status === 'active' ? 'badge-active' : 'badge-inactive';
const model = (agent.config && agent.config.model) || agent.model || 'claude-sonnet-4-6';
const updatedAt = AgentsUI.formatDate(agent.updated_at || agent.updatedAt || agent.created_at || agent.createdAt);
return `
<div class="agent-card" data-agent-id="${agent.id}">
<div class="agent-card-body">
<div class="agent-card-top">
<div class="agent-avatar" style="background-color: ${color}" aria-hidden="true">
<span>${initials}</span>
</div>
<div class="agent-info">
<h3 class="agent-name">${name}</h3>
<span class="badge ${statusClass}">${statusLabel}</span>
</div>
</div>
${agent.description ? `<p class="agent-description">${agent.description}</p>` : ''}
<div class="agent-meta">
<span class="agent-meta-item">
<i data-lucide="cpu"></i>
${model}
</span>
<span class="agent-meta-item">
<i data-lucide="clock"></i>
${updatedAt}
</span>
</div>
</div>
<div class="agent-actions">
<button class="btn btn-primary btn-sm" data-action="execute" data-id="${agent.id}">
<i data-lucide="play"></i>
Executar
</button>
<button class="btn btn-ghost btn-sm" data-action="edit" data-id="${agent.id}">
<i data-lucide="pencil"></i>
Editar
</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 btn-danger" data-action="delete" data-id="${agent.id}" title="Excluir agente">
<i data-lucide="trash-2"></i>
</button>
</div>
</div>
`;
},
openCreateModal() {
const form = document.getElementById('agent-form');
if (form) form.reset();
const idField = document.getElementById('agent-form-id');
if (idField) idField.value = '';
const titleEl = document.getElementById('agent-modal-title');
if (titleEl) titleEl.textContent = 'Novo Agente';
const toggle = document.getElementById('agent-status-toggle');
if (toggle) toggle.checked = true;
const tagsHidden = document.getElementById('agent-tags');
if (tagsHidden) tagsHidden.value = '[]';
const tagsChips = document.getElementById('agent-tags-chips');
if (tagsChips) tagsChips.innerHTML = '';
Modal.open('agent-modal-overlay');
},
async openEditModal(agentId) {
try {
const agent = await API.agents.get(agentId);
const titleEl = document.getElementById('agent-modal-title');
if (titleEl) titleEl.textContent = 'Editar Agente';
const fields = {
'agent-form-id': agent.id,
'agent-name': agent.agent_name || agent.name || '',
'agent-description': agent.description || '',
'agent-system-prompt': (agent.config && agent.config.systemPrompt) || '',
'agent-model': (agent.config && agent.config.model) || 'claude-sonnet-4-6',
'agent-workdir': (agent.config && agent.config.workingDirectory) || '',
};
for (const [fieldId, value] of Object.entries(fields)) {
const el = document.getElementById(fieldId);
if (el) el.value = value;
}
const toggle = document.getElementById('agent-status-toggle');
if (toggle) toggle.checked = agent.status === 'active';
const tags = Array.isArray(agent.tags) ? agent.tags : [];
const tagsHidden = document.getElementById('agent-tags');
if (tagsHidden) tagsHidden.value = JSON.stringify(tags);
const tagsChips = document.getElementById('agent-tags-chips');
if (tagsChips) {
tagsChips.innerHTML = tags.map((t) =>
`<span class="tag-chip">${t}<button type="button" data-tag="${t}" class="tag-remove" aria-label="Remover tag ${t}">×</button></span>`
).join('');
}
Modal.open('agent-modal-overlay');
} catch (err) {
Toast.error(`Erro ao carregar agente: ${err.message}`);
}
},
async save() {
const idEl = document.getElementById('agent-form-id');
const id = idEl ? idEl.value.trim() : '';
const nameEl = document.getElementById('agent-name');
if (!nameEl || !nameEl.value.trim()) {
Toast.warning('Nome do agente é obrigatório');
return;
}
const tagsHidden = document.getElementById('agent-tags');
let tags = [];
try {
tags = JSON.parse(tagsHidden?.value || '[]');
} catch {
tags = [];
}
const toggle = document.getElementById('agent-status-toggle');
const data = {
agent_name: nameEl.value.trim(),
description: document.getElementById('agent-description')?.value.trim() || '',
status: toggle && toggle.checked ? 'active' : 'inactive',
config: {
systemPrompt: document.getElementById('agent-system-prompt')?.value.trim() || '',
model: document.getElementById('agent-model')?.value || 'claude-sonnet-4-6',
workingDirectory: document.getElementById('agent-workdir')?.value.trim() || '',
},
};
try {
if (id) {
await API.agents.update(id, data);
Toast.success('Agente atualizado com sucesso');
} else {
await API.agents.create(data);
Toast.success('Agente criado com sucesso');
}
Modal.close('agent-modal-overlay');
await AgentsUI.load();
} catch (err) {
Toast.error(`Erro ao salvar agente: ${err.message}`);
}
},
async delete(agentId) {
const confirmed = await Modal.confirm(
'Excluir agente',
'Tem certeza que deseja excluir este agente? Esta ação não pode ser desfeita.'
);
if (!confirmed) return;
try {
await API.agents.delete(agentId);
Toast.success('Agente excluído com sucesso');
await AgentsUI.load();
} catch (err) {
Toast.error(`Erro ao excluir agente: ${err.message}`);
}
},
async execute(agentId) {
const agent = AgentsUI.agents.find((a) => a.id === agentId);
try {
const allAgents = AgentsUI.agents.length > 0 ? AgentsUI.agents : await API.agents.list();
const selectEl = document.getElementById('execute-agent-select');
if (selectEl) {
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>`)
.join('');
selectEl.value = agentId;
}
const hiddenId = document.getElementById('execute-agent-id');
if (hiddenId) hiddenId.value = agentId;
const taskEl = document.getElementById('execute-task-desc');
if (taskEl) taskEl.value = '';
const instructionsEl = document.getElementById('execute-instructions');
if (instructionsEl) instructionsEl.value = '';
Modal.open('execute-modal-overlay');
} catch (err) {
Toast.error(`Erro ao abrir modal de execução: ${err.message}`);
}
},
async export(agentId) {
try {
const data = await API.agents.export(agentId);
const jsonEl = document.getElementById('export-code-content');
if (jsonEl) jsonEl.textContent = JSON.stringify(data, null, 2);
Modal.open('export-modal-overlay');
} catch (err) {
Toast.error(`Erro ao exportar agente: ${err.message}`);
}
},
getAvatarColor(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const index = Math.abs(hash) % AgentsUI.avatarColors.length;
return AgentsUI.avatarColors[index];
},
getInitials(name) {
return name
.split(' ')
.slice(0, 2)
.map((w) => w[0])
.join('')
.toUpperCase();
},
formatDate(isoString) {
if (!isoString) return '—';
const date = new Date(isoString);
return date.toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
},
};
window.AgentsUI = AgentsUI;

View File

@@ -0,0 +1,118 @@
const DashboardUI = {
async load() {
try {
const [status, agents] = await Promise.all([
API.system.status(),
API.agents.list(),
]);
DashboardUI.updateMetrics(status, agents);
DashboardUI.updateRecentActivity(status.executions?.list || []);
DashboardUI.updateSystemStatus(status);
} catch (err) {
Toast.error(`Erro ao carregar dashboard: ${err.message}`);
}
},
updateMetrics(status, agents) {
const metrics = {
'metric-total-agents': status.agents?.total ?? (agents?.length ?? 0),
'metric-active-agents': status.agents?.active ?? 0,
'metric-executions-today': status.executions?.active ?? 0,
'metric-schedules': status.schedules?.total ?? 0,
};
for (const [id, target] of Object.entries(metrics)) {
const el = document.getElementById(id);
if (!el) continue;
const current = parseInt(el.textContent, 10) || 0;
DashboardUI._animateCount(el, current, target);
}
},
_animateCount(el, from, to) {
const duration = 600;
const start = performance.now();
const step = (now) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const value = Math.round(from + (to - from) * eased);
el.textContent = value;
if (progress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
},
updateRecentActivity(executions) {
const list = document.getElementById('activity-list');
if (!list) return;
if (!executions || executions.length === 0) {
list.innerHTML = `
<li class="activity-empty">
<i data-lucide="inbox"></i>
<span>Nenhuma execução recente</span>
</li>
`;
if (window.lucide) lucide.createIcons({ nodes: [list] });
return;
}
list.innerHTML = executions.map((exec) => {
const statusClass = DashboardUI._statusBadgeClass(exec.status);
const statusLabel = DashboardUI._statusLabel(exec.status);
const time = exec.startedAt
? new Date(exec.startedAt).toLocaleTimeString('pt-BR')
: '—';
return `
<li class="activity-item">
<div class="activity-item-info">
<span class="activity-item-agent">${exec.agentName || exec.agentId || 'Agente'}</span>
<span class="activity-item-task">${exec.task || ''}</span>
</div>
<div class="activity-item-meta">
<span class="badge ${statusClass}">${statusLabel}</span>
<span class="activity-item-time">${time}</span>
</div>
</li>
`;
}).join('');
},
updateSystemStatus(status) {
const wsBadge = document.getElementById('system-ws-status-badge');
if (wsBadge) {
const wsConnected = document.getElementById('ws-indicator')?.classList.contains('ws-indicator--connected');
wsBadge.textContent = wsConnected ? 'Conectado' : 'Desconectado';
wsBadge.className = `badge ${wsConnected ? 'badge--green' : 'badge--red'}`;
}
},
_statusBadgeClass(status) {
const map = {
running: 'badge--blue',
completed: 'badge--green',
error: 'badge--red',
cancelled: 'badge--gray',
};
return map[status] || 'badge--gray';
},
_statusLabel(status) {
const map = {
running: 'Em execução',
completed: 'Concluído',
error: 'Erro',
cancelled: 'Cancelado',
};
return map[status] || status || 'Desconhecido';
},
};
window.DashboardUI = DashboardUI;

View File

@@ -0,0 +1,106 @@
const Modal = {
_confirmResolve: null,
open(modalId) {
const overlay = document.getElementById(modalId);
if (!overlay) return;
overlay.hidden = false;
requestAnimationFrame(() => overlay.classList.add('active'));
const firstInput = overlay.querySelector('input:not([type="hidden"]), textarea, select');
if (firstInput) {
setTimeout(() => firstInput.focus(), 50);
}
},
close(modalId) {
const overlay = document.getElementById(modalId);
if (!overlay) return;
overlay.classList.remove('active');
setTimeout(() => { overlay.hidden = true; }, 200);
const form = overlay.querySelector('form');
if (form) form.reset();
},
closeAll() {
document.querySelectorAll('.modal-overlay').forEach((overlay) => {
if (!overlay.hidden) {
overlay.classList.remove('active');
setTimeout(() => { overlay.hidden = true; }, 200);
const form = overlay.querySelector('form');
if (form) form.reset();
}
});
},
confirm(title, message) {
return new Promise((resolve) => {
Modal._confirmResolve = resolve;
const titleEl = document.getElementById('confirm-modal-title');
const messageEl = document.getElementById('confirm-modal-message');
if (titleEl) titleEl.textContent = title;
if (messageEl) messageEl.textContent = message;
Modal.open('confirm-modal-overlay');
});
},
_resolveConfirm(result) {
Modal.close('confirm-modal-overlay');
if (Modal._confirmResolve) {
Modal._confirmResolve(result);
Modal._confirmResolve = null;
}
},
_setupListeners() {
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-overlay')) {
const modalId = e.target.id;
if (modalId === 'confirm-modal-overlay') {
Modal._resolveConfirm(false);
} else {
Modal.close(modalId);
}
return;
}
const closeBtn = e.target.closest('[data-modal-close]');
if (closeBtn) {
const targetId = closeBtn.dataset.modalClose;
if (targetId === 'confirm-modal-overlay') {
Modal._resolveConfirm(false);
} else {
Modal.close(targetId);
}
}
});
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const activeModal = document.querySelector('.modal-overlay.active');
if (!activeModal) return;
if (activeModal.id === 'confirm-modal-overlay') {
Modal._resolveConfirm(false);
} else {
Modal.close(activeModal.id);
}
});
const confirmBtn = document.getElementById('confirm-modal-confirm-btn');
if (confirmBtn) confirmBtn.addEventListener('click', () => Modal._resolveConfirm(true));
},
};
document.addEventListener('DOMContentLoaded', () => Modal._setupListeners());
window.Modal = Modal;

View File

@@ -0,0 +1,361 @@
const PipelinesUI = {
pipelines: [],
agents: [],
_editingId: null,
_steps: [],
async load() {
try {
const [pipelines, agents] = await Promise.all([
API.pipelines.list(),
API.agents.list(),
]);
PipelinesUI.pipelines = Array.isArray(pipelines) ? pipelines : [];
PipelinesUI.agents = Array.isArray(agents) ? agents : [];
PipelinesUI.render();
} catch (err) {
Toast.error(`Erro ao carregar pipelines: ${err.message}`);
}
},
render() {
const grid = document.getElementById('pipelines-grid');
if (!grid) return;
const existingCards = grid.querySelectorAll('.pipeline-card');
existingCards.forEach((c) => c.remove());
const emptyState = grid.querySelector('.empty-state');
if (PipelinesUI.pipelines.length === 0) {
if (!emptyState) {
grid.insertAdjacentHTML('beforeend', PipelinesUI.renderEmpty());
}
if (window.lucide) lucide.createIcons({ nodes: [grid] });
return;
}
if (emptyState) emptyState.remove();
const fragment = document.createDocumentFragment();
PipelinesUI.pipelines.forEach((pipeline) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = PipelinesUI.renderCard(pipeline);
fragment.appendChild(wrapper.firstElementChild);
});
grid.appendChild(fragment);
if (window.lucide) lucide.createIcons({ nodes: [grid] });
},
renderEmpty() {
return `
<div class="empty-state">
<div class="empty-state-icon">
<i data-lucide="git-merge"></i>
</div>
<h3 class="empty-state-title">Nenhum pipeline cadastrado</h3>
<p class="empty-state-desc">Crie seu primeiro pipeline para encadear agentes em fluxos automatizados.</p>
<button class="btn btn--primary btn--icon-text" type="button" id="pipelines-empty-new-btn">
<i data-lucide="plus"></i>
<span>Criar Pipeline</span>
</button>
</div>
`;
},
renderCard(pipeline) {
const steps = Array.isArray(pipeline.steps) ? pipeline.steps : [];
const stepCount = steps.length;
const flowHtml = steps.map((step, index) => {
const agentName = step.agentName || step.agentId || 'Agente';
const isLast = index === steps.length - 1;
return `
<span class="pipeline-step-badge">
<span class="pipeline-step-number">${index + 1}</span>
${agentName}
</span>
${!isLast ? '<span class="pipeline-flow-arrow">→</span>' : ''}
`;
}).join('');
return `
<div class="agent-card pipeline-card" data-pipeline-id="${pipeline.id}">
<div class="agent-card-body">
<div class="agent-card-top">
<div class="agent-info">
<h3 class="agent-name">${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>` : ''}
<div class="pipeline-flow">
${flowHtml || '<span class="agent-description">Nenhum passo configurado</span>'}
</div>
</div>
<div class="agent-actions">
<button class="btn btn-primary btn-sm" data-action="execute-pipeline" data-id="${pipeline.id}">
<i data-lucide="play"></i>
Executar
</button>
<button class="btn btn-ghost btn-sm" data-action="edit-pipeline" data-id="${pipeline.id}">
<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>
`;
},
openCreateModal() {
PipelinesUI._editingId = null;
PipelinesUI._steps = [
{ agentId: '', inputTemplate: '' },
{ agentId: '', inputTemplate: '' },
];
const titleEl = document.getElementById('pipeline-modal-title');
if (titleEl) titleEl.textContent = 'Novo Pipeline';
const idEl = document.getElementById('pipeline-form-id');
if (idEl) idEl.value = '';
const nameEl = document.getElementById('pipeline-name');
if (nameEl) nameEl.value = '';
const descEl = document.getElementById('pipeline-description');
if (descEl) descEl.value = '';
PipelinesUI.renderSteps();
Modal.open('pipeline-modal-overlay');
},
async openEditModal(pipelineId) {
try {
const pipeline = await API.pipelines.get(pipelineId);
PipelinesUI._editingId = pipelineId;
PipelinesUI._steps = Array.isArray(pipeline.steps)
? pipeline.steps.map((s) => ({ agentId: s.agentId || '', inputTemplate: s.inputTemplate || '' }))
: [];
const titleEl = document.getElementById('pipeline-modal-title');
if (titleEl) titleEl.textContent = 'Editar Pipeline';
const idEl = document.getElementById('pipeline-form-id');
if (idEl) idEl.value = pipeline.id;
const nameEl = document.getElementById('pipeline-name');
if (nameEl) nameEl.value = pipeline.name || '';
const descEl = document.getElementById('pipeline-description');
if (descEl) descEl.value = pipeline.description || '';
PipelinesUI.renderSteps();
Modal.open('pipeline-modal-overlay');
} catch (err) {
Toast.error(`Erro ao carregar pipeline: ${err.message}`);
}
},
renderSteps() {
const container = document.getElementById('pipeline-steps-container');
if (!container) return;
if (PipelinesUI._steps.length === 0) {
container.innerHTML = '';
return;
}
const agentOptions = PipelinesUI.agents
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`)
.join('');
container.innerHTML = PipelinesUI._steps.map((step, index) => {
const isFirst = index === 0;
const isLast = index === PipelinesUI._steps.length - 1;
const connectorHtml = !isLast
? '<div class="pipeline-step-connector"><i data-lucide="arrow-down" style="width:14px;height:14px"></i></div>'
: '';
return `
<div class="pipeline-step-row" data-step-index="${index}">
<span class="pipeline-step-number-lg">${index + 1}</span>
<div class="pipeline-step-content">
<select class="select" data-step-field="agentId" data-step-index="${index}">
<option value="">Selecionar agente...</option>
${agentOptions}
</select>
<textarea
class="textarea"
rows="2"
placeholder="{{input}} será substituído pelo output anterior"
data-step-field="inputTemplate"
data-step-index="${index}"
>${step.inputTemplate || ''}</textarea>
</div>
<div class="pipeline-step-actions">
<button class="btn btn-ghost btn-icon btn-sm" type="button" data-step-action="move-up" data-step-index="${index}" title="Mover para cima" ${isFirst ? 'disabled' : ''}>
<i data-lucide="chevron-up" style="width:14px;height:14px"></i>
</button>
<button class="btn btn-ghost btn-icon btn-sm" type="button" data-step-action="move-down" data-step-index="${index}" title="Mover para baixo" ${isLast ? 'disabled' : ''}>
<i data-lucide="chevron-down" style="width:14px;height:14px"></i>
</button>
<button class="btn btn-ghost btn-icon btn-sm btn-danger" type="button" data-step-action="remove" data-step-index="${index}" title="Remover passo">
<i data-lucide="x" style="width:14px;height:14px"></i>
</button>
</div>
</div>
${connectorHtml}
`;
}).join('');
container.querySelectorAll('select[data-step-field="agentId"]').forEach((select) => {
const index = parseInt(select.dataset.stepIndex, 10);
select.value = PipelinesUI._steps[index].agentId || '';
});
if (window.lucide) lucide.createIcons({ nodes: [container] });
},
_syncStepsFromDOM() {
const container = document.getElementById('pipeline-steps-container');
if (!container) return;
container.querySelectorAll('[data-step-field]').forEach((el) => {
const index = parseInt(el.dataset.stepIndex, 10);
const field = el.dataset.stepField;
if (PipelinesUI._steps[index] !== undefined) {
PipelinesUI._steps[index][field] = el.value;
}
});
},
addStep() {
PipelinesUI._syncStepsFromDOM();
PipelinesUI._steps.push({ agentId: '', inputTemplate: '' });
PipelinesUI.renderSteps();
},
removeStep(index) {
PipelinesUI._syncStepsFromDOM();
PipelinesUI._steps.splice(index, 1);
PipelinesUI.renderSteps();
},
moveStep(index, direction) {
PipelinesUI._syncStepsFromDOM();
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= PipelinesUI._steps.length) return;
const temp = PipelinesUI._steps[index];
PipelinesUI._steps[index] = PipelinesUI._steps[targetIndex];
PipelinesUI._steps[targetIndex] = temp;
PipelinesUI.renderSteps();
},
async save() {
PipelinesUI._syncStepsFromDOM();
const name = document.getElementById('pipeline-name')?.value.trim();
if (!name) {
Toast.warning('Nome do pipeline é obrigatório');
return;
}
if (PipelinesUI._steps.length < 2) {
Toast.warning('O pipeline precisa de pelo menos 2 passos');
return;
}
const invalidStep = PipelinesUI._steps.find((s) => !s.agentId);
if (invalidStep) {
Toast.warning('Todos os passos devem ter um agente selecionado');
return;
}
const data = {
name,
description: document.getElementById('pipeline-description')?.value.trim() || '',
steps: PipelinesUI._steps.map((s) => ({
agentId: s.agentId,
inputTemplate: s.inputTemplate || '',
})),
};
try {
if (PipelinesUI._editingId) {
await API.pipelines.update(PipelinesUI._editingId, data);
Toast.success('Pipeline atualizado com sucesso');
} else {
await API.pipelines.create(data);
Toast.success('Pipeline criado com sucesso');
}
Modal.close('pipeline-modal-overlay');
await PipelinesUI.load();
} catch (err) {
Toast.error(`Erro ao salvar pipeline: ${err.message}`);
}
},
async delete(pipelineId) {
const confirmed = await Modal.confirm(
'Excluir pipeline',
'Tem certeza que deseja excluir este pipeline? Esta ação não pode ser desfeita.'
);
if (!confirmed) return;
try {
await API.pipelines.delete(pipelineId);
Toast.success('Pipeline excluído com sucesso');
await PipelinesUI.load();
} catch (err) {
Toast.error(`Erro ao excluir pipeline: ${err.message}`);
}
},
execute(pipelineId) {
const pipeline = PipelinesUI.pipelines.find((p) => p.id === pipelineId);
const titleEl = document.getElementById('pipeline-execute-title');
if (titleEl) titleEl.textContent = `Executar: ${pipeline ? pipeline.name : 'Pipeline'}`;
const idEl = document.getElementById('pipeline-execute-id');
if (idEl) idEl.value = pipelineId;
const inputEl = document.getElementById('pipeline-execute-input');
if (inputEl) inputEl.value = '';
Modal.open('pipeline-execute-modal-overlay');
},
async _executeFromModal() {
const pipelineId = document.getElementById('pipeline-execute-id')?.value;
const input = document.getElementById('pipeline-execute-input')?.value.trim();
if (!input) {
Toast.warning('O input inicial é obrigatório');
return;
}
try {
await API.pipelines.execute(pipelineId, input);
Modal.close('pipeline-execute-modal-overlay');
App.navigateTo('terminal');
Toast.info('Pipeline iniciado');
} catch (err) {
Toast.error(`Erro ao executar pipeline: ${err.message}`);
}
},
};
window.PipelinesUI = PipelinesUI;

View File

@@ -0,0 +1,182 @@
const SchedulesUI = {
schedules: [],
async load() {
try {
SchedulesUI.schedules = await API.schedules.list();
SchedulesUI.render();
} catch (err) {
Toast.error(`Erro ao carregar agendamentos: ${err.message}`);
}
},
render() {
const tbody = document.getElementById('schedules-tbody');
if (!tbody) return;
if (SchedulesUI.schedules.length === 0) {
tbody.innerHTML = `
<tr class="table-empty-row">
<td colspan="6">
<div class="empty-state empty-state--inline">
<i data-lucide="clock"></i>
<span>Nenhum agendamento configurado</span>
</div>
</td>
</tr>
`;
if (window.lucide) lucide.createIcons({ nodes: [tbody] });
return;
}
tbody.innerHTML = SchedulesUI.schedules.map((schedule) => {
const cronExpr = schedule.cronExpression || schedule.cronExpr || '';
const statusClass = schedule.active ? 'badge-active' : 'badge-inactive';
const statusLabel = schedule.active ? 'Ativo' : 'Inativo';
const humanCron = SchedulesUI.cronToHuman(cronExpr);
const nextRun = schedule.nextRun
? new Date(schedule.nextRun).toLocaleString('pt-BR')
: '—';
return `
<tr>
<td>${schedule.agentName || schedule.agentId || '—'}</td>
<td class="schedule-task-cell" title="${schedule.taskDescription || ''}">${schedule.taskDescription || '—'}</td>
<td>
<span title="${cronExpr}">${humanCron}</span>
<small class="font-mono">${cronExpr}</small>
</td>
<td>${nextRun}</td>
<td><span class="badge ${statusClass}">${statusLabel}</span></td>
<td>
<button
class="btn btn-ghost btn-sm btn-danger"
data-action="delete-schedule"
data-id="${schedule.taskId}"
title="Remover agendamento"
aria-label="Remover agendamento"
>
<i data-lucide="trash-2"></i>
</button>
</td>
</tr>
`;
}).join('');
if (window.lucide) lucide.createIcons({ nodes: [tbody] });
},
async openCreateModal() {
try {
const agents = await API.agents.list();
const select = document.getElementById('schedule-agent');
if (select) {
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>`)
.join('');
}
const taskEl = document.getElementById('schedule-task');
if (taskEl) taskEl.value = '';
const cronEl = document.getElementById('schedule-cron');
if (cronEl) cronEl.value = '';
Modal.open('schedule-modal-overlay');
} catch (err) {
Toast.error(`Erro ao abrir modal de agendamento: ${err.message}`);
}
},
async save() {
const agentId = document.getElementById('schedule-agent')?.value;
const taskDescription = document.getElementById('schedule-task')?.value.trim();
const cronExpression = document.getElementById('schedule-cron')?.value.trim();
if (!agentId) {
Toast.warning('Selecione um agente');
return;
}
if (!taskDescription) {
Toast.warning('Descrição da tarefa é obrigatória');
return;
}
if (!cronExpression) {
Toast.warning('Expressão cron é obrigatória');
return;
}
try {
await API.schedules.create({ agentId, taskDescription, cronExpression });
Toast.success('Agendamento criado com sucesso');
Modal.close('schedule-modal-overlay');
await SchedulesUI.load();
} catch (err) {
Toast.error(`Erro ao criar agendamento: ${err.message}`);
}
},
async delete(taskId) {
const confirmed = await Modal.confirm(
'Remover agendamento',
'Tem certeza que deseja remover este agendamento?'
);
if (!confirmed) return;
try {
await API.schedules.delete(taskId);
Toast.success('Agendamento removido com sucesso');
await SchedulesUI.load();
} catch (err) {
Toast.error(`Erro ao remover agendamento: ${err.message}`);
}
},
cronToHuman(expression) {
if (!expression) return '—';
const presets = {
'* * * * *': 'A cada minuto',
'*/5 * * * *': 'A cada 5 minutos',
'*/10 * * * *': 'A cada 10 minutos',
'*/15 * * * *': 'A cada 15 minutos',
'*/30 * * * *': 'A cada 30 minutos',
'0 * * * *': 'A cada hora',
'0 */2 * * *': 'A cada 2 horas',
'0 */6 * * *': 'A cada 6 horas',
'0 */12 * * *': 'A cada 12 horas',
'0 0 * * *': 'Todo dia à meia-noite',
'0 9 * * *': 'Todo dia às 9h',
'0 18 * * *': 'Todo dia às 18h',
'0 0 * * 1': 'Toda segunda-feira',
'0 0 * * 1-5': 'Dias úteis à meia-noite',
'0 9 * * 1-5': 'Dias úteis às 9h',
'0 9 * * 1': 'Semanal (seg 09:00)',
'0 0 1 * *': 'Todo primeiro do mês',
'0 0 1 1 *': 'Todo 1º de janeiro',
};
if (presets[expression]) return presets[expression];
const parts = expression.split(' ');
if (parts.length !== 5) return expression;
const [minute, hour, day, month, weekday] = parts;
if (minute.startsWith('*/')) return `A cada ${minute.slice(2)} minutos`;
if (hour.startsWith('*/') && minute === '0') return `A cada ${hour.slice(2)} horas`;
if (minute === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
return `Todo dia às ${hour.padStart(2, '0')}h`;
}
return expression;
},
};
window.SchedulesUI = SchedulesUI;

View File

@@ -0,0 +1,191 @@
const TasksUI = {
tasks: [],
async load() {
try {
TasksUI.tasks = await API.tasks.list();
TasksUI.render();
} catch (err) {
Toast.error(`Erro ao carregar tarefas: ${err.message}`);
}
},
render() {
const container = document.getElementById('tasks-grid');
const empty = document.getElementById('tasks-empty-state');
if (!container) return;
const existingCards = container.querySelectorAll('.task-card');
existingCards.forEach((c) => c.remove());
if (TasksUI.tasks.length === 0) {
if (empty) empty.style.display = 'flex';
return;
}
if (empty) empty.style.display = 'none';
const fragment = document.createDocumentFragment();
TasksUI.tasks.forEach((task) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = TasksUI._renderCard(task);
fragment.appendChild(wrapper.firstElementChild);
});
container.appendChild(fragment);
if (window.lucide) lucide.createIcons({ nodes: [container] });
},
_renderCard(task) {
const categoryClass = TasksUI._categoryClass(task.category);
const categoryLabel = task.category || 'Geral';
const createdAt = TasksUI._formatDate(task.createdAt);
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>
</div>
${task.description ? `<p class="task-card-description">${task.description}</p>` : ''}
<div class="task-card-footer">
<span class="task-card-date">
<i data-lucide="calendar"></i>
${createdAt}
</span>
<div class="task-card-actions">
<button class="btn btn--ghost btn--sm" data-action="edit-task" data-id="${task.id}" title="Editar tarefa">
<i data-lucide="pencil"></i>
</button>
<button class="btn btn--ghost btn--sm btn--danger" data-action="delete-task" data-id="${task.id}" title="Excluir tarefa">
<i data-lucide="trash-2"></i>
</button>
</div>
</div>
</div>
`;
},
openCreateModal() {
const container = document.getElementById('tasks-grid');
if (!container) return;
const existing = document.getElementById('task-inline-form');
if (existing) {
existing.remove();
return;
}
const formHtml = `
<div class="task-card task-card--form" id="task-inline-form">
<div class="form-group">
<label class="form-label" for="task-inline-name">Nome da tarefa *</label>
<input type="text" id="task-inline-name" class="input" placeholder="Ex: Code Review de PR" required autocomplete="off">
</div>
<div class="form-group">
<label class="form-label" for="task-inline-category">Categoria</label>
<select id="task-inline-category" class="select">
<option value="">Selecionar...</option>
<option value="code-review">Code Review</option>
<option value="security">Segurança</option>
<option value="refactor">Refatoração</option>
<option value="tests">Testes</option>
<option value="docs">Documentação</option>
<option value="performance">Performance</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="task-inline-description">Descrição</label>
<textarea id="task-inline-description" class="textarea" rows="2" placeholder="Descreva o objetivo desta tarefa..."></textarea>
</div>
<div class="form-actions">
<button class="btn btn--primary" id="btn-save-inline-task" type="button">Salvar</button>
<button class="btn btn--ghost" id="btn-cancel-inline-task" type="button">Cancelar</button>
</div>
</div>
`;
const empty = document.getElementById('tasks-empty-state');
if (empty) empty.style.display = 'none';
container.insertAdjacentHTML('afterbegin', formHtml);
document.getElementById('btn-save-inline-task')?.addEventListener('click', () => {
const name = document.getElementById('task-inline-name')?.value.trim();
const category = document.getElementById('task-inline-category')?.value;
const description = document.getElementById('task-inline-description')?.value.trim();
if (!name) {
Toast.warning('Nome da tarefa é obrigatório');
return;
}
TasksUI.save({ name, category, description });
});
document.getElementById('btn-cancel-inline-task')?.addEventListener('click', () => {
document.getElementById('task-inline-form')?.remove();
if (TasksUI.tasks.length === 0) {
const emptyEl = document.getElementById('tasks-empty-state');
if (emptyEl) emptyEl.style.display = 'flex';
}
});
document.getElementById('task-inline-name')?.focus();
},
async save(data) {
if (!data || !data.name) {
Toast.warning('Nome da tarefa é obrigatório');
return;
}
try {
await API.tasks.create(data);
Toast.success('Tarefa criada com sucesso');
document.getElementById('task-inline-form')?.remove();
await TasksUI.load();
} catch (err) {
Toast.error(`Erro ao salvar tarefa: ${err.message}`);
}
},
async delete(taskId) {
const confirmed = await Modal.confirm(
'Excluir tarefa',
'Tem certeza que deseja excluir esta tarefa?'
);
if (!confirmed) return;
try {
await API.tasks.delete(taskId);
Toast.success('Tarefa excluída com sucesso');
await TasksUI.load();
} catch (err) {
Toast.error(`Erro ao excluir tarefa: ${err.message}`);
}
},
_categoryClass(category) {
const map = {
'code-review': 'badge--blue',
security: 'badge--red',
refactor: 'badge--purple',
tests: 'badge--green',
docs: 'badge--gray',
performance: 'badge--orange',
};
return map[(category || '').toLowerCase()] || 'badge--gray';
},
_formatDate(isoString) {
if (!isoString) return '—';
return new Date(isoString).toLocaleDateString('pt-BR');
},
};
window.TasksUI = TasksUI;

View File

@@ -0,0 +1,108 @@
const Terminal = {
lines: [],
maxLines: 1000,
autoScroll: true,
executionFilter: null,
_processingInterval: null,
addLine(content, type = 'default') {
const time = new Date();
const formatted = time.toTimeString().slice(0, 8);
Terminal.lines.push({ content, type, timestamp: formatted });
if (Terminal.lines.length > Terminal.maxLines) {
Terminal.lines.shift();
}
Terminal.render();
},
startProcessing(agentName) {
Terminal.stopProcessing();
Terminal.addLine(`Agente "${agentName}" processando tarefa...`, 'system');
let dots = 0;
Terminal._processingInterval = setInterval(() => {
dots = (dots + 1) % 4;
const indicator = document.getElementById('terminal-processing');
if (indicator) {
indicator.textContent = 'Processando' + '.'.repeat(dots + 1);
}
}, 500);
Terminal.render();
},
stopProcessing() {
if (Terminal._processingInterval) {
clearInterval(Terminal._processingInterval);
Terminal._processingInterval = null;
}
},
clear() {
Terminal.stopProcessing();
Terminal.lines = [];
Terminal.executionFilter = null;
Terminal.render();
},
setExecutionFilter(executionId) {
Terminal.executionFilter = executionId;
Terminal.render();
},
scrollToBottom() {
const output = document.getElementById('terminal-output');
if (output) output.scrollTop = output.scrollHeight;
},
render() {
const output = document.getElementById('terminal-output');
if (!output) return;
const lines = Terminal.executionFilter
? Terminal.lines.filter((l) => l.executionId === Terminal.executionFilter)
: Terminal.lines;
if (lines.length === 0 && !Terminal._processingInterval) {
output.innerHTML = `
<div class="terminal-welcome">
<span class="terminal-prompt">$</span>
<span class="terminal-text">Aguardando execução de agente...</span>
</div>`;
return;
}
const html = lines.map((line) => {
const typeClass = line.type && line.type !== 'default' ? ' ' + line.type : '';
const escaped = Terminal._escapeHtml(line.content);
const formatted = escaped.replace(/\n/g, '<br>');
return `<div class="terminal-line${typeClass}">
<span class="timestamp">${line.timestamp}</span>
<span class="content">${formatted}</span>
</div>`;
}).join('');
const processing = Terminal._processingInterval
? '<div class="terminal-line system"><span class="terminal-processing-indicator"><span id="terminal-processing" class="processing-dots">Processando...</span><span class="terminal-spinner"></span></span></div>'
: '';
output.innerHTML = html + processing + '<span class="terminal-cursor blink">_</span>';
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

@@ -0,0 +1,66 @@
const Toast = {
iconMap: {
success: 'check-circle',
error: 'x-circle',
info: 'info',
warning: 'alert-triangle',
},
colorMap: {
success: 'toast-success',
error: 'toast-error',
info: 'toast-info',
warning: 'toast-warning',
},
show(message, type = 'info', duration = 4000) {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
const iconName = Toast.iconMap[type] || 'info';
toast.innerHTML = `
<span class="toast-icon" data-lucide="${iconName}"></span>
<span class="toast-message">${message}</span>
<button class="toast-close" aria-label="Fechar notificação">
<i data-lucide="x"></i>
</button>
`;
const closeBtn = toast.querySelector('.toast-close');
closeBtn.addEventListener('click', () => Toast.dismiss(toast));
container.appendChild(toast);
if (window.lucide) {
lucide.createIcons({ nodes: [toast] });
}
requestAnimationFrame(() => {
toast.classList.add('toast-show');
});
if (duration > 0) {
setTimeout(() => Toast.dismiss(toast), duration);
}
return toast;
},
dismiss(toast) {
toast.classList.remove('toast-show');
toast.classList.add('removing');
toast.addEventListener('animationend', () => toast.remove(), { once: true });
setTimeout(() => toast.remove(), 400);
},
success(message, duration) { return Toast.show(message, 'success', duration); },
error(message, duration) { return Toast.show(message, 'error', duration); },
info(message, duration) { return Toast.show(message, 'info', duration); },
warning(message, duration) { return Toast.show(message, 'warning', duration); },
};
window.Toast = Toast;

72
server.js Normal file
View File

@@ -0,0 +1,72 @@
import express from 'express';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import apiRouter, { setWsBroadcast } from './src/routes/api.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3000;
const app = express();
const httpServer = createServer(app);
const wss = new WebSocketServer({ server: httpServer });
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
app.use(express.json());
app.use(express.static(join(__dirname, 'public')));
app.use('/api', apiRouter);
const connectedClients = new Set();
wss.on('connection', (ws) => {
connectedClients.add(ws);
ws.on('close', () => {
connectedClients.delete(ws);
});
ws.on('error', () => {
connectedClients.delete(ws);
});
});
function broadcast(message) {
const payload = JSON.stringify(message);
for (const client of connectedClients) {
if (client.readyState === 1) {
client.send(payload);
}
}
}
setWsBroadcast(broadcast);
function gracefulShutdown(signal) {
console.log(`\nSinal ${signal} recebido. Encerrando servidor...`);
httpServer.close(() => {
console.log('Servidor HTTP encerrado.');
process.exit(0);
});
setTimeout(() => {
console.error('Forçando encerramento após timeout.');
process.exit(1);
}, 10000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
httpServer.listen(PORT, () => {
console.log(`Painel administrativo disponível em http://localhost:${PORT}`);
console.log(`WebSocket server ativo na mesma porta.`);
});

189
src/agents/executor.js Normal file
View File

@@ -0,0 +1,189 @@
import { spawn } from 'child_process';
import { existsSync } from 'fs';
import { v4 as uuidv4 } from 'uuid';
const CLAUDE_BIN = '/home/fred/.local/bin/claude';
const DEFAULT_MODEL = 'claude-sonnet-4-6';
const activeExecutions = new Map();
function cleanEnv() {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
return env;
}
function buildArgs(agentConfig, prompt) {
const model = agentConfig.model || DEFAULT_MODEL;
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--model', model];
if (agentConfig.systemPrompt) {
args.push('--system-prompt', agentConfig.systemPrompt);
}
return args;
}
function buildPrompt(task, instructions) {
const parts = [];
if (task) parts.push(task);
if (instructions) parts.push(`\nInstruções adicionais:\n${instructions}`);
return parts.join('\n');
}
function parseStreamLine(line) {
const trimmed = line.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch {
return { type: 'text', content: trimmed };
}
}
function extractText(event) {
if (!event) return null;
if (event.type === 'assistant' && event.message?.content) {
return event.message.content
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('');
}
if (event.type === 'content_block_delta' && event.delta?.text) {
return event.delta.text;
}
if (event.type === 'content_block_start' && event.content_block?.text) {
return event.content_block.text;
}
if (event.type === 'result') {
if (typeof event.result === 'string') return event.result;
if (event.result?.content) {
return event.result.content
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('');
}
}
if (event.type === 'text') return event.content || null;
return null;
}
export function execute(agentConfig, task, callbacks = {}) {
const executionId = uuidv4();
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}`);
console.log(`[executor] Modelo: ${agentConfig.model || DEFAULT_MODEL}`);
console.log(`[executor] cwd: ${spawnOptions.cwd || process.cwd()}`);
const child = spawn(CLAUDE_BIN, args, spawnOptions);
let hadError = false;
activeExecutions.set(executionId, {
process: child,
agentConfig,
task,
startedAt: new Date().toISOString(),
executionId,
});
let outputBuffer = '';
let errorBuffer = '';
let fullText = '';
child.stdout.on('data', (chunk) => {
const raw = chunk.toString();
const lines = (outputBuffer + raw).split('\n');
outputBuffer = lines.pop();
for (const line of lines) {
const parsed = parseStreamLine(line);
if (!parsed) continue;
const text = extractText(parsed);
if (text) {
fullText += text;
if (onData) onData({ type: 'chunk', content: text }, executionId);
}
}
});
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) => {
console.log(`[executor][close] code=${code} hadError=${hadError}`);
activeExecutions.delete(executionId);
if (hadError) return;
if (outputBuffer.trim()) {
const parsed = parseStreamLine(outputBuffer);
if (parsed) {
const text = extractText(parsed);
if (text) fullText += text;
}
}
if (onComplete) {
onComplete(
{
executionId,
exitCode: code,
result: fullText,
stderr: errorBuffer,
},
executionId,
);
}
});
return executionId;
}
export function cancel(executionId) {
const execution = activeExecutions.get(executionId);
if (!execution) return false;
execution.process.kill('SIGTERM');
activeExecutions.delete(executionId);
return true;
}
export function getActiveExecutions() {
return Array.from(activeExecutions.entries()).map(([id, exec]) => ({
executionId: id,
startedAt: exec.startedAt,
agentConfig: exec.agentConfig,
}));
}

185
src/agents/manager.js Normal file
View File

@@ -0,0 +1,185 @@
import { v4 as uuidv4 } from 'uuid';
import { agentsStore } from '../store/db.js';
import * as executor from './executor.js';
import * as scheduler from './scheduler.js';
const DEFAULT_CONFIG = {
model: 'claude-sonnet-4-6',
systemPrompt: '',
workingDirectory: '',
maxTokens: 16000,
temperature: 1,
};
function validateAgent(data) {
const errors = [];
if (!data.agent_name || typeof data.agent_name !== 'string') {
errors.push('agent_name é obrigatório e deve ser uma string');
}
if (data.config?.model && typeof data.config.model !== 'string') {
errors.push('config.model deve ser uma string');
}
return errors;
}
export function getAllAgents() {
return agentsStore.getAll();
}
export function getAgentById(id) {
return agentsStore.getById(id);
}
export function createAgent(data) {
const errors = validateAgent(data);
if (errors.length > 0) {
throw new Error(errors.join('; '));
}
const agentData = {
agent_name: data.agent_name,
description: data.description || '',
tasks: data.tasks || [],
config: { ...DEFAULT_CONFIG, ...(data.config || {}) },
status: data.status || 'active',
assigned_host: data.assigned_host || 'localhost',
executions: [],
};
return agentsStore.create(agentData);
}
export function updateAgent(id, data) {
const existing = agentsStore.getById(id);
if (!existing) return null;
const updateData = {};
if (data.agent_name !== undefined) updateData.agent_name = data.agent_name;
if (data.description !== undefined) updateData.description = data.description;
if (data.tasks !== undefined) updateData.tasks = data.tasks;
if (data.status !== undefined) updateData.status = data.status;
if (data.assigned_host !== undefined) updateData.assigned_host = data.assigned_host;
if (data.config !== undefined) {
updateData.config = { ...existing.config, ...data.config };
}
return agentsStore.update(id, updateData);
}
export function deleteAgent(id) {
return agentsStore.delete(id);
}
export function executeTask(agentId, task, instructions, wsCallback) {
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 executionRecord = {
executionId: null,
agentId,
task: typeof task === 'string' ? task : task.description,
startedAt: new Date().toISOString(),
status: 'running',
};
const executionId = executor.execute(
agent.config,
{ description: task, instructions },
{
onData: (parsed, execId) => {
if (wsCallback) {
wsCallback({
type: 'execution_output',
executionId: execId,
agentId,
data: parsed,
});
}
},
onError: (err, execId) => {
updateAgentExecution(agentId, execId, { status: 'error', error: err.message, endedAt: new Date().toISOString() });
if (wsCallback) {
wsCallback({
type: 'execution_error',
executionId: execId,
agentId,
data: { error: err.message },
});
}
},
onComplete: (result, execId) => {
updateAgentExecution(agentId, execId, { status: 'completed', result, endedAt: new Date().toISOString() });
if (wsCallback) {
wsCallback({
type: 'execution_complete',
executionId: execId,
agentId,
data: result,
});
}
},
}
);
executionRecord.executionId = executionId;
const updatedAgent = agentsStore.getById(agentId);
const executions = [...(updatedAgent.executions || []), executionRecord];
agentsStore.update(agentId, { executions: executions.slice(-100) });
return executionId;
}
function updateAgentExecution(agentId, executionId, updates) {
const agent = agentsStore.getById(agentId);
if (!agent) return;
const executions = (agent.executions || []).map((exec) => {
if (exec.executionId === executionId) {
return { ...exec, ...updates };
}
return exec;
});
agentsStore.update(agentId, { executions });
}
export function scheduleTask(agentId, taskDescription, cronExpression, wsCallback) {
const agent = agentsStore.getById(agentId);
if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
const scheduleId = uuidv4();
scheduler.schedule(scheduleId, cronExpression, () => {
executeTask(agentId, taskDescription, null, wsCallback);
});
return { scheduleId, agentId, taskDescription, cronExpression };
}
export function cancelExecution(executionId) {
return executor.cancel(executionId);
}
export function getActiveExecutions() {
return executor.getActiveExecutions();
}
export function exportAgent(agentId) {
const agent = agentsStore.getById(agentId);
if (!agent) return null;
return {
id: agent.id,
agent_name: agent.agent_name,
description: agent.description,
tasks: agent.tasks,
config: agent.config,
status: agent.status,
assigned_host: agent.assigned_host,
created_at: agent.created_at,
updated_at: agent.updated_at,
executions: agent.executions || [],
};
}

204
src/agents/pipeline.js Normal file
View File

@@ -0,0 +1,204 @@
import { v4 as uuidv4 } from 'uuid';
import { pipelinesStore } from '../store/db.js';
import { agentsStore } from '../store/db.js';
import * as executor from './executor.js';
const activePipelines = new Map();
function validatePipeline(data) {
const errors = [];
if (!data.name || typeof data.name !== 'string') {
errors.push('name é obrigatório e deve ser uma string');
}
if (!Array.isArray(data.steps) || data.steps.length === 0) {
errors.push('steps é obrigatório e deve ser um array não vazio');
} else {
data.steps.forEach((step, index) => {
if (!step.agentId) errors.push(`steps[${index}].agentId é obrigatório`);
});
}
return errors;
}
function buildSteps(steps) {
return steps
.map((step, index) => ({
id: step.id || uuidv4(),
agentId: step.agentId,
order: step.order !== undefined ? step.order : index,
inputTemplate: step.inputTemplate || null,
description: step.description || '',
}))
.sort((a, b) => a.order - b.order);
}
function applyTemplate(template, input) {
if (!template) return input;
return template.replace(/\{\{input\}\}/g, input);
}
function executeStepAsPromise(agentConfig, prompt, pipelineState) {
return new Promise((resolve, reject) => {
const executionId = executor.execute(
agentConfig,
{ description: prompt },
{
onData: () => {},
onError: (err) => {
reject(err);
},
onComplete: (result) => {
resolve(result.result || '');
},
}
);
pipelineState.currentExecutionId = executionId;
});
}
export async function executePipeline(pipelineId, initialInput, wsCallback) {
const pipeline = pipelinesStore.getById(pipelineId);
if (!pipeline) throw new Error(`Pipeline ${pipelineId} não encontrado`);
const pipelineState = {
currentExecutionId: null,
currentStep: 0,
canceled: false,
};
activePipelines.set(pipelineId, pipelineState);
const steps = buildSteps(pipeline.steps);
const results = [];
let currentInput = initialInput;
try {
for (let i = 0; i < steps.length; i++) {
if (pipelineState.canceled) break;
const step = steps[i];
pipelineState.currentStep = i;
const agent = agentsStore.getById(step.agentId);
if (!agent) throw new Error(`Agente ${step.agentId} não encontrado no passo ${i}`);
if (agent.status !== 'active') throw new Error(`Agente ${agent.agent_name} está inativo`);
const prompt = applyTemplate(step.inputTemplate, currentInput);
if (wsCallback) {
wsCallback({
type: 'pipeline_step_start',
pipelineId,
stepIndex: i,
stepId: step.id,
agentName: agent.agent_name,
totalSteps: steps.length,
});
}
const result = await executeStepAsPromise(agent.config, prompt, pipelineState);
if (pipelineState.canceled) break;
currentInput = result;
results.push({ stepId: step.id, agentName: agent.agent_name, result });
if (wsCallback) {
wsCallback({
type: 'pipeline_step_complete',
pipelineId,
stepIndex: i,
stepId: step.id,
result: result.slice(0, 500),
});
}
}
activePipelines.delete(pipelineId);
if (!pipelineState.canceled && wsCallback) {
wsCallback({
type: 'pipeline_complete',
pipelineId,
results,
});
}
return results;
} catch (err) {
activePipelines.delete(pipelineId);
if (wsCallback) {
wsCallback({
type: 'pipeline_error',
pipelineId,
stepIndex: pipelineState.currentStep,
error: err.message,
});
}
throw err;
}
}
export function cancelPipeline(pipelineId) {
const state = activePipelines.get(pipelineId);
if (!state) return false;
state.canceled = true;
if (state.currentExecutionId) {
executor.cancel(state.currentExecutionId);
}
activePipelines.delete(pipelineId);
return true;
}
export function getActivePipelines() {
return Array.from(activePipelines.entries()).map(([id, state]) => ({
pipelineId: id,
currentStep: state.currentStep,
currentExecutionId: state.currentExecutionId,
}));
}
export function createPipeline(data) {
const errors = validatePipeline(data);
if (errors.length > 0) throw new Error(errors.join('; '));
const pipelineData = {
name: data.name,
description: data.description || '',
steps: buildSteps(data.steps),
status: data.status || 'active',
};
return pipelinesStore.create(pipelineData);
}
export function updatePipeline(id, data) {
const existing = pipelinesStore.getById(id);
if (!existing) return null;
const updateData = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.status !== undefined) updateData.status = data.status;
if (data.steps !== undefined) updateData.steps = buildSteps(data.steps);
return pipelinesStore.update(id, updateData);
}
export function deletePipeline(id) {
return pipelinesStore.delete(id);
}
export function getPipeline(id) {
return pipelinesStore.getById(id);
}
export function getAllPipelines() {
return pipelinesStore.getAll();
}

84
src/agents/scheduler.js Normal file
View File

@@ -0,0 +1,84 @@
import cron from 'node-cron';
import { EventEmitter } from 'events';
const HISTORY_LIMIT = 50;
const schedules = new Map();
const history = [];
const emitter = new EventEmitter();
function addToHistory(entry) {
history.unshift(entry);
if (history.length > HISTORY_LIMIT) {
history.splice(HISTORY_LIMIT);
}
}
export function schedule(taskId, cronExpr, callback) {
if (schedules.has(taskId)) {
unschedule(taskId);
}
if (!cron.validate(cronExpr)) {
throw new Error(`Expressão cron inválida: ${cronExpr}`);
}
const task = cron.schedule(
cronExpr,
() => {
const firedAt = new Date().toISOString();
addToHistory({ taskId, cronExpr, firedAt });
emitter.emit('scheduled-task', { taskId, firedAt });
if (callback) callback({ taskId, firedAt });
},
{ scheduled: true }
);
schedules.set(taskId, {
taskId,
cronExpr,
task,
active: true,
createdAt: new Date().toISOString(),
});
return { taskId, cronExpr };
}
export function unschedule(taskId) {
const entry = schedules.get(taskId);
if (!entry) return false;
entry.task.stop();
schedules.delete(taskId);
return true;
}
export function setActive(taskId, active) {
const entry = schedules.get(taskId);
if (!entry) return false;
if (active) {
entry.task.start();
} else {
entry.task.stop();
}
entry.active = active;
return true;
}
export function getSchedules() {
return Array.from(schedules.values()).map(({ task: _, ...rest }) => rest);
}
export function getHistory() {
return [...history];
}
export function on(event, listener) {
emitter.on(event, listener);
}
export function off(event, listener) {
emitter.off(event, listener);
}

280
src/routes/api.js Normal file
View File

@@ -0,0 +1,280 @@
import { Router } from 'express';
import * as manager from '../agents/manager.js';
import { tasksStore } from '../store/db.js';
import * as scheduler from '../agents/scheduler.js';
import * as pipeline from '../agents/pipeline.js';
const router = Router();
let wsbroadcast = null;
export function setWsBroadcast(fn) {
wsbroadcast = fn;
}
function wsCallback(message) {
if (wsbroadcast) wsbroadcast(message);
}
router.get('/agents', (req, res) => {
try {
res.json(manager.getAllAgents());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/agents/:id', (req, res) => {
try {
const agent = manager.getAgentById(req.params.id);
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
res.json(agent);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/agents', (req, res) => {
try {
const agent = manager.createAgent(req.body);
res.status(201).json(agent);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.put('/agents/:id', (req, res) => {
try {
const agent = manager.updateAgent(req.params.id, req.body);
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
res.json(agent);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.delete('/agents/:id', (req, res) => {
try {
const deleted = manager.deleteAgent(req.params.id);
if (!deleted) return res.status(404).json({ error: 'Agente não encontrado' });
res.status(204).send();
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/agents/:id/execute', (req, res) => {
try {
const { task, instructions } = req.body;
if (!task) return res.status(400).json({ error: 'task é obrigatório' });
const executionId = manager.executeTask(req.params.id, task, instructions, wsCallback);
res.status(202).json({ executionId, status: 'started' });
} catch (err) {
const status = err.message.includes('não encontrado') ? 404 : 400;
res.status(status).json({ error: err.message });
}
});
router.post('/agents/:id/cancel/:executionId', (req, res) => {
try {
const cancelled = manager.cancelExecution(req.params.executionId);
if (!cancelled) return res.status(404).json({ error: 'Execução não encontrada ou já finalizada' });
res.json({ cancelled: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/agents/:id/export', (req, res) => {
try {
const exported = manager.exportAgent(req.params.id);
if (!exported) return res.status(404).json({ error: 'Agente não encontrado' });
res.json(exported);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/tasks', (req, res) => {
try {
res.json(tasksStore.getAll());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/tasks', (req, res) => {
try {
if (!req.body.name) return res.status(400).json({ error: 'name é obrigatório' });
const task = tasksStore.create(req.body);
res.status(201).json(task);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.put('/tasks/:id', (req, res) => {
try {
const task = tasksStore.update(req.params.id, req.body);
if (!task) return res.status(404).json({ error: 'Tarefa não encontrada' });
res.json(task);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.delete('/tasks/:id', (req, res) => {
try {
const deleted = tasksStore.delete(req.params.id);
if (!deleted) return res.status(404).json({ error: 'Tarefa não encontrada' });
res.status(204).send();
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/schedules', (req, res) => {
try {
const { agentId, taskDescription, cronExpression } = req.body;
if (!agentId || !taskDescription || !cronExpression) {
return res.status(400).json({ error: 'agentId, taskDescription e cronExpression são obrigatórios' });
}
const result = manager.scheduleTask(agentId, taskDescription, cronExpression, wsCallback);
res.status(201).json(result);
} catch (err) {
const status = err.message.includes('não encontrado') ? 404 : 400;
res.status(status).json({ error: err.message });
}
});
router.get('/schedules', (req, res) => {
try {
res.json(scheduler.getSchedules());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/schedules/:taskId', (req, res) => {
try {
const removed = scheduler.unschedule(req.params.taskId);
if (!removed) return res.status(404).json({ error: 'Agendamento não encontrado' });
res.status(204).send();
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/pipelines', (req, res) => {
try {
res.json(pipeline.getAllPipelines());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/pipelines/:id', (req, res) => {
try {
const found = pipeline.getPipeline(req.params.id);
if (!found) return res.status(404).json({ error: 'Pipeline não encontrado' });
res.json(found);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/pipelines', (req, res) => {
try {
const created = pipeline.createPipeline(req.body);
res.status(201).json(created);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.put('/pipelines/:id', (req, res) => {
try {
const updated = pipeline.updatePipeline(req.params.id, req.body);
if (!updated) return res.status(404).json({ error: 'Pipeline não encontrado' });
res.json(updated);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.delete('/pipelines/:id', (req, res) => {
try {
const deleted = pipeline.deletePipeline(req.params.id);
if (!deleted) return res.status(404).json({ error: 'Pipeline não encontrado' });
res.status(204).send();
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/pipelines/:id/execute', (req, res) => {
try {
const { input } = req.body;
if (!input) return res.status(400).json({ error: 'input é obrigatório' });
pipeline.executePipeline(req.params.id, input, wsCallback).catch(() => {});
res.status(202).json({ pipelineId: req.params.id, status: 'started' });
} catch (err) {
const status = err.message.includes('não encontrado') ? 404 : 400;
res.status(status).json({ error: err.message });
}
});
router.post('/pipelines/:id/cancel', (req, res) => {
try {
const cancelled = pipeline.cancelPipeline(req.params.id);
if (!cancelled) return res.status(404).json({ error: 'Pipeline não está em execução' });
res.json({ cancelled: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/system/status', (req, res) => {
try {
const agents = manager.getAllAgents();
const activeExecutions = manager.getActiveExecutions();
const schedules = scheduler.getSchedules();
const pipelines = pipeline.getAllPipelines();
const activePipelines = pipeline.getActivePipelines();
res.json({
agents: {
total: agents.length,
active: agents.filter((a) => a.status === 'active').length,
inactive: agents.filter((a) => a.status === 'inactive').length,
},
executions: {
active: activeExecutions.length,
list: activeExecutions,
},
schedules: {
total: schedules.length,
active: schedules.filter((s) => s.active).length,
},
pipelines: {
total: pipelines.length,
active: pipelines.filter((p) => p.status === 'active').length,
running: activePipelines.length,
},
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/executions/active', (req, res) => {
try {
res.json(manager.getActiveExecutions());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

89
src/store/db.js Normal file
View File

@@ -0,0 +1,89 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { v4 as uuidv4 } from 'uuid';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_DIR = `${__dirname}/../../data`;
const AGENTS_FILE = `${DATA_DIR}/agents.json`;
const TASKS_FILE = `${DATA_DIR}/tasks.json`;
const PIPELINES_FILE = `${DATA_DIR}/pipelines.json`;
function ensureDataDir() {
if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true });
}
}
function loadFile(filePath) {
ensureDataDir();
if (!existsSync(filePath)) {
writeFileSync(filePath, JSON.stringify([]), 'utf8');
return [];
}
try {
return JSON.parse(readFileSync(filePath, 'utf8'));
} catch {
return [];
}
}
function saveFile(filePath, data) {
ensureDataDir();
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
}
function createStore(filePath) {
return {
load: () => loadFile(filePath),
save: (data) => saveFile(filePath, data),
getAll: () => loadFile(filePath),
getById: (id) => {
const items = loadFile(filePath);
return items.find((item) => item.id === id) || null;
},
create: (data) => {
const items = loadFile(filePath);
const newItem = {
id: uuidv4(),
...data,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
items.push(newItem);
saveFile(filePath, items);
return newItem;
},
update: (id, data) => {
const items = loadFile(filePath);
const index = items.findIndex((item) => item.id === id);
if (index === -1) return null;
items[index] = {
...items[index],
...data,
id,
updated_at: new Date().toISOString(),
};
saveFile(filePath, items);
return items[index];
},
delete: (id) => {
const items = loadFile(filePath);
const index = items.findIndex((item) => item.id === id);
if (index === -1) return false;
items.splice(index, 1);
saveFile(filePath, items);
return true;
},
};
}
export const agentsStore = createStore(AGENTS_FILE);
export const tasksStore = createStore(TASKS_FILE);
export const pipelinesStore = createStore(PIPELINES_FILE);