Compare commits
43 Commits
93d9027e2c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1606efa09f | ||
|
|
633b19f80d | ||
|
|
2fae816162 | ||
|
|
2201ac8699 | ||
|
|
a6bbe33e4b | ||
|
|
4c197eef91 | ||
|
|
e9f65c2845 | ||
|
|
2fccaaac40 | ||
|
|
3178366e0e | ||
|
|
fa47538a8f | ||
|
|
7a4ab2279d | ||
|
|
7cbfcb2d0d | ||
|
|
46a6ebc9dd | ||
|
|
f6bf7ce0ed | ||
|
|
96733b55cd | ||
|
|
3ed285c9d1 | ||
|
|
6a21a4d711 | ||
|
|
bbfb9864bd | ||
|
|
c29aa695d4 | ||
|
|
4db351cb45 | ||
|
|
af1c59b75c | ||
|
|
738ab12631 | ||
|
|
46f999c676 | ||
|
|
39f0902a0f | ||
|
|
fd3c2dc69a | ||
|
|
1411c750e4 | ||
|
|
1ef5903da1 | ||
|
|
d662860c61 | ||
|
|
a1d3ce707c | ||
|
|
275d74b18c | ||
|
|
38556f9bf5 | ||
|
|
972ae92291 | ||
|
|
0b5a81c3e6 | ||
|
|
64389b3cf9 | ||
|
|
a2a1aa2c7a | ||
|
|
9b66a415ff | ||
|
|
bbd2ec46dd | ||
|
|
3b10984233 | ||
|
|
9a874ad032 | ||
|
|
da22154f66 | ||
|
|
69943f91be | ||
|
|
68605d837d | ||
|
|
d7d2421fc2 |
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.git
|
||||
.env
|
||||
*.log
|
||||
70
CLAUDE.md
70
CLAUDE.md
@@ -1,70 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Sobre o Projeto
|
||||
|
||||
Painel administrativo web para orquestração de agentes Claude Code. Permite criar, configurar e executar agentes que invocam o CLI `claude` como subprocesso, com suporte a agendamento via cron e pipelines sequenciais (saída de um agente alimenta o próximo).
|
||||
|
||||
## Comandos
|
||||
|
||||
```bash
|
||||
npm start # Inicia o servidor (porta 3000)
|
||||
npm run dev # Inicia com --watch (hot reload automático)
|
||||
```
|
||||
|
||||
Não há testes, linting ou build configurados.
|
||||
|
||||
## Arquitetura
|
||||
|
||||
### Backend (Node.js + Express, ESM)
|
||||
|
||||
```
|
||||
server.js → HTTP + WebSocket (ws) na mesma porta
|
||||
src/routes/api.js → Todas as rotas REST sob /api
|
||||
src/agents/manager.js → CRUD de agentes + orquestra execuções e agendamentos
|
||||
src/agents/executor.js → Spawna o CLI claude como child_process com stream-json
|
||||
src/agents/scheduler.js → Agendamento cron via node-cron (in-memory)
|
||||
src/agents/pipeline.js → Execução sequencial de steps, cada um delegando ao executor
|
||||
src/store/db.js → Persistência em arquivos JSON (data/*.json)
|
||||
```
|
||||
|
||||
**Fluxo de execução:** API recebe POST → `manager.executeTask()` → `executor.execute()` spawna `/home/fred/.local/bin/claude` com `--output-format stream-json` → stdout é parseado linha a linha → chunks são enviados via WebSocket broadcast para o frontend.
|
||||
|
||||
**Pipelines:** Executam steps em sequência. Cada step usa um agente diferente. A saída de um step é passada como input do próximo via template `{{input}}`.
|
||||
|
||||
**Persistência:** `db.js` expõe stores (agents, tasks, pipelines) que leem/escrevem JSON em `data/`. Cada operação recarrega o arquivo inteiro. Agendamentos cron são apenas in-memory.
|
||||
|
||||
### Frontend (Vanilla JS, SPA)
|
||||
|
||||
```
|
||||
public/index.html → SPA single-page com todas as seções
|
||||
public/css/styles.css → Estilos (Inter + JetBrains Mono, Lucide icons)
|
||||
public/js/app.js → Controlador principal, navegação, WebSocket client
|
||||
public/js/api.js → Client HTTP para /api/*
|
||||
public/js/components/*.js → UI por seção (dashboard, agents, tasks, schedules, pipelines, terminal, modal, toast)
|
||||
```
|
||||
|
||||
O frontend usa objetos globais no `window` (App, API, DashboardUI, AgentsUI, etc.) sem bundler ou framework. WebSocket reconecta automaticamente com backoff exponencial.
|
||||
|
||||
### Endpoints REST
|
||||
|
||||
| Recurso | Rotas |
|
||||
|---------|-------|
|
||||
| Agentes | GET/POST `/api/agents`, GET/PUT/DELETE `/api/agents/:id`, POST `.../execute`, POST `.../cancel/:executionId`, GET `.../export` |
|
||||
| Tarefas | GET/POST `/api/tasks`, PUT/DELETE `/api/tasks/:id` |
|
||||
| Agendamentos | GET/POST `/api/schedules`, DELETE `/api/schedules/:taskId` |
|
||||
| Pipelines | GET/POST `/api/pipelines`, GET/PUT/DELETE `/api/pipelines/:id`, POST `.../execute`, POST `.../cancel` |
|
||||
| Sistema | GET `/api/system/status`, GET `/api/executions/active` |
|
||||
|
||||
### WebSocket Events
|
||||
|
||||
O servidor envia eventos tipados (`execution_output`, `execution_complete`, `execution_error`, `pipeline_step_start`, `pipeline_step_complete`, `pipeline_complete`, `pipeline_error`) que o frontend renderiza no terminal.
|
||||
|
||||
## Convenções
|
||||
|
||||
- Todo o código e mensagens em português brasileiro
|
||||
- ESM (`"type": "module"` no package.json) — usar `import`/`export`, não `require`
|
||||
- Sem TypeScript, sem bundler, sem framework frontend
|
||||
- IDs gerados com `uuid` v4
|
||||
- Modelo padrão dos agentes: `claude-sonnet-4-6`
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache git docker-cli
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
COPY . .
|
||||
RUN mkdir -p data && chown -R node:node /app
|
||||
RUN addgroup -g 984 docker 2>/dev/null; addgroup node docker 2>/dev/null || true
|
||||
USER node
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
433
README.md
433
README.md
@@ -1,148 +1,373 @@
|
||||
# Agents Orchestrator
|
||||
<p align="center">
|
||||
<img src="docs/logo.svg" alt="Agents Orchestrator" width="80" />
|
||||
</p>
|
||||
|
||||
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.
|
||||
<h1 align="center">Agents Orchestrator</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Plataforma de orquestração de agentes IA com interface visual, pipelines automatizados e integração Git nativa.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://agents.nitro-cloud.duckdns.org"><img src="https://img.shields.io/badge/demo-live-00d4aa?style=flat-square" alt="Live Demo" /></a>
|
||||
<a href="https://git.nitro-cloud.duckdns.org/fred/agents-orchestrator"><img src="https://img.shields.io/badge/gitea-repo-6c40cc?style=flat-square" alt="Gitea" /></a>
|
||||
<img src="https://img.shields.io/badge/node-%3E%3D22-339933?style=flat-square&logo=node.js&logoColor=white" alt="Node.js" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#visao-geral">Visao Geral</a> •
|
||||
<a href="#funcionalidades">Funcionalidades</a> •
|
||||
<a href="#quick-start">Quick Start</a> •
|
||||
<a href="#arquitetura">Arquitetura</a> •
|
||||
<a href="#api">API</a> •
|
||||
<a href="#deploy">Deploy</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Visao Geral
|
||||
|
||||
Agents Orchestrator e uma plataforma web para criar, configurar e executar agentes [Claude Code](https://docs.anthropic.com/en/docs/claude-code) de forma visual. Projetada para equipes de desenvolvimento e profissionais que precisam orquestrar multiplos agentes IA com diferentes especialidades, executar pipelines de trabalho automatizados e integrar com repositorios Git — tudo a partir de um painel administrativo elegante.
|
||||
|
||||
### Por que usar?
|
||||
|
||||
| Problema | Solucao |
|
||||
|----------|---------|
|
||||
| Gerenciar multiplos agentes via CLI e tedioso | Interface visual com cards, filtros e execucao com 1 clique |
|
||||
| Saida do agente nao e visivel em tempo real | Terminal com streaming WebSocket chunk-a-chunk |
|
||||
| Automatizar fluxos sequenciais e complexo | Pipelines visuais com aprovacao humana entre passos |
|
||||
| Agentes nao tem acesso a repositorios remotos | Integracao Git nativa com clone, commit e push automatico |
|
||||
| Deploy manual e propenso a erros | `git deploy` — um comando faz tudo |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
### Agentes
|
||||
|
||||
## Pré-requisitos
|
||||
- Criacao com system prompt, modelo (Sonnet/Opus/Haiku), diretorio de trabalho, ferramentas permitidas e modo de permissao
|
||||
- Tags para organizacao e filtragem
|
||||
- Duplicacao, importacao/exportacao JSON
|
||||
- Delegacao automatica entre agentes (Tech Lead → PO)
|
||||
- Agentes coordenadores recebem lista de agentes disponiveis injetada no prompt
|
||||
|
||||
- **Node.js** 18+
|
||||
- **Claude Code CLI** instalado e autenticado (`claude` disponível no PATH)
|
||||
### Execucao
|
||||
|
||||
## Instalação
|
||||
- Modal de execucao com seletor de agente, tarefa, instrucoes adicionais e arquivos de contexto
|
||||
- **Seletor de repositorio Git** — escolha um repo do Gitea e o branch; o sistema clona/atualiza, executa e faz commit/push automatico
|
||||
- Templates rapidos: deteccao de bugs, revisao OWASP, refatoracao, testes, documentacao, performance
|
||||
- Retry automatico configuravel por agente
|
||||
- Continuacao de conversa (resume session)
|
||||
- Cancelamento individual ou em massa
|
||||
|
||||
### Pipelines
|
||||
|
||||
- Encadeamento de multiplos agentes em fluxos sequenciais
|
||||
- Saida de cada passo alimenta o proximo via `{{input}}`
|
||||
- **Seletor de repositorio** — todos os passos trabalham no mesmo repo com commit automatico ao final
|
||||
- Portoes de aprovacao humana (human-in-the-loop)
|
||||
- Retomada de pipelines falhos a partir do passo onde pararam
|
||||
- Editor de fluxo visual com drag para reordenar passos
|
||||
|
||||
### Terminal
|
||||
|
||||
- Streaming em tempo real via WebSocket
|
||||
- Botao Interromper para cancelar execucoes ativas
|
||||
- Busca no output com navegacao entre ocorrencias
|
||||
- Download como `.txt` e copia para clipboard
|
||||
- Auto-scroll toggleavel
|
||||
|
||||
### Integração Git
|
||||
|
||||
- Listagem automatica de repositorios do Gitea
|
||||
- Seletor de branch dinamico
|
||||
- Clone/pull automatico antes da execucao
|
||||
- **Commit e push automatico** ao final com mensagem descritiva
|
||||
- Instrucao injetada para agentes nao fazerem operacoes git
|
||||
- Publicacao de projetos: cria repo, configura subdominio, deploy com 1 clique
|
||||
|
||||
### Explorador de Arquivos
|
||||
|
||||
- Navegacao em `/home/projetos/` com breadcrumb
|
||||
- Download de arquivos individuais ou pastas completas (.tar.gz)
|
||||
- Exclusao com confirmacao
|
||||
- Botao publicar em projetos — cria repo no Gitea, configura Caddy e faz deploy automatico em `projeto.nitro-cloud.duckdns.org`
|
||||
|
||||
### Dashboard
|
||||
|
||||
- Metricas em tempo real: agentes, execucoes, agendamentos, custo, webhooks
|
||||
- Graficos: execucoes por dia, custo diario, distribuicao de status, top 5 agentes, taxa de sucesso
|
||||
- Seletor de periodo: 7, 14 ou 30 dias
|
||||
|
||||
### Catalogo de Tarefas
|
||||
|
||||
- Tarefas reutilizaveis com nome, categoria e descricao
|
||||
- Categorias: Code Review, Seguranca, Refatoracao, Testes, Documentacao, Performance
|
||||
- Filtro por texto e categoria
|
||||
- Execucao direta a partir do catalogo
|
||||
|
||||
### Agendamento Cron
|
||||
|
||||
- Expressoes cron com presets (horario, diario, semanal, mensal)
|
||||
- Historico de execucoes por agendamento
|
||||
- Retry automatico em caso de limite de slots
|
||||
|
||||
### Webhooks
|
||||
|
||||
- Disparo de execucoes via HTTP externo
|
||||
- Edicao, teste com 1 clique e snippet cURL
|
||||
- Assinatura HMAC-SHA256
|
||||
|
||||
### Notificacoes
|
||||
|
||||
- Centro de notificacoes com badge de contagem
|
||||
- Notificacoes nativas do navegador
|
||||
- Polling automatico a cada 15 segundos
|
||||
|
||||
### Tema e UX
|
||||
|
||||
- Tema claro/escuro com transicao suave
|
||||
- Atalhos de teclado (`1`-`9` navegacao, `N` novo agente, `Esc` fechar modal)
|
||||
- Exportacao de historico como CSV
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Requisitos
|
||||
|
||||
- Node.js >= 22
|
||||
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) instalado e autenticado
|
||||
|
||||
### Execucao local
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
git clone https://github.com/fredac100/agents-orchestrator.git
|
||||
cd agents-orchestrator
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
## Uso
|
||||
Acesse `http://localhost:3000`.
|
||||
|
||||
### Com Docker
|
||||
|
||||
```bash
|
||||
# Produção
|
||||
npm start
|
||||
|
||||
# Desenvolvimento (hot reload)
|
||||
npm run dev
|
||||
docker build -t agents-orchestrator .
|
||||
docker run -p 3000:3000 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
-v ~/.claude:/home/node/.claude \
|
||||
agents-orchestrator
|
||||
```
|
||||
|
||||
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
|
||||
HTTPS (443)
|
||||
|
|
||||
[Caddy] ─── SSL automatico via DuckDNS
|
||||
|
|
||||
*.nitro-cloud.duckdns.org
|
||||
|
|
||||
┌──────────────┼──────────────┐
|
||||
| | |
|
||||
[agents.*] [git.*] [projeto.*]
|
||||
| | |
|
||||
┌──────┴──────┐ [Gitea] [Caddy file_server]
|
||||
| |
|
||||
[Express] [WebSocket]
|
||||
| |
|
||||
├── API REST (40+ endpoints)
|
||||
├── Manager (CRUD + orquestracao)
|
||||
├── Executor (spawn claude CLI)
|
||||
├── Pipeline (sequencial + aprovacao)
|
||||
├── Scheduler (cron jobs)
|
||||
├── Git Integration (clone/pull/commit/push)
|
||||
└── Store (JSON com escrita atomica)
|
||||
```
|
||||
|
||||
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.
|
||||
### Estrutura do Projeto
|
||||
|
||||
## API REST
|
||||
```
|
||||
server.js HTTP + WebSocket + rate limiting + auth
|
||||
src/
|
||||
routes/api.js API REST — 40+ endpoints
|
||||
agents/
|
||||
manager.js CRUD + orquestracao + delegacao
|
||||
executor.js Spawna o CLI claude como child_process
|
||||
scheduler.js Agendamento cron
|
||||
pipeline.js Execucao sequencial + aprovacao humana
|
||||
git-integration.js Clone, pull, commit, push automatico
|
||||
store/db.js Persistencia JSON com escrita atomica
|
||||
cache/index.js Cache L1 (memoria) + L2 (Redis opcional)
|
||||
reports/generator.js Geracao de relatorios de execucao
|
||||
public/
|
||||
app.html SPA com hash routing
|
||||
css/styles.css Design system (dark/light)
|
||||
js/
|
||||
app.js Controlador principal + WebSocket
|
||||
api.js Client HTTP para a API
|
||||
components/ 16 modulos UI independentes
|
||||
scripts/
|
||||
deploy.sh Deploy automatizado via rsync + Docker
|
||||
data/ Persistencia em JSON (8 stores)
|
||||
```
|
||||
|
||||
| Método | Endpoint | Descrição |
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### Agentes
|
||||
|
||||
| Metodo | Endpoint | Descricao |
|
||||
|--------|----------|-----------|
|
||||
| `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 |
|
||||
| `POST` | `/api/agents/:id/execute` | Executar tarefa (aceita `repoName` e `repoBranch`) |
|
||||
| `POST` | `/api/agents/:id/continue` | Continuar conversa |
|
||||
| `POST` | `/api/agents/:id/cancel/:execId` | Cancelar execucao |
|
||||
| `GET` | `/api/agents/:id/export` | Exportar agente |
|
||||
| `POST` | `/api/agents/:id/duplicate` | Duplicar agente |
|
||||
|
||||
### Pipelines
|
||||
|
||||
| Metodo | Endpoint | Descricao |
|
||||
|--------|----------|-----------|
|
||||
| `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 |
|
||||
| `POST` | `/api/pipelines/:id/execute` | Executar (aceita `repoName` e `repoBranch`) |
|
||||
| `POST` | `/api/pipelines/:id/approve` | Aprovar passo pendente |
|
||||
| `POST` | `/api/pipelines/:id/reject` | Rejeitar passo |
|
||||
| `POST` | `/api/pipelines/resume/:execId` | Retomar pipeline falho |
|
||||
|
||||
### Repositorios
|
||||
|
||||
| Metodo | Endpoint | Descricao |
|
||||
|--------|----------|-----------|
|
||||
| `GET` | `/api/repos` | Listar repositorios do Gitea |
|
||||
| `GET` | `/api/repos/:name/branches` | Listar branches de um repo |
|
||||
|
||||
### Arquivos e Publicacao
|
||||
|
||||
| Metodo | Endpoint | Descricao |
|
||||
|--------|----------|-----------|
|
||||
| `GET` | `/api/files` | Listar diretorio |
|
||||
| `GET` | `/api/files/download` | Download de arquivo |
|
||||
| `GET` | `/api/files/download-folder` | Download de pasta (.tar.gz) |
|
||||
| `DELETE` | `/api/files` | Excluir arquivo ou pasta |
|
||||
| `POST` | `/api/files/publish` | Publicar projeto (repo + deploy + subdominio) |
|
||||
|
||||
### Sistema
|
||||
|
||||
| Metodo | Endpoint | Descricao |
|
||||
|--------|----------|-----------|
|
||||
| `GET` | `/api/health` | Health check |
|
||||
| `GET` | `/api/system/status` | Status geral |
|
||||
| `GET` | `/api/stats/costs` | Estatisticas de custo |
|
||||
| `GET` | `/api/stats/charts` | Dados para graficos |
|
||||
|
||||
---
|
||||
|
||||
## Deploy
|
||||
|
||||
### Deploy automatico
|
||||
|
||||
```bash
|
||||
git deploy
|
||||
```
|
||||
|
||||
O alias executa `scripts/deploy.sh` que automaticamente:
|
||||
|
||||
1. Push para GitHub e Gitea
|
||||
2. Backup dos dados no VPS
|
||||
3. Sincronizacao via rsync
|
||||
4. Correcao de permissoes
|
||||
5. Rebuild do container Docker
|
||||
6. Verificacao de integridade
|
||||
7. Limpeza de backups antigos (mantem 3)
|
||||
|
||||
```bash
|
||||
# Apenas deploy sem push
|
||||
bash scripts/deploy.sh --skip-push
|
||||
```
|
||||
|
||||
### Variaveis de Ambiente
|
||||
|
||||
| Variavel | Descricao | Padrao |
|
||||
|----------|-----------|--------|
|
||||
| `PORT` | Porta do servidor | `3000` |
|
||||
| `HOST` | Endereco de bind | `0.0.0.0` |
|
||||
| `AUTH_TOKEN` | Bearer token para auth da API | _(desabilitado)_ |
|
||||
| `ALLOWED_ORIGIN` | Origin para CORS | `http://localhost:3000` |
|
||||
| `WEBHOOK_SECRET` | Segredo HMAC para webhooks | _(desabilitado)_ |
|
||||
| `GITEA_URL` | URL interna do Gitea | `http://gitea:3000` |
|
||||
| `GITEA_USER` | Usuario do Gitea | `fred` |
|
||||
| `GITEA_PASS` | Senha do Gitea | _(obrigatorio para Git)_ |
|
||||
| `DOMAIN` | Dominio base para subdominios | `nitro-cloud.duckdns.org` |
|
||||
| `CLAUDE_BIN` | Caminho do CLI Claude | _(auto-detectado)_ |
|
||||
| `REDIS_URL` | Redis para cache L2 | _(somente memoria)_ |
|
||||
|
||||
---
|
||||
|
||||
## Seguranca
|
||||
|
||||
- HTTPS via Caddy com certificado wildcard Let's Encrypt
|
||||
- Autenticacao Bearer token com timing-safe comparison
|
||||
- Rate limiting: 100 req/min (API), 30 req/min (webhooks)
|
||||
- CORS restrito a origin configurada
|
||||
- Correlation IDs em todas as requisicoes
|
||||
- Escrita atomica em disco (temp + rename)
|
||||
- Sanitizacao de prompts (NUL, controle, limite 50K chars)
|
||||
- HMAC-SHA256 para webhooks recebidos
|
||||
- Protecao contra path traversal no file explorer
|
||||
|
||||
---
|
||||
|
||||
## Eventos WebSocket
|
||||
|
||||
O servidor envia eventos tipados via WebSocket que o frontend renderiza no terminal:
|
||||
|
||||
| Evento | Descrição |
|
||||
| Evento | Descricao |
|
||||
|--------|-----------|
|
||||
| `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 |
|
||||
| `execution_output` | Chunk de saida do agente |
|
||||
| `execution_complete` | Execucao finalizada |
|
||||
| `execution_error` | Erro durante execucao |
|
||||
| `execution_retry` | Tentativa de retry |
|
||||
| `pipeline_step_start` | Inicio de passo |
|
||||
| `pipeline_step_complete` | Passo concluido |
|
||||
| `pipeline_complete` | Pipeline finalizado |
|
||||
| `pipeline_error` | Erro em um passo do pipeline |
|
||||
| `pipeline_error` | Erro no pipeline |
|
||||
| `pipeline_approval_required` | Aguardando aprovacao humana |
|
||||
| `report_generated` | Relatorio gerado |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
| Camada | Tecnologias |
|
||||
|--------|-------------|
|
||||
| **Backend** | Node.js 22, Express, WebSocket (ws), node-cron, uuid |
|
||||
| **Frontend** | HTML, CSS, JavaScript vanilla — sem framework, sem bundler |
|
||||
| **Graficos** | Chart.js 4.x |
|
||||
| **Icones** | Lucide |
|
||||
| **Fontes** | Inter (UI), JetBrains Mono (terminal) |
|
||||
| **Persistencia** | JSON em disco com escrita atomica |
|
||||
| **Cache** | In-memory + Redis opcional (ioredis) |
|
||||
| **Infra** | Docker, Caddy, DuckDNS, Let's Encrypt |
|
||||
| **Git** | Gitea (self-hosted) |
|
||||
|
||||
## Licença
|
||||
---
|
||||
|
||||
## Licenca
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>Desenvolvido por <a href="https://nitro-cloud.duckdns.org">Nitro Cloud</a></sub>
|
||||
</p>
|
||||
|
||||
BIN
docs/dashboard.png
Normal file
BIN
docs/dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
230
package-lock.json
generated
230
package-lock.json
generated
@@ -1,14 +1,18 @@
|
||||
{
|
||||
"name": "agents-orchestrator",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agents-orchestrator",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"compression": "^1.8.1",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.3",
|
||||
"uuid": "^10.0.0",
|
||||
"ws": "^8.18.0"
|
||||
@@ -27,6 +31,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -57,6 +67,23 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -95,6 +122,60 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/compressible": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": ">= 1.43.0 < 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/negotiator": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -279,6 +360,24 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -397,6 +496,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -435,6 +543,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -513,12 +630,51 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -549,6 +705,15 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -573,6 +738,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -640,6 +814,20 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -798,6 +986,23 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -820,6 +1025,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
@@ -829,6 +1040,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@@ -880,6 +1097,15 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,13 @@
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"compression": "^1.8.1",
|
||||
"express": "^4.21.0",
|
||||
"ws": "^8.18.0",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.3",
|
||||
"uuid": "^10.0.0"
|
||||
"uuid": "^10.0.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
1444
public/app.html
Normal file
1444
public/app.html
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1598
public/index.html
1598
public/index.html
File diff suppressed because it is too large
Load Diff
@@ -38,11 +38,29 @@ const API = {
|
||||
create(data) { return API.request('POST', '/agents', data); },
|
||||
update(id, data) { return API.request('PUT', `/agents/${id}`, data); },
|
||||
delete(id) { return API.request('DELETE', `/agents/${id}`); },
|
||||
execute(id, task, instructions) { return API.request('POST', `/agents/${id}/execute`, { task, instructions }); },
|
||||
execute(id, task, instructions, contextFiles, workingDirectory, repoName, repoBranch) {
|
||||
const body = { task, instructions };
|
||||
if (repoName) { body.repoName = repoName; if (repoBranch) body.repoBranch = repoBranch; }
|
||||
else if (workingDirectory) body.workingDirectory = workingDirectory;
|
||||
if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
|
||||
return API.request('POST', `/agents/${id}/execute`, body);
|
||||
},
|
||||
cancel(id, executionId) { return API.request('POST', `/agents/${id}/cancel/${executionId}`); },
|
||||
continue(id, sessionId, message) { return API.request('POST', `/agents/${id}/continue`, { sessionId, message }); },
|
||||
export(id) { return API.request('GET', `/agents/${id}/export`); },
|
||||
import(data) { return API.request('POST', '/agents/import', data); },
|
||||
duplicate(id) { return API.request('POST', `/agents/${id}/duplicate`); },
|
||||
},
|
||||
|
||||
secrets: {
|
||||
list(agentId) { return API.request('GET', `/agents/${agentId}/secrets`); },
|
||||
create(agentId, data) { return API.request('POST', `/agents/${agentId}/secrets`, data); },
|
||||
delete(agentId, name) { return API.request('DELETE', `/agents/${agentId}/secrets/${encodeURIComponent(name)}`); },
|
||||
},
|
||||
|
||||
versions: {
|
||||
list(agentId) { return API.request('GET', `/agents/${agentId}/versions`); },
|
||||
restore(agentId, version) { return API.request('POST', `/agents/${agentId}/versions/${version}/restore`); },
|
||||
},
|
||||
|
||||
tasks: {
|
||||
@@ -66,14 +84,17 @@ const API = {
|
||||
create(data) { return API.request('POST', '/pipelines', data); },
|
||||
update(id, data) { return API.request('PUT', `/pipelines/${id}`, data); },
|
||||
delete(id) { return API.request('DELETE', `/pipelines/${id}`); },
|
||||
execute(id, input, workingDirectory) {
|
||||
execute(id, input, workingDirectory, contextFiles, repoName, repoBranch) {
|
||||
const body = { input };
|
||||
if (workingDirectory) body.workingDirectory = workingDirectory;
|
||||
if (repoName) { body.repoName = repoName; if (repoBranch) body.repoBranch = repoBranch; }
|
||||
else if (workingDirectory) body.workingDirectory = workingDirectory;
|
||||
if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
|
||||
return API.request('POST', `/pipelines/${id}/execute`, body);
|
||||
},
|
||||
cancel(id) { return API.request('POST', `/pipelines/${id}/cancel`); },
|
||||
approve(id) { return API.request('POST', `/pipelines/${id}/approve`); },
|
||||
reject(id) { return API.request('POST', `/pipelines/${id}/reject`); },
|
||||
resume(executionId) { return API.request('POST', `/pipelines/resume/${executionId}`); },
|
||||
},
|
||||
|
||||
webhooks: {
|
||||
@@ -81,16 +102,26 @@ const API = {
|
||||
create(data) { return API.request('POST', '/webhooks', data); },
|
||||
update(id, data) { return API.request('PUT', `/webhooks/${id}`, data); },
|
||||
delete(id) { return API.request('DELETE', `/webhooks/${id}`); },
|
||||
test(id) { return API.request('POST', `/webhooks/${id}/test`); },
|
||||
},
|
||||
|
||||
stats: {
|
||||
costs(days) { return API.request('GET', `/stats/costs${days ? '?days=' + days : ''}`); },
|
||||
charts(days) { return API.request('GET', `/stats/charts${days ? '?days=' + days : ''}`); },
|
||||
},
|
||||
|
||||
notifications: {
|
||||
list() { return API.request('GET', '/notifications'); },
|
||||
markRead(id) { return API.request('POST', `/notifications/${id}/read`); },
|
||||
markAllRead() { return API.request('POST', '/notifications/read-all'); },
|
||||
clear() { return API.request('DELETE', '/notifications'); },
|
||||
},
|
||||
|
||||
system: {
|
||||
status() { return API.request('GET', '/system/status'); },
|
||||
info() { return API.request('GET', '/system/info'); },
|
||||
activeExecutions() { return API.request('GET', '/executions/active'); },
|
||||
cancelAll() { return API.request('POST', '/executions/cancel-all'); },
|
||||
},
|
||||
|
||||
settings: {
|
||||
@@ -98,6 +129,38 @@ const API = {
|
||||
save(data) { return API.request('PUT', '/settings', data); },
|
||||
},
|
||||
|
||||
uploads: {
|
||||
async send(files) {
|
||||
const form = new FormData();
|
||||
for (const f of files) form.append('files', f);
|
||||
const response = await fetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Client-Id': API.clientId },
|
||||
body: form,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Erro no upload');
|
||||
return data;
|
||||
},
|
||||
},
|
||||
|
||||
repos: {
|
||||
list() { return API.request('GET', '/repos'); },
|
||||
branches(name) { return API.request('GET', `/repos/${encodeURIComponent(name)}/branches`); },
|
||||
},
|
||||
|
||||
files: {
|
||||
list(path) { return API.request('GET', `/files${path ? '?path=' + encodeURIComponent(path) : ''}`); },
|
||||
delete(path) { return API.request('DELETE', `/files?path=${encodeURIComponent(path)}`); },
|
||||
publish(path) { return API.request('POST', '/files/publish', { path }); },
|
||||
},
|
||||
|
||||
reports: {
|
||||
list() { return API.request('GET', '/reports'); },
|
||||
get(filename) { return API.request('GET', `/reports/${encodeURIComponent(filename)}`); },
|
||||
delete(filename) { return API.request('DELETE', `/reports/${encodeURIComponent(filename)}`); },
|
||||
},
|
||||
|
||||
executions: {
|
||||
recent(limit = 20) { return API.request('GET', `/executions/recent?limit=${limit}`); },
|
||||
history(params = {}) {
|
||||
@@ -107,6 +170,19 @@ const API = {
|
||||
get(id) { return API.request('GET', `/executions/history/${id}`); },
|
||||
delete(id) { return API.request('DELETE', `/executions/history/${id}`); },
|
||||
clearAll() { return API.request('DELETE', '/executions/history'); },
|
||||
retry(id) { return API.request('POST', `/executions/${id}/retry`); },
|
||||
async exportCsv() {
|
||||
const response = await fetch('/api/executions/export', {
|
||||
headers: { 'X-Client-Id': API.clientId },
|
||||
});
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `execucoes_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
278
public/js/app.js
278
public/js/app.js
@@ -5,6 +5,8 @@ const App = {
|
||||
wsReconnectTimer: null,
|
||||
_initialized: false,
|
||||
_lastAgentName: '',
|
||||
_executeDropzone: null,
|
||||
_pipelineDropzone: null,
|
||||
|
||||
sectionTitles: {
|
||||
dashboard: 'Dashboard',
|
||||
@@ -15,21 +17,55 @@ const App = {
|
||||
webhooks: 'Webhooks',
|
||||
terminal: 'Terminal',
|
||||
history: 'Histórico',
|
||||
files: 'Projetos',
|
||||
settings: 'Configurações',
|
||||
},
|
||||
|
||||
sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'files', 'settings'],
|
||||
|
||||
init() {
|
||||
if (App._initialized) return;
|
||||
App._initialized = true;
|
||||
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
|
||||
App.setupNavigation();
|
||||
App.setupWebSocket();
|
||||
App.setupEventListeners();
|
||||
App.setupKeyboardShortcuts();
|
||||
App.navigateTo('dashboard');
|
||||
|
||||
App._executeDropzone = Utils.initDropzone('execute-dropzone', 'execute-files', 'execute-file-list');
|
||||
App._pipelineDropzone = Utils.initDropzone('pipeline-execute-dropzone', 'pipeline-execute-files', 'pipeline-execute-file-list');
|
||||
App._initRepoSelectors();
|
||||
|
||||
const initialSection = location.hash.replace('#', '') || 'dashboard';
|
||||
App.navigateTo(App.sections.includes(initialSection) ? initialSection : 'dashboard');
|
||||
App.startPeriodicRefresh();
|
||||
|
||||
if (window.lucide) lucide.createIcons();
|
||||
window.addEventListener('hashchange', () => {
|
||||
const section = location.hash.replace('#', '') || 'dashboard';
|
||||
if (App.sections.includes(section)) App.navigateTo(section);
|
||||
});
|
||||
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('theme', next);
|
||||
Utils.refreshIcons();
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof NotificationsUI !== 'undefined') NotificationsUI.init();
|
||||
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
|
||||
Utils.refreshIcons();
|
||||
},
|
||||
|
||||
setupNavigation() {
|
||||
@@ -47,6 +83,10 @@ const App = {
|
||||
},
|
||||
|
||||
navigateTo(section) {
|
||||
if (location.hash !== `#${section}`) {
|
||||
history.pushState(null, '', `#${section}`);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.section').forEach((el) => {
|
||||
const isActive = el.id === section;
|
||||
el.classList.toggle('active', isActive);
|
||||
@@ -75,6 +115,7 @@ const App = {
|
||||
case 'pipelines': await PipelinesUI.load(); break;
|
||||
case 'webhooks': await WebhooksUI.load(); break;
|
||||
case 'history': await HistoryUI.load(); break;
|
||||
case 'files': await FilesUI.load(); break;
|
||||
case 'settings': await SettingsUI.load(); break;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -138,8 +179,18 @@ const App = {
|
||||
|
||||
case 'execution_output': {
|
||||
Terminal.stopProcessing();
|
||||
const evtType = data.data?.type || 'chunk';
|
||||
const content = data.data?.content || '';
|
||||
if (content) {
|
||||
if (!content) break;
|
||||
if (evtType === 'tool') {
|
||||
Terminal.addLine(`▸ ${content}`, 'tool', data.executionId);
|
||||
} else if (evtType === 'turn') {
|
||||
Terminal.addLine(`── ${content} ──`, 'turn', data.executionId);
|
||||
} else if (evtType === 'system') {
|
||||
Terminal.addLine(content, 'system', data.executionId);
|
||||
} else if (evtType === 'stderr') {
|
||||
Terminal.addLine(content, 'stderr', data.executionId);
|
||||
} else {
|
||||
Terminal.addLine(content, 'default', data.executionId);
|
||||
}
|
||||
App._updateActiveBadge();
|
||||
@@ -174,6 +225,11 @@ const App = {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof NotificationsUI !== 'undefined') {
|
||||
NotificationsUI.loadCount();
|
||||
NotificationsUI.showBrowserNotification('Execução concluída', data.agentName || 'Agente');
|
||||
}
|
||||
|
||||
Toast.success('Execução concluída');
|
||||
App.refreshCurrentSection();
|
||||
App._updateActiveBadge();
|
||||
@@ -183,14 +239,39 @@ const App = {
|
||||
case 'execution_error':
|
||||
Terminal.stopProcessing();
|
||||
Terminal.addLine(data.data?.error || 'Erro na execução', 'error', data.executionId);
|
||||
|
||||
if (typeof NotificationsUI !== 'undefined') {
|
||||
NotificationsUI.loadCount();
|
||||
NotificationsUI.showBrowserNotification('Execução falhou', data.agentName || 'Agente');
|
||||
}
|
||||
|
||||
Toast.error(`Erro na execução: ${data.data?.error || 'desconhecido'}`);
|
||||
App._updateActiveBadge();
|
||||
break;
|
||||
|
||||
case 'execution_retry':
|
||||
Terminal.stopProcessing();
|
||||
Terminal.addLine(
|
||||
`Retry ${data.attempt || '?'}/${data.maxRetries || '?'} — próxima tentativa em ${data.nextRetryIn || '?'}s. Motivo: ${data.reason || 'erro na execução'}`,
|
||||
'warning',
|
||||
data.executionId
|
||||
);
|
||||
break;
|
||||
|
||||
case 'pipeline_step_output': {
|
||||
Terminal.stopProcessing();
|
||||
const stepEvtType = data.data?.type || 'chunk';
|
||||
const stepContent = data.data?.content || '';
|
||||
if (stepContent) {
|
||||
if (!stepContent) break;
|
||||
if (stepEvtType === 'tool') {
|
||||
Terminal.addLine(`▸ ${stepContent}`, 'tool', data.executionId);
|
||||
} else if (stepEvtType === 'turn') {
|
||||
Terminal.addLine(`── ${stepContent} ──`, 'turn', data.executionId);
|
||||
} else if (stepEvtType === 'system') {
|
||||
Terminal.addLine(stepContent, 'system', data.executionId);
|
||||
} else if (stepEvtType === 'stderr') {
|
||||
Terminal.addLine(stepContent, 'stderr', data.executionId);
|
||||
} else {
|
||||
Terminal.addLine(stepContent, 'default', data.executionId);
|
||||
}
|
||||
break;
|
||||
@@ -198,6 +279,7 @@ const App = {
|
||||
|
||||
case 'pipeline_step_start':
|
||||
Terminal.stopProcessing();
|
||||
if (data.resumed) Terminal.addLine('(retomando execução anterior)', 'system');
|
||||
Terminal.addLine(`Pipeline passo ${data.stepIndex + 1}/${data.totalSteps}: Executando agente "${data.agentName}"...`, 'system');
|
||||
Terminal.startProcessing(data.agentName);
|
||||
break;
|
||||
@@ -211,6 +293,9 @@ const App = {
|
||||
case 'pipeline_complete':
|
||||
Terminal.stopProcessing();
|
||||
Terminal.addLine('Pipeline concluído com sucesso.', 'success');
|
||||
if (data.lastSessionId && data.lastAgentId) {
|
||||
Terminal.enableChat(data.lastAgentId, data.lastAgentName || 'Agente', data.lastSessionId);
|
||||
}
|
||||
Toast.success('Pipeline concluído');
|
||||
App.refreshCurrentSection();
|
||||
break;
|
||||
@@ -241,9 +326,58 @@ const App = {
|
||||
|
||||
case 'pipeline_status':
|
||||
break;
|
||||
|
||||
case 'report_generated':
|
||||
if (data.reportFile) {
|
||||
Terminal.addLine(`📄 Relatório gerado: ${data.reportFile}`, 'info');
|
||||
App._openReport(data.reportFile);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
async _openReport(filename) {
|
||||
try {
|
||||
const data = await API.request('GET', `/reports/${encodeURIComponent(filename)}`);
|
||||
if (!data || !data.content) return;
|
||||
|
||||
const modal = document.getElementById('execution-detail-modal-overlay');
|
||||
const title = document.getElementById('execution-detail-title');
|
||||
const content = document.getElementById('execution-detail-content');
|
||||
if (!modal || !title || !content) return;
|
||||
|
||||
title.textContent = 'Relatório de Execução';
|
||||
content.innerHTML = `
|
||||
<div class="report-actions">
|
||||
<button class="btn btn-ghost btn-sm" id="report-copy-btn">
|
||||
<i data-lucide="copy"></i> Copiar
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" id="report-download-btn">
|
||||
<i data-lucide="download"></i> Download .md
|
||||
</button>
|
||||
</div>
|
||||
<div class="report-content"><pre class="report-markdown">${Utils.escapeHtml(data.content)}</pre></div>`;
|
||||
Utils.refreshIcons(content);
|
||||
|
||||
document.getElementById('report-copy-btn')?.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(data.content).then(() => Toast.success('Relatório copiado'));
|
||||
});
|
||||
|
||||
document.getElementById('report-download-btn')?.addEventListener('click', () => {
|
||||
const blob = new Blob([data.content], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
Toast.success('Download iniciado');
|
||||
});
|
||||
|
||||
Modal.open('execution-detail-modal-overlay');
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
_showApprovalNotification(pipelineId, stepIndex, agentName) {
|
||||
const container = document.getElementById('approval-notification');
|
||||
if (!container) return;
|
||||
@@ -253,7 +387,7 @@ const App = {
|
||||
<div class="approval-icon"><i data-lucide="shield-alert"></i></div>
|
||||
<div class="approval-text">
|
||||
<strong>Aprovação necessária</strong>
|
||||
<span>Passo ${stepIndex + 1} (${agentName || 'agente'}) aguardando autorização</span>
|
||||
<span>Passo ${stepIndex + 1} (${Utils.escapeHtml(agentName) || 'agente'}) aguardando autorização</span>
|
||||
</div>
|
||||
<div class="approval-actions">
|
||||
<button class="btn btn--primary btn--sm" id="approval-approve-btn" type="button">Aprovar</button>
|
||||
@@ -264,7 +398,7 @@ const App = {
|
||||
container.hidden = false;
|
||||
container.dataset.pipelineId = pipelineId;
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
|
||||
document.getElementById('approval-approve-btn')?.addEventListener('click', () => {
|
||||
App._handleApproval(pipelineId, true);
|
||||
@@ -424,6 +558,18 @@ const App = {
|
||||
|
||||
on('pipeline-execute-submit', 'click', () => PipelinesUI._executeFromModal());
|
||||
|
||||
on('terminal-stop-btn', 'click', async () => {
|
||||
try {
|
||||
await API.system.cancelAll();
|
||||
Terminal.stopProcessing();
|
||||
Terminal.addLine('Todas as execuções foram interrompidas.', 'error');
|
||||
Toast.warning('Execuções interrompidas');
|
||||
App._updateActiveBadge();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao interromper: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
on('terminal-clear-btn', 'click', () => {
|
||||
Terminal.clear();
|
||||
Terminal.disableChat();
|
||||
@@ -538,6 +684,8 @@ const App = {
|
||||
case 'edit': AgentsUI.openEditModal(id); break;
|
||||
case 'export': AgentsUI.export(id); break;
|
||||
case 'delete': AgentsUI.delete(id); break;
|
||||
case 'duplicate': AgentsUI.duplicate(id); break;
|
||||
case 'versions': AgentsUI.openVersionsModal(id); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -587,6 +735,7 @@ const App = {
|
||||
switch (action) {
|
||||
case 'execute-pipeline': PipelinesUI.execute(id); break;
|
||||
case 'edit-pipeline': PipelinesUI.openEditModal(id); break;
|
||||
case 'flow-pipeline': FlowEditor.open(id); break;
|
||||
case 'delete-pipeline': PipelinesUI.delete(id); break;
|
||||
}
|
||||
});
|
||||
@@ -598,6 +747,8 @@ const App = {
|
||||
switch (action) {
|
||||
case 'view-execution': HistoryUI.viewDetail(id); break;
|
||||
case 'delete-execution': HistoryUI.deleteExecution(id); break;
|
||||
case 'retry': HistoryUI.retryExecution(id); break;
|
||||
case 'resume-pipeline': HistoryUI.resumePipeline(id); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -610,6 +761,22 @@ const App = {
|
||||
case 'delete-webhook': WebhooksUI.delete(id); break;
|
||||
case 'copy-webhook-url': WebhooksUI.copyUrl(url); break;
|
||||
case 'copy-webhook-curl': WebhooksUI.copyCurl(id); break;
|
||||
case 'edit-webhook': WebhooksUI.openEditModal(id); break;
|
||||
case 'test-webhook': WebhooksUI.test(id); break;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('files-container')?.addEventListener('click', (e) => {
|
||||
const el = e.target.closest('[data-action]');
|
||||
if (!el) return;
|
||||
e.preventDefault();
|
||||
const { action, path } = el.dataset;
|
||||
switch (action) {
|
||||
case 'navigate-files': FilesUI.navigate(path || ''); break;
|
||||
case 'download-file': FilesUI.downloadFile(path); break;
|
||||
case 'download-folder': FilesUI.downloadFolder(path); break;
|
||||
case 'publish-project': FilesUI.publishProject(path); break;
|
||||
case 'delete-entry': FilesUI.deleteEntry(path, el.dataset.entryType); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -624,6 +791,7 @@ const App = {
|
||||
case 'move-up': PipelinesUI.moveStep(stepIndex, -1); break;
|
||||
case 'move-down': PipelinesUI.moveStep(stepIndex, 1); break;
|
||||
case 'remove': PipelinesUI.removeStep(stepIndex); break;
|
||||
case 'toggle-mode': PipelinesUI.toggleMode(stepIndex); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -661,8 +829,8 @@ const App = {
|
||||
hidden.value = JSON.stringify(tags);
|
||||
chips.innerHTML = tags.map((t) => `
|
||||
<span class="tag-chip">
|
||||
${t}
|
||||
<button type="button" class="tag-remove" data-tag="${t}" aria-label="Remover tag ${t}">×</button>
|
||||
${Utils.escapeHtml(t)}
|
||||
<button type="button" class="tag-remove" data-tag="${Utils.escapeHtml(t)}" aria-label="Remover tag ${Utils.escapeHtml(t)}">×</button>
|
||||
</span>
|
||||
`).join('');
|
||||
};
|
||||
@@ -693,6 +861,61 @@ const App = {
|
||||
});
|
||||
},
|
||||
|
||||
_reposCache: null,
|
||||
|
||||
async _loadRepos(selectId) {
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
try {
|
||||
if (!App._reposCache) App._reposCache = await API.repos.list();
|
||||
const current = select.value;
|
||||
select.innerHTML = '<option value="">Nenhum (usar diretório manual)</option>';
|
||||
App._reposCache.forEach(r => {
|
||||
select.insertAdjacentHTML('beforeend',
|
||||
`<option value="${Utils.escapeHtml(r.name)}">${Utils.escapeHtml(r.name)}${r.description ? ' — ' + Utils.escapeHtml(r.description.slice(0, 40)) : ''}</option>`
|
||||
);
|
||||
});
|
||||
if (current) select.value = current;
|
||||
} catch { }
|
||||
},
|
||||
|
||||
_initRepoSelectors() {
|
||||
const pairs = [
|
||||
['execute-repo', 'execute-repo-branch', 'execute-workdir-group'],
|
||||
['pipeline-execute-repo', 'pipeline-execute-repo-branch', 'pipeline-execute-workdir-group'],
|
||||
];
|
||||
pairs.forEach(([repoId, branchId, workdirGroupId]) => {
|
||||
const repoSelect = document.getElementById(repoId);
|
||||
const branchSelect = document.getElementById(branchId);
|
||||
const workdirGroup = document.getElementById(workdirGroupId);
|
||||
if (!repoSelect) return;
|
||||
|
||||
repoSelect.addEventListener('change', async () => {
|
||||
const repoName = repoSelect.value;
|
||||
if (repoName) {
|
||||
if (workdirGroup) workdirGroup.style.display = 'none';
|
||||
if (branchSelect) {
|
||||
branchSelect.style.display = '';
|
||||
branchSelect.innerHTML = '<option value="">Branch padrão</option>';
|
||||
try {
|
||||
const branches = await API.repos.branches(repoName);
|
||||
branches.forEach(b => {
|
||||
branchSelect.insertAdjacentHTML('beforeend', `<option value="${Utils.escapeHtml(b)}">${Utils.escapeHtml(b)}</option>`);
|
||||
});
|
||||
} catch { }
|
||||
}
|
||||
} else {
|
||||
if (workdirGroup) workdirGroup.style.display = '';
|
||||
if (branchSelect) branchSelect.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
repoSelect.addEventListener('focus', () => {
|
||||
if (repoSelect.options.length <= 1) App._loadRepos(repoId);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async _handleExecute() {
|
||||
const agentId = document.getElementById('execute-agent-select')?.value
|
||||
|| document.getElementById('execute-agent-id')?.value;
|
||||
@@ -709,16 +932,28 @@ const App = {
|
||||
}
|
||||
|
||||
const instructions = document.getElementById('execute-instructions')?.value.trim() || '';
|
||||
const workingDirectory = document.getElementById('execute-workdir')?.value.trim() || '';
|
||||
const repoName = document.getElementById('execute-repo')?.value || '';
|
||||
const repoBranch = document.getElementById('execute-repo-branch')?.value || '';
|
||||
|
||||
try {
|
||||
const selectEl = document.getElementById('execute-agent-select');
|
||||
const agentName = selectEl?.selectedOptions[0]?.text || 'Agente';
|
||||
|
||||
let contextFiles = null;
|
||||
const dropzone = App._executeDropzone;
|
||||
if (dropzone && dropzone.getFiles().length > 0) {
|
||||
Toast.info('Fazendo upload dos arquivos...');
|
||||
const uploadResult = await API.uploads.send(dropzone.getFiles());
|
||||
contextFiles = uploadResult.files;
|
||||
}
|
||||
|
||||
Terminal.disableChat();
|
||||
App._lastAgentName = agentName;
|
||||
|
||||
await API.agents.execute(agentId, task, instructions);
|
||||
await API.agents.execute(agentId, task, instructions, contextFiles, workingDirectory, repoName, repoBranch);
|
||||
|
||||
if (dropzone) dropzone.reset();
|
||||
Modal.close('execute-modal-overlay');
|
||||
App.navigateTo('terminal');
|
||||
Toast.info('Execução iniciada');
|
||||
@@ -768,14 +1003,32 @@ const App = {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName);
|
||||
if (isTyping) return;
|
||||
const isInInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName);
|
||||
if (isInInput) return;
|
||||
|
||||
if (e.key === 'n' || e.key === 'N') {
|
||||
if (App.currentSection === 'agents') {
|
||||
AgentsUI.openCreateModal();
|
||||
}
|
||||
}
|
||||
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const sectionKeys = {
|
||||
'1': 'dashboard',
|
||||
'2': 'agents',
|
||||
'3': 'tasks',
|
||||
'4': 'schedules',
|
||||
'5': 'pipelines',
|
||||
'6': 'terminal',
|
||||
'7': 'history',
|
||||
'8': 'webhooks',
|
||||
'9': 'settings',
|
||||
};
|
||||
if (sectionKeys[e.key]) {
|
||||
e.preventDefault();
|
||||
App.navigateTo(sectionKeys[e.key]);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -794,6 +1047,9 @@ const App = {
|
||||
if (countEl) countEl.textContent = count;
|
||||
if (badge) badge.style.display = count > 0 ? 'flex' : 'none';
|
||||
|
||||
const stopBtn = document.getElementById('terminal-stop-btn');
|
||||
if (stopBtn) stopBtn.hidden = count === 0;
|
||||
|
||||
const terminalSelect = document.getElementById('terminal-execution-select');
|
||||
if (terminalSelect && Array.isArray(active)) {
|
||||
const existing = new Set(
|
||||
|
||||
@@ -39,8 +39,19 @@ const AgentsUI = {
|
||||
|
||||
if (empty) empty.style.display = 'none';
|
||||
|
||||
const sorted = [...agents].sort((a, b) => {
|
||||
const rank = (agent) => {
|
||||
const name = (agent.agent_name || agent.name || '').toLowerCase();
|
||||
const tags = (agent.tags || []).map((t) => t.toLowerCase());
|
||||
if (name === 'tech lead' || tags.includes('lider')) return 0;
|
||||
if (name === 'product owner' || tags.includes('po') || tags.includes('product-owner')) return 1;
|
||||
return 2;
|
||||
};
|
||||
return rank(a) - rank(b);
|
||||
});
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
agents.forEach((agent) => {
|
||||
sorted.forEach((agent) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = AgentsUI.renderCard(agent);
|
||||
fragment.appendChild(wrapper.firstElementChild);
|
||||
@@ -48,7 +59,7 @@ const AgentsUI = {
|
||||
|
||||
grid.appendChild(fragment);
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [grid] });
|
||||
Utils.refreshIcons(grid);
|
||||
},
|
||||
|
||||
filter(searchText, statusFilter) {
|
||||
@@ -76,23 +87,33 @@ const AgentsUI = {
|
||||
const model = (agent.config && agent.config.model) || agent.model || 'claude-sonnet-4-6';
|
||||
const updatedAt = AgentsUI.formatDate(agent.updated_at || agent.updatedAt || agent.created_at || agent.createdAt);
|
||||
const tags = Array.isArray(agent.tags) && agent.tags.length > 0
|
||||
? `<div class="agent-tags">${agent.tags.map((t) => `<span class="tag-chip tag-chip--sm">${t}</span>`).join('')}</div>`
|
||||
? `<div class="agent-tags">${agent.tags.map((t) => `<span class="tag-chip tag-chip--sm">${Utils.escapeHtml(t)}</span>`).join('')}</div>`
|
||||
: '';
|
||||
const agentNameLower = (agent.agent_name || agent.name || '').toLowerCase();
|
||||
const tagsLower = Array.isArray(agent.tags) ? agent.tags.map((t) => t.toLowerCase()) : [];
|
||||
const isLeader = agentNameLower === 'tech lead' || tagsLower.includes('lider');
|
||||
const isPO = !isLeader && (agentNameLower === 'product owner' || tagsLower.includes('po') || tagsLower.includes('product-owner'));
|
||||
const roleClass = isLeader ? ' agent-card--leader' : isPO ? ' agent-card--po' : '';
|
||||
const roleBadge = isLeader
|
||||
? '<i data-lucide="crown" class="agent-leader-icon"></i>'
|
||||
: isPO
|
||||
? '<i data-lucide="shield-check" class="agent-po-icon"></i>'
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="agent-card" data-agent-id="${agent.id}">
|
||||
<div class="agent-card${roleClass}" 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>
|
||||
<h3 class="agent-name">${roleBadge}${Utils.escapeHtml(name)}</h3>
|
||||
<span class="badge ${statusClass}">${statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${agent.description ? `<p class="agent-description">${agent.description}</p>` : ''}
|
||||
${agent.description ? `<p class="agent-description">${Utils.escapeHtml(agent.description)}</p>` : ''}
|
||||
${tags}
|
||||
|
||||
<div class="agent-meta">
|
||||
@@ -112,16 +133,23 @@ const AgentsUI = {
|
||||
<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 class="agent-actions-icons">
|
||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="edit" data-id="${agent.id}" title="Editar agente">
|
||||
<i data-lucide="pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="duplicate" data-id="${agent.id}" title="Duplicar agente">
|
||||
<i data-lucide="copy"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="export" data-id="${agent.id}" title="Exportar agente">
|
||||
<i data-lucide="download"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="versions" data-id="${agent.id}" title="Histórico de versões">
|
||||
<i data-lucide="history"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm btn-danger" data-action="delete" data-id="${agent.id}" title="Excluir agente">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -152,10 +180,31 @@ const AgentsUI = {
|
||||
const maxTurns = document.getElementById('agent-max-turns');
|
||||
if (maxTurns) maxTurns.value = '0';
|
||||
|
||||
const workdir = document.getElementById('agent-workdir');
|
||||
if (workdir) workdir.value = '/home/projetos/';
|
||||
|
||||
const permissionMode = document.getElementById('agent-permission-mode');
|
||||
if (permissionMode) permissionMode.value = '';
|
||||
|
||||
const retryToggle = document.getElementById('agent-retry-toggle');
|
||||
if (retryToggle) retryToggle.checked = false;
|
||||
|
||||
const retryMaxGroup = document.getElementById('agent-retry-max-group');
|
||||
if (retryMaxGroup) retryMaxGroup.style.display = 'none';
|
||||
|
||||
const retryMax = document.getElementById('agent-retry-max');
|
||||
if (retryMax) retryMax.value = '3';
|
||||
|
||||
AgentsUI._populateDelegateSelect('');
|
||||
|
||||
const secretsSection = document.getElementById('agent-secrets-section');
|
||||
if (secretsSection) secretsSection.hidden = true;
|
||||
|
||||
const secretsList = document.getElementById('agent-secrets-list');
|
||||
if (secretsList) secretsList.innerHTML = '';
|
||||
|
||||
Modal.open('agent-modal-overlay');
|
||||
AgentsUI._setupModalListeners();
|
||||
},
|
||||
|
||||
async openEditModal(agentId) {
|
||||
@@ -196,7 +245,25 @@ const AgentsUI = {
|
||||
).join('');
|
||||
}
|
||||
|
||||
const retryToggle = document.getElementById('agent-retry-toggle');
|
||||
const retryOnFailure = agent.config && agent.config.retryOnFailure;
|
||||
if (retryToggle) retryToggle.checked = !!retryOnFailure;
|
||||
|
||||
const retryMaxGroup = document.getElementById('agent-retry-max-group');
|
||||
if (retryMaxGroup) retryMaxGroup.style.display = retryOnFailure ? '' : 'none';
|
||||
|
||||
const retryMax = document.getElementById('agent-retry-max');
|
||||
if (retryMax) retryMax.value = (agent.config && agent.config.maxRetries) || '3';
|
||||
|
||||
AgentsUI._populateDelegateSelect(agent.config?.delegateTo || '', agent.id);
|
||||
|
||||
const secretsSection = document.getElementById('agent-secrets-section');
|
||||
if (secretsSection) secretsSection.hidden = false;
|
||||
|
||||
AgentsUI._loadSecrets(agent.id);
|
||||
|
||||
Modal.open('agent-modal-overlay');
|
||||
AgentsUI._setupModalListeners();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao carregar agente: ${err.message}`);
|
||||
}
|
||||
@@ -234,6 +301,9 @@ const AgentsUI = {
|
||||
allowedTools: document.getElementById('agent-allowed-tools')?.value.trim() || '',
|
||||
maxTurns: parseInt(document.getElementById('agent-max-turns')?.value) || 0,
|
||||
permissionMode: document.getElementById('agent-permission-mode')?.value || '',
|
||||
retryOnFailure: !!document.getElementById('agent-retry-toggle')?.checked,
|
||||
maxRetries: parseInt(document.getElementById('agent-retry-max')?.value) || 3,
|
||||
delegateTo: document.getElementById('agent-delegate-to')?.value || '',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -279,7 +349,7 @@ const AgentsUI = {
|
||||
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
|
||||
allAgents
|
||||
.filter((a) => a.status === 'active')
|
||||
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`)
|
||||
.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
|
||||
.join('');
|
||||
|
||||
selectEl.value = agentId;
|
||||
@@ -294,8 +364,21 @@ const AgentsUI = {
|
||||
const instructionsEl = document.getElementById('execute-instructions');
|
||||
if (instructionsEl) instructionsEl.value = '';
|
||||
|
||||
if (App._executeDropzone) App._executeDropzone.reset();
|
||||
|
||||
const selectedAgent = allAgents.find(a => a.id === agentId);
|
||||
const workdirEl = document.getElementById('execute-workdir');
|
||||
if (workdirEl) {
|
||||
workdirEl.value = (selectedAgent?.config?.workingDirectory) || '/home/projetos/';
|
||||
}
|
||||
|
||||
AgentsUI._loadSavedTasks();
|
||||
|
||||
const repoSelect = document.getElementById('execute-repo');
|
||||
if (repoSelect) { repoSelect.value = ''; repoSelect.dispatchEvent(new Event('change')); }
|
||||
App._reposCache = null;
|
||||
App._loadRepos('execute-repo');
|
||||
|
||||
Modal.open('execute-modal-overlay');
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao abrir modal de execução: ${err.message}`);
|
||||
@@ -311,7 +394,7 @@ const AgentsUI = {
|
||||
savedTaskSelect.innerHTML = '<option value="">Digitar manualmente...</option>' +
|
||||
tasks.map((t) => {
|
||||
const label = t.category ? `[${t.category.toUpperCase()}] ${t.name}` : t.name;
|
||||
return `<option value="${t.id}">${label}</option>`;
|
||||
return `<option value="${t.id}">${Utils.escapeHtml(label)}</option>`;
|
||||
}).join('');
|
||||
AgentsUI._savedTasksCache = tasks;
|
||||
} catch {
|
||||
@@ -322,6 +405,16 @@ const AgentsUI = {
|
||||
|
||||
_savedTasksCache: [],
|
||||
|
||||
async duplicate(agentId) {
|
||||
try {
|
||||
await API.agents.duplicate(agentId);
|
||||
Toast.success('Agente duplicado com sucesso');
|
||||
await AgentsUI.load();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao duplicar agente: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async export(agentId) {
|
||||
try {
|
||||
const data = await API.agents.export(agentId);
|
||||
@@ -393,6 +486,231 @@ const AgentsUI = {
|
||||
minute: '2-digit',
|
||||
});
|
||||
},
|
||||
|
||||
_populateDelegateSelect(currentValue, excludeId) {
|
||||
const select = document.getElementById('agent-delegate-to');
|
||||
if (!select) return;
|
||||
const activeAgents = AgentsUI.agents.filter(a => a.status === 'active' && a.id !== excludeId);
|
||||
select.innerHTML = '<option value="">Nenhum</option>' +
|
||||
activeAgents.map(a => `<option value="${a.id}" ${a.id === currentValue ? 'selected' : ''}>${Utils.escapeHtml(a.agent_name || a.name)}</option>`).join('');
|
||||
},
|
||||
|
||||
_setupModalListeners() {
|
||||
const retryToggle = document.getElementById('agent-retry-toggle');
|
||||
const retryMaxGroup = document.getElementById('agent-retry-max-group');
|
||||
|
||||
if (retryToggle && !retryToggle._listenerAdded) {
|
||||
retryToggle._listenerAdded = true;
|
||||
retryToggle.addEventListener('change', () => {
|
||||
if (retryMaxGroup) retryMaxGroup.style.display = retryToggle.checked ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
const addSecretBtn = document.getElementById('agent-secret-add-btn');
|
||||
if (addSecretBtn && !addSecretBtn._listenerAdded) {
|
||||
addSecretBtn._listenerAdded = true;
|
||||
addSecretBtn.addEventListener('click', () => {
|
||||
const agentId = document.getElementById('agent-form-id')?.value;
|
||||
if (agentId) {
|
||||
AgentsUI._addSecret(agentId);
|
||||
} else {
|
||||
Toast.warning('Salve o agente primeiro para adicionar secrets');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async _loadSecrets(agentId) {
|
||||
const list = document.getElementById('agent-secrets-list');
|
||||
if (!list) return;
|
||||
|
||||
try {
|
||||
const secrets = await API.secrets.list(agentId);
|
||||
const items = Array.isArray(secrets) ? secrets : (secrets?.secrets || []);
|
||||
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = '<p class="text-muted text-sm">Nenhum secret configurado.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = items.map(s => `
|
||||
<div class="secret-item">
|
||||
<span class="secret-name font-mono">${Utils.escapeHtml(s.name || s)}</span>
|
||||
<span class="secret-value-placeholder">••••••••</span>
|
||||
<button type="button" class="btn btn-ghost btn-icon btn-sm btn-danger" data-secret-delete="${Utils.escapeHtml(s.name || s)}" data-agent-id="${agentId}" title="Remover secret">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
Utils.refreshIcons(list);
|
||||
|
||||
list.querySelectorAll('[data-secret-delete]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
AgentsUI._deleteSecret(btn.dataset.agentId, btn.dataset.secretDelete);
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
list.innerHTML = '<p class="text-muted text-sm">Erro ao carregar secrets.</p>';
|
||||
}
|
||||
},
|
||||
|
||||
async _addSecret(agentId) {
|
||||
const nameEl = document.getElementById('agent-secret-name');
|
||||
const valueEl = document.getElementById('agent-secret-value');
|
||||
const name = nameEl?.value.trim();
|
||||
const value = valueEl?.value;
|
||||
|
||||
if (!name) {
|
||||
Toast.warning('Nome do secret é obrigatório');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
Toast.warning('Valor do secret é obrigatório');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.secrets.create(agentId, { name, value });
|
||||
Toast.success(`Secret "${name}" salvo`);
|
||||
if (nameEl) nameEl.value = '';
|
||||
if (valueEl) valueEl.value = '';
|
||||
AgentsUI._loadSecrets(agentId);
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao salvar secret: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async _deleteSecret(agentId, secretName) {
|
||||
const confirmed = await Modal.confirm(
|
||||
'Remover secret',
|
||||
`Tem certeza que deseja remover o secret "${secretName}"?`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await API.secrets.delete(agentId, secretName);
|
||||
Toast.success(`Secret "${secretName}" removido`);
|
||||
AgentsUI._loadSecrets(agentId);
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao remover secret: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async openVersionsModal(agentId) {
|
||||
const agent = AgentsUI.agents.find(a => a.id === agentId);
|
||||
const titleEl = document.getElementById('agent-versions-title');
|
||||
const contentEl = document.getElementById('agent-versions-content');
|
||||
|
||||
if (titleEl) titleEl.textContent = `Versões — ${agent?.agent_name || agent?.name || 'Agente'}`;
|
||||
|
||||
if (contentEl) {
|
||||
contentEl.innerHTML = '<div class="flex flex-center gap-8"><div class="spinner"></div><span class="text-secondary">Carregando versões...</span></div>';
|
||||
}
|
||||
|
||||
Modal.open('agent-versions-modal-overlay');
|
||||
|
||||
try {
|
||||
const versions = await API.versions.list(agentId);
|
||||
const items = Array.isArray(versions) ? versions : (versions?.versions || []);
|
||||
|
||||
if (!contentEl) return;
|
||||
|
||||
if (items.length === 0) {
|
||||
contentEl.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon"><i data-lucide="history"></i></div>
|
||||
<h3 class="empty-state-title">Sem histórico de versões</h3>
|
||||
<p class="empty-state-desc">As alterações neste agente serão registradas aqui automaticamente.</p>
|
||||
</div>`;
|
||||
Utils.refreshIcons(contentEl);
|
||||
return;
|
||||
}
|
||||
|
||||
contentEl.innerHTML = `
|
||||
<div class="versions-timeline">
|
||||
${items.map((v, i) => {
|
||||
const date = v.changedAt ? new Date(v.changedAt).toLocaleString('pt-BR') : '—';
|
||||
const changedFields = AgentsUI._getChangedFields(v);
|
||||
const isLatest = i === 0;
|
||||
|
||||
return `
|
||||
<div class="version-item ${isLatest ? 'version-item--latest' : ''}">
|
||||
<div class="version-node">
|
||||
<div class="version-dot ${isLatest ? 'version-dot--active' : ''}"></div>
|
||||
${i < items.length - 1 ? '<div class="version-line"></div>' : ''}
|
||||
</div>
|
||||
<div class="version-content">
|
||||
<div class="version-header">
|
||||
<span class="version-number">v${v.version || items.length - i}</span>
|
||||
<span class="version-date">${date}</span>
|
||||
${!isLatest ? `<button class="btn btn-ghost btn-sm" data-restore-version="${v.version || items.length - i}" data-agent-id="${agentId}" type="button">
|
||||
<i data-lucide="undo-2"></i> Restaurar
|
||||
</button>` : '<span class="badge badge-active">Atual</span>'}
|
||||
</div>
|
||||
${changedFields ? `<div class="version-changes">${changedFields}</div>` : ''}
|
||||
${v.changelog ? `<p class="version-changelog">${Utils.escapeHtml(v.changelog)}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
|
||||
Utils.refreshIcons(contentEl);
|
||||
|
||||
contentEl.querySelectorAll('[data-restore-version]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const version = btn.dataset.restoreVersion;
|
||||
const aid = btn.dataset.agentId;
|
||||
const confirmed = await Modal.confirm(
|
||||
'Restaurar versão',
|
||||
`Deseja restaurar a versão v${version} deste agente? A configuração atual será substituída.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await API.versions.restore(aid, version);
|
||||
Toast.success(`Versão v${version} restaurada`);
|
||||
Modal.close('agent-versions-modal-overlay');
|
||||
await AgentsUI.load();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao restaurar versão: ${err.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
if (contentEl) {
|
||||
contentEl.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon"><i data-lucide="alert-circle"></i></div>
|
||||
<h3 class="empty-state-title">Erro ao carregar versões</h3>
|
||||
<p class="empty-state-desc">${Utils.escapeHtml(err.message)}</p>
|
||||
</div>`;
|
||||
Utils.refreshIcons(contentEl);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_getChangedFields(version) {
|
||||
if (!version.config) return '';
|
||||
const fieldLabels = {
|
||||
systemPrompt: 'System Prompt',
|
||||
model: 'Modelo',
|
||||
workingDirectory: 'Diretório',
|
||||
allowedTools: 'Ferramentas',
|
||||
maxTurns: 'Max Turns',
|
||||
permissionMode: 'Permission Mode',
|
||||
retryOnFailure: 'Retry',
|
||||
};
|
||||
|
||||
const fields = Object.keys(version.config || {}).filter(k => fieldLabels[k]);
|
||||
if (fields.length === 0) return '';
|
||||
|
||||
return fields.map(f =>
|
||||
`<span class="version-field-badge">${fieldLabels[f] || f}</span>`
|
||||
).join('');
|
||||
},
|
||||
};
|
||||
|
||||
window.AgentsUI = AgentsUI;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const DashboardUI = {
|
||||
charts: {},
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const [status, recentExecs] = await Promise.all([
|
||||
@@ -9,11 +11,253 @@ const DashboardUI = {
|
||||
DashboardUI.updateMetrics(status);
|
||||
DashboardUI.updateRecentActivity(recentExecs || []);
|
||||
DashboardUI.updateSystemStatus(status);
|
||||
DashboardUI.setupChartPeriod();
|
||||
DashboardUI.loadCharts();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao carregar dashboard: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async loadCharts() {
|
||||
try {
|
||||
const period = document.getElementById('chart-period');
|
||||
const days = period ? parseInt(period.value) : 7;
|
||||
const data = await API.stats.charts(days);
|
||||
DashboardUI.renderExecutionsChart(data);
|
||||
DashboardUI.renderCostChart(data);
|
||||
DashboardUI.renderStatusChart(data);
|
||||
DashboardUI.renderTopAgentsChart(data);
|
||||
DashboardUI.renderSuccessRateChart(data);
|
||||
} catch (e) {
|
||||
console.error('Erro ao carregar gráficos:', e);
|
||||
}
|
||||
},
|
||||
|
||||
_cssVar(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
},
|
||||
|
||||
renderExecutionsChart(data) {
|
||||
const ctx = document.getElementById('executions-chart');
|
||||
if (!ctx) return;
|
||||
if (DashboardUI.charts.executions) DashboardUI.charts.executions.destroy();
|
||||
|
||||
const labels = (data.labels || []).map(l => {
|
||||
const d = new Date(l + 'T12:00:00');
|
||||
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
|
||||
});
|
||||
|
||||
DashboardUI.charts.executions = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'Sucesso', data: data.successCounts || [], backgroundColor: 'rgba(34, 197, 94, 0.8)', borderRadius: 4 },
|
||||
{ label: 'Erro', data: data.errorCounts || [], backgroundColor: 'rgba(239, 68, 68, 0.8)', borderRadius: 4 },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: DashboardUI._cssVar('--text-secondary'), font: { size: 11 } },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: { display: false },
|
||||
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(128,128,128,0.1)' },
|
||||
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
renderCostChart(data) {
|
||||
const ctx = document.getElementById('cost-chart');
|
||||
if (!ctx) return;
|
||||
if (DashboardUI.charts.cost) DashboardUI.charts.cost.destroy();
|
||||
|
||||
const labels = (data.labels || []).map(l => {
|
||||
const d = new Date(l + 'T12:00:00');
|
||||
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
|
||||
});
|
||||
|
||||
DashboardUI.charts.cost = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Custo (USD)',
|
||||
data: data.costData || [],
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#6366f1',
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(128,128,128,0.1)' },
|
||||
ticks: {
|
||||
color: DashboardUI._cssVar('--text-tertiary'),
|
||||
font: { size: 10 },
|
||||
callback: (v) => '$' + v.toFixed(2),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
renderStatusChart(data) {
|
||||
const ctx = document.getElementById('status-chart');
|
||||
if (!ctx) return;
|
||||
if (DashboardUI.charts.status) DashboardUI.charts.status.destroy();
|
||||
|
||||
const dist = data.statusDistribution || {};
|
||||
const statuses = Object.keys(dist);
|
||||
const values = Object.values(dist);
|
||||
const colors = {
|
||||
completed: '#22c55e',
|
||||
error: '#ef4444',
|
||||
running: '#6366f1',
|
||||
canceled: '#f59e0b',
|
||||
rejected: '#ef4444',
|
||||
};
|
||||
|
||||
DashboardUI.charts.status = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: statuses.map(s => s.charAt(0).toUpperCase() + s.slice(1)),
|
||||
datasets: [{
|
||||
data: values,
|
||||
backgroundColor: statuses.map(s => colors[s] || '#94a3b8'),
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
aspectRatio: 1,
|
||||
cutout: '65%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: DashboardUI._cssVar('--text-secondary'),
|
||||
font: { size: 11 },
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
renderTopAgentsChart(data) {
|
||||
const ctx = document.getElementById('agents-chart');
|
||||
if (!ctx) return;
|
||||
if (DashboardUI.charts.agents) DashboardUI.charts.agents.destroy();
|
||||
|
||||
const top = data.topAgents || [];
|
||||
|
||||
DashboardUI.charts.agents = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: top.map(a => a.name.length > 15 ? a.name.substring(0, 15) + '\u2026' : a.name),
|
||||
datasets: [{
|
||||
data: top.map(a => a.count),
|
||||
backgroundColor: ['#6366f1', '#8b5cf6', '#a78bfa', '#c4b5fd', '#ddd6fe'],
|
||||
borderRadius: 4,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y',
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(128,128,128,0.1)' },
|
||||
ticks: { color: DashboardUI._cssVar('--text-tertiary'), font: { size: 10 } },
|
||||
},
|
||||
y: {
|
||||
grid: { display: false },
|
||||
ticks: { color: DashboardUI._cssVar('--text-secondary'), font: { size: 10 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
renderSuccessRateChart(data) {
|
||||
const ctx = document.getElementById('success-rate-chart');
|
||||
if (!ctx) return;
|
||||
if (DashboardUI.charts.successRate) DashboardUI.charts.successRate.destroy();
|
||||
|
||||
const dist = data.statusDistribution || {};
|
||||
const total = Object.values(dist).reduce((a, b) => a + b, 0);
|
||||
const success = dist.completed || 0;
|
||||
const rate = total > 0 ? Math.round((success / total) * 100) : 0;
|
||||
|
||||
DashboardUI.charts.successRate = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Sucesso', 'Outros'],
|
||||
datasets: [{
|
||||
data: [rate, 100 - rate],
|
||||
backgroundColor: ['#22c55e', 'rgba(128,128,128,0.15)'],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
aspectRatio: 1,
|
||||
cutout: '75%',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { enabled: false },
|
||||
},
|
||||
},
|
||||
plugins: [{
|
||||
id: 'centerText',
|
||||
afterDraw(chart) {
|
||||
const { ctx: c, width, height } = chart;
|
||||
c.save();
|
||||
c.font = 'bold 24px Inter';
|
||||
c.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--text-primary').trim();
|
||||
c.textAlign = 'center';
|
||||
c.textBaseline = 'middle';
|
||||
c.fillText(rate + '%', width / 2, height / 2);
|
||||
c.restore();
|
||||
},
|
||||
}],
|
||||
});
|
||||
},
|
||||
|
||||
updateMetrics(status) {
|
||||
const metrics = {
|
||||
'metric-total-agents': status.agents?.total ?? 0,
|
||||
@@ -71,15 +315,15 @@ const DashboardUI = {
|
||||
<span>Nenhuma execução recente</span>
|
||||
</li>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [list] });
|
||||
Utils.refreshIcons(list);
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = executions.map((exec) => {
|
||||
const statusClass = DashboardUI._statusBadgeClass(exec.status);
|
||||
const statusLabel = DashboardUI._statusLabel(exec.status);
|
||||
const name = exec.agentName || exec.pipelineName || exec.agentId || 'Execução';
|
||||
const taskText = exec.task || exec.input || '';
|
||||
const name = Utils.escapeHtml(exec.agentName || exec.pipelineName || exec.agentId || 'Execução');
|
||||
const taskText = Utils.escapeHtml(exec.task || exec.input || '');
|
||||
const typeBadge = exec.type === 'pipeline'
|
||||
? '<span class="badge badge--purple" style="font-size:0.6rem;padding:1px 5px;">Pipeline</span> '
|
||||
: '';
|
||||
@@ -110,6 +354,14 @@ const DashboardUI = {
|
||||
}).join('');
|
||||
},
|
||||
|
||||
setupChartPeriod() {
|
||||
const chartPeriod = document.getElementById('chart-period');
|
||||
if (chartPeriod && !chartPeriod._listenerAdded) {
|
||||
chartPeriod._listenerAdded = true;
|
||||
chartPeriod.addEventListener('change', () => DashboardUI.loadCharts());
|
||||
}
|
||||
},
|
||||
|
||||
updateSystemStatus(status) {
|
||||
const wsBadge = document.getElementById('system-ws-status-badge');
|
||||
if (wsBadge) {
|
||||
@@ -117,6 +369,18 @@ const DashboardUI = {
|
||||
wsBadge.textContent = wsConnected ? 'Conectado' : 'Desconectado';
|
||||
wsBadge.className = `badge ${wsConnected ? 'badge--green' : 'badge--red'}`;
|
||||
}
|
||||
|
||||
const claudeBadge = document.getElementById('system-claude-status-badge');
|
||||
if (claudeBadge) {
|
||||
API.system.info().then((info) => {
|
||||
const available = info.claudeVersion && info.claudeVersion !== 'N/A';
|
||||
claudeBadge.textContent = available ? info.claudeVersion : 'Indisponível';
|
||||
claudeBadge.className = `badge ${available ? 'badge--green' : 'badge--red'}`;
|
||||
}).catch(() => {
|
||||
claudeBadge.textContent = 'Indisponível';
|
||||
claudeBadge.className = 'badge badge--red';
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_statusBadgeClass(status) {
|
||||
|
||||
210
public/js/components/files.js
Normal file
210
public/js/components/files.js
Normal file
@@ -0,0 +1,210 @@
|
||||
const FilesUI = {
|
||||
currentPath: '',
|
||||
|
||||
async load() {
|
||||
await FilesUI.navigate('');
|
||||
},
|
||||
|
||||
async navigate(path) {
|
||||
try {
|
||||
const data = await API.files.list(path);
|
||||
FilesUI.currentPath = data.path || '';
|
||||
FilesUI.render(data);
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao carregar arquivos: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
render(data) {
|
||||
const container = document.getElementById('files-container');
|
||||
if (!container) return;
|
||||
|
||||
const breadcrumb = FilesUI._renderBreadcrumb(data.path);
|
||||
const entries = data.entries || [];
|
||||
|
||||
if (entries.length === 0) {
|
||||
container.innerHTML = `
|
||||
${breadcrumb}
|
||||
<div class="files-empty">
|
||||
<i data-lucide="folder-open" style="width:48px;height:48px;color:var(--text-muted)"></i>
|
||||
<p>Nenhum arquivo encontrado neste diretório</p>
|
||||
</div>
|
||||
`;
|
||||
Utils.refreshIcons(container);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = entries.map(entry => FilesUI._renderRow(entry, data.path)).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
${breadcrumb}
|
||||
<div class="files-toolbar">
|
||||
<span class="files-count">${entries.length} ${entries.length === 1 ? 'item' : 'itens'}</span>
|
||||
<button class="btn btn--ghost btn--sm" data-action="download-folder" data-path="${Utils.escapeHtml(data.path || '')}" title="Baixar pasta como .tar.gz"><i data-lucide="download" style="width:14px;height:14px"></i> Baixar tudo</button>
|
||||
</div>
|
||||
<div class="files-table-wrapper">
|
||||
<table class="files-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="files-th-name">Nome</th>
|
||||
<th class="files-th-size">Tamanho</th>
|
||||
<th class="files-th-date">Modificado</th>
|
||||
<th class="files-th-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
_renderBreadcrumb(currentPath) {
|
||||
const parts = currentPath ? currentPath.split('/').filter(Boolean) : [];
|
||||
let html = `<nav class="files-breadcrumb"><a href="#" data-action="navigate-files" data-path="" class="files-breadcrumb-link"><i data-lucide="home" style="width:14px;height:14px"></i> projetos</a>`;
|
||||
|
||||
let accumulated = '';
|
||||
for (const part of parts) {
|
||||
accumulated += (accumulated ? '/' : '') + part;
|
||||
html += ` <span class="files-breadcrumb-sep">/</span> <a href="#" data-action="navigate-files" data-path="${Utils.escapeHtml(accumulated)}" class="files-breadcrumb-link">${Utils.escapeHtml(part)}</a>`;
|
||||
}
|
||||
|
||||
html += '</nav>';
|
||||
return html;
|
||||
},
|
||||
|
||||
_renderRow(entry, currentPath) {
|
||||
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
|
||||
const icon = entry.type === 'directory' ? 'folder' : FilesUI._fileIcon(entry.extension);
|
||||
const iconColor = entry.type === 'directory' ? 'var(--warning)' : 'var(--text-muted)';
|
||||
const size = entry.type === 'directory' ? '—' : FilesUI._formatSize(entry.size);
|
||||
const date = FilesUI._formatDate(entry.modified);
|
||||
|
||||
const nameCell = entry.type === 'directory'
|
||||
? `<a href="#" class="files-entry-link files-entry-dir" data-action="navigate-files" data-path="${Utils.escapeHtml(fullPath)}"><i data-lucide="${icon}" style="width:16px;height:16px;color:${iconColor};flex-shrink:0"></i> ${Utils.escapeHtml(entry.name)}</a>`
|
||||
: `<span class="files-entry-link files-entry-file"><i data-lucide="${icon}" style="width:16px;height:16px;color:${iconColor};flex-shrink:0"></i> ${Utils.escapeHtml(entry.name)}</span>`;
|
||||
|
||||
const downloadBtn = entry.type === 'directory'
|
||||
? `<button class="btn btn--ghost btn--sm" data-action="download-folder" data-path="${Utils.escapeHtml(fullPath)}" title="Baixar pasta"><i data-lucide="download" style="width:14px;height:14px"></i></button>`
|
||||
: `<button class="btn btn--ghost btn--sm" data-action="download-file" data-path="${Utils.escapeHtml(fullPath)}" title="Baixar arquivo"><i data-lucide="download" style="width:14px;height:14px"></i></button>`;
|
||||
const isRootDir = entry.type === 'directory' && !currentPath;
|
||||
const publishBtn = isRootDir
|
||||
? `<button class="btn btn--ghost btn--sm btn-publish" data-action="publish-project" data-path="${Utils.escapeHtml(fullPath)}" title="Publicar projeto"><i data-lucide="rocket" style="width:14px;height:14px"></i></button>`
|
||||
: '';
|
||||
const deleteBtn = `<button class="btn btn--ghost btn--sm btn-danger" data-action="delete-entry" data-path="${Utils.escapeHtml(fullPath)}" data-entry-type="${entry.type}" title="Excluir"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>`;
|
||||
const actions = `${downloadBtn}${publishBtn}${deleteBtn}`;
|
||||
|
||||
return `
|
||||
<tr class="files-row">
|
||||
<td class="files-td-name">${nameCell}</td>
|
||||
<td class="files-td-size">${size}</td>
|
||||
<td class="files-td-date">${date}</td>
|
||||
<td class="files-td-actions">${actions}</td>
|
||||
</tr>
|
||||
`;
|
||||
},
|
||||
|
||||
_fileIcon(ext) {
|
||||
const map = {
|
||||
js: 'file-code-2', ts: 'file-code-2', jsx: 'file-code-2', tsx: 'file-code-2',
|
||||
py: 'file-code-2', rb: 'file-code-2', go: 'file-code-2', rs: 'file-code-2',
|
||||
java: 'file-code-2', c: 'file-code-2', cpp: 'file-code-2', h: 'file-code-2',
|
||||
html: 'file-code-2', css: 'file-code-2', scss: 'file-code-2', vue: 'file-code-2',
|
||||
json: 'file-json', xml: 'file-json', yaml: 'file-json', yml: 'file-json',
|
||||
md: 'file-text', txt: 'file-text', log: 'file-text', csv: 'file-text',
|
||||
pdf: 'file-text',
|
||||
png: 'file-image', jpg: 'file-image', jpeg: 'file-image', gif: 'file-image',
|
||||
svg: 'file-image', webp: 'file-image', ico: 'file-image',
|
||||
zip: 'file-archive', tar: 'file-archive', gz: 'file-archive', rar: 'file-archive',
|
||||
sh: 'file-terminal', bash: 'file-terminal',
|
||||
sql: 'database',
|
||||
env: 'file-lock',
|
||||
};
|
||||
return map[ext] || 'file';
|
||||
},
|
||||
|
||||
_formatSize(bytes) {
|
||||
if (bytes == null) return '—';
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||
},
|
||||
|
||||
_formatDate(isoString) {
|
||||
if (!isoString) return '—';
|
||||
const d = new Date(isoString);
|
||||
return d.toLocaleDateString('pt-BR') + ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||
},
|
||||
|
||||
downloadFile(path) {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/files/download?path=${encodeURIComponent(path)}`;
|
||||
a.download = '';
|
||||
a.click();
|
||||
},
|
||||
|
||||
downloadFolder(path) {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/files/download-folder?path=${encodeURIComponent(path)}`;
|
||||
a.download = '';
|
||||
a.click();
|
||||
},
|
||||
|
||||
async publishProject(path) {
|
||||
const name = path.split('/').pop();
|
||||
const confirmed = await Modal.confirm(
|
||||
'Publicar projeto',
|
||||
`Isso irá criar o repositório "${name}" no Gitea, fazer push dos arquivos e publicar em <strong>${name}.nitro-cloud.duckdns.org</strong>. Continuar?`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
Toast.info('Publicando projeto... isso pode levar alguns segundos');
|
||||
const result = await API.files.publish(path);
|
||||
Toast.success(`Projeto publicado com sucesso!`);
|
||||
|
||||
const modal = document.getElementById('execution-detail-modal-overlay');
|
||||
const title = document.getElementById('execution-detail-title');
|
||||
const content = document.getElementById('execution-detail-content');
|
||||
if (modal && title && content) {
|
||||
title.textContent = 'Projeto Publicado';
|
||||
content.innerHTML = `
|
||||
<div class="publish-result">
|
||||
<div class="publish-result-item"><strong>Repositório:</strong> <a href="${Utils.escapeHtml(result.repoUrl)}" target="_blank">${Utils.escapeHtml(result.repoUrl)}</a></div>
|
||||
<div class="publish-result-item"><strong>Site:</strong> <a href="${Utils.escapeHtml(result.siteUrl)}" target="_blank">${Utils.escapeHtml(result.siteUrl)}</a></div>
|
||||
<div class="publish-result-item"><strong>Status:</strong> <span class="badge badge-active">${Utils.escapeHtml(result.status)}</span></div>
|
||||
${result.message ? `<div class="publish-result-item"><em>${Utils.escapeHtml(result.message)}</em></div>` : ''}
|
||||
</div>`;
|
||||
Modal.open('execution-detail-modal-overlay');
|
||||
}
|
||||
|
||||
await FilesUI.navigate(FilesUI.currentPath);
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao publicar: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteEntry(path, entryType) {
|
||||
const label = entryType === 'directory' ? 'pasta' : 'arquivo';
|
||||
const name = path.split('/').pop();
|
||||
const confirmed = await Modal.confirm(
|
||||
`Excluir ${label}`,
|
||||
`Tem certeza que deseja excluir "${name}"? Esta ação não pode ser desfeita.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await API.files.delete(path);
|
||||
Toast.success(`${label.charAt(0).toUpperCase() + label.slice(1)} excluído`);
|
||||
await FilesUI.navigate(FilesUI.currentPath);
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao excluir: ${err.message}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
window.FilesUI = FilesUI;
|
||||
761
public/js/components/flow-editor.js
Normal file
761
public/js/components/flow-editor.js
Normal file
@@ -0,0 +1,761 @@
|
||||
const FlowEditor = {
|
||||
_overlay: null,
|
||||
_canvas: null,
|
||||
_ctx: null,
|
||||
_nodesContainer: null,
|
||||
_pipelineId: null,
|
||||
_pipeline: null,
|
||||
_agents: [],
|
||||
_nodes: [],
|
||||
_dragState: null,
|
||||
_panOffset: { x: 0, y: 0 },
|
||||
_panStart: null,
|
||||
_scale: 1,
|
||||
_selectedNode: null,
|
||||
_editingNode: null,
|
||||
_resizeObserver: null,
|
||||
_animFrame: null,
|
||||
_dirty: false,
|
||||
|
||||
NODE_WIDTH: 240,
|
||||
NODE_HEIGHT: 72,
|
||||
NODE_GAP_Y: 100,
|
||||
START_X: 0,
|
||||
START_Y: 60,
|
||||
|
||||
async open(pipelineId) {
|
||||
try {
|
||||
const [pipeline, agents] = await Promise.all([
|
||||
API.pipelines.get(pipelineId),
|
||||
API.agents.list(),
|
||||
]);
|
||||
|
||||
FlowEditor._pipelineId = pipelineId;
|
||||
FlowEditor._pipeline = pipeline;
|
||||
FlowEditor._agents = Array.isArray(agents) ? agents : [];
|
||||
FlowEditor._selectedNode = null;
|
||||
FlowEditor._editingNode = null;
|
||||
FlowEditor._panOffset = { x: 0, y: 0 };
|
||||
FlowEditor._scale = 1;
|
||||
FlowEditor._dirty = false;
|
||||
|
||||
FlowEditor._buildNodes();
|
||||
FlowEditor._show();
|
||||
FlowEditor._centerView();
|
||||
FlowEditor._render();
|
||||
} catch (err) {
|
||||
Toast.error('Erro ao abrir editor de fluxo: ' + err.message);
|
||||
}
|
||||
},
|
||||
|
||||
_buildNodes() {
|
||||
const steps = Array.isArray(FlowEditor._pipeline.steps) ? FlowEditor._pipeline.steps : [];
|
||||
FlowEditor._nodes = steps.map((step, i) => {
|
||||
const agent = FlowEditor._agents.find((a) => a.id === step.agentId);
|
||||
return {
|
||||
id: step.id || 'step-' + i,
|
||||
index: i,
|
||||
x: 0,
|
||||
y: i * (FlowEditor.NODE_HEIGHT + FlowEditor.NODE_GAP_Y),
|
||||
agentId: step.agentId || '',
|
||||
agentName: agent ? (agent.agent_name || agent.name) : (step.agentName || 'Agente'),
|
||||
inputTemplate: step.inputTemplate || '',
|
||||
requiresApproval: !!step.requiresApproval,
|
||||
description: step.description || '',
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
_show() {
|
||||
let overlay = document.getElementById('flow-editor-overlay');
|
||||
if (!overlay) {
|
||||
FlowEditor._createDOM();
|
||||
overlay = document.getElementById('flow-editor-overlay');
|
||||
}
|
||||
|
||||
FlowEditor._overlay = overlay;
|
||||
FlowEditor._canvas = document.getElementById('flow-editor-canvas');
|
||||
FlowEditor._ctx = FlowEditor._canvas.getContext('2d');
|
||||
FlowEditor._nodesContainer = document.getElementById('flow-editor-nodes');
|
||||
|
||||
const titleEl = document.getElementById('flow-editor-title');
|
||||
if (titleEl) titleEl.textContent = FlowEditor._pipeline.name || 'Pipeline';
|
||||
|
||||
const saveBtn = document.getElementById('flow-editor-save-btn');
|
||||
if (saveBtn) saveBtn.classList.toggle('flow-btn--disabled', true);
|
||||
|
||||
overlay.hidden = false;
|
||||
requestAnimationFrame(() => overlay.classList.add('active'));
|
||||
|
||||
FlowEditor._setupEvents();
|
||||
FlowEditor._resizeCanvas();
|
||||
|
||||
if (!FlowEditor._resizeObserver) {
|
||||
FlowEditor._resizeObserver = new ResizeObserver(() => {
|
||||
FlowEditor._resizeCanvas();
|
||||
FlowEditor._render();
|
||||
});
|
||||
}
|
||||
FlowEditor._resizeObserver.observe(FlowEditor._canvas.parentElement);
|
||||
},
|
||||
|
||||
_createDOM() {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = `
|
||||
<div class="flow-editor-overlay" id="flow-editor-overlay" hidden>
|
||||
<div class="flow-editor">
|
||||
<div class="flow-editor-header">
|
||||
<div class="flow-editor-header-left">
|
||||
<button class="flow-btn flow-btn--ghost" id="flow-editor-close-btn" title="Voltar">
|
||||
<i data-lucide="arrow-left" style="width:18px;height:18px"></i>
|
||||
</button>
|
||||
<div class="flow-editor-title-group">
|
||||
<h2 class="flow-editor-title" id="flow-editor-title">Pipeline</h2>
|
||||
<span class="flow-editor-subtitle">Editor de Fluxo</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-editor-header-actions">
|
||||
<div class="flow-editor-zoom">
|
||||
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-zoom-out" title="Diminuir zoom">
|
||||
<i data-lucide="minus" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<span class="flow-zoom-label" id="flow-zoom-label">100%</span>
|
||||
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-zoom-in" title="Aumentar zoom">
|
||||
<i data-lucide="plus" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-zoom-fit" title="Centralizar">
|
||||
<i data-lucide="maximize-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-add-node-btn" title="Adicionar passo">
|
||||
<i data-lucide="plus-circle" style="width:16px;height:16px"></i>
|
||||
<span>Passo</span>
|
||||
</button>
|
||||
<button class="flow-btn flow-btn--primary flow-btn--disabled" id="flow-editor-save-btn">
|
||||
<i data-lucide="save" style="width:14px;height:14px"></i>
|
||||
<span>Salvar</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-editor-body">
|
||||
<div class="flow-editor-canvas-wrap" id="flow-editor-canvas-wrap">
|
||||
<canvas id="flow-editor-canvas"></canvas>
|
||||
<div class="flow-editor-nodes" id="flow-editor-nodes"></div>
|
||||
</div>
|
||||
<div class="flow-editor-panel" id="flow-editor-panel" hidden>
|
||||
<div class="flow-panel-header">
|
||||
<h3 class="flow-panel-title" id="flow-panel-title">Configuração</h3>
|
||||
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-panel-close" title="Fechar painel">
|
||||
<i data-lucide="x" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flow-panel-body" id="flow-panel-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(div.firstElementChild);
|
||||
},
|
||||
|
||||
_setupEvents() {
|
||||
const wrap = document.getElementById('flow-editor-canvas-wrap');
|
||||
if (!wrap || wrap._flowBound) return;
|
||||
wrap._flowBound = true;
|
||||
|
||||
wrap.addEventListener('pointerdown', FlowEditor._onPointerDown);
|
||||
wrap.addEventListener('pointermove', FlowEditor._onPointerMove);
|
||||
wrap.addEventListener('pointerup', FlowEditor._onPointerUp);
|
||||
wrap.addEventListener('wheel', FlowEditor._onWheel, { passive: false });
|
||||
|
||||
document.getElementById('flow-editor-close-btn')?.addEventListener('click', FlowEditor._close);
|
||||
document.getElementById('flow-editor-save-btn')?.addEventListener('click', FlowEditor._save);
|
||||
document.getElementById('flow-add-node-btn')?.addEventListener('click', FlowEditor._addNode);
|
||||
document.getElementById('flow-zoom-in')?.addEventListener('click', () => FlowEditor._zoom(0.1));
|
||||
document.getElementById('flow-zoom-out')?.addEventListener('click', () => FlowEditor._zoom(-0.1));
|
||||
document.getElementById('flow-zoom-fit')?.addEventListener('click', () => FlowEditor._centerView());
|
||||
document.getElementById('flow-panel-close')?.addEventListener('click', FlowEditor._closePanel);
|
||||
|
||||
document.addEventListener('keydown', FlowEditor._onKeyDown);
|
||||
},
|
||||
|
||||
_resizeCanvas() {
|
||||
const wrap = document.getElementById('flow-editor-canvas-wrap');
|
||||
const canvas = FlowEditor._canvas;
|
||||
if (!wrap || !canvas) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = wrap.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
canvas.style.width = rect.width + 'px';
|
||||
canvas.style.height = rect.height + 'px';
|
||||
FlowEditor._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
},
|
||||
|
||||
_render() {
|
||||
if (FlowEditor._animFrame) cancelAnimationFrame(FlowEditor._animFrame);
|
||||
FlowEditor._animFrame = requestAnimationFrame(FlowEditor._draw);
|
||||
},
|
||||
|
||||
_draw() {
|
||||
const ctx = FlowEditor._ctx;
|
||||
const canvas = FlowEditor._canvas;
|
||||
if (!ctx || !canvas) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.width / dpr;
|
||||
const h = canvas.height / dpr;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.save();
|
||||
ctx.translate(FlowEditor._panOffset.x, FlowEditor._panOffset.y);
|
||||
ctx.scale(FlowEditor._scale, FlowEditor._scale);
|
||||
|
||||
FlowEditor._drawGrid(ctx, w, h);
|
||||
FlowEditor._drawConnections(ctx);
|
||||
|
||||
ctx.restore();
|
||||
FlowEditor._renderNodes();
|
||||
},
|
||||
|
||||
_drawGrid(ctx, w, h) {
|
||||
const scale = FlowEditor._scale;
|
||||
const ox = FlowEditor._panOffset.x;
|
||||
const oy = FlowEditor._panOffset.y;
|
||||
const gridSize = 24;
|
||||
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
|
||||
ctx.lineWidth = 1 / scale;
|
||||
|
||||
const startX = Math.floor(-ox / scale / gridSize) * gridSize;
|
||||
const startY = Math.floor(-oy / scale / gridSize) * gridSize;
|
||||
const endX = startX + w / scale + gridSize * 2;
|
||||
const endY = startY + h / scale + gridSize * 2;
|
||||
|
||||
ctx.beginPath();
|
||||
for (let x = startX; x < endX; x += gridSize) {
|
||||
ctx.moveTo(x, startY);
|
||||
ctx.lineTo(x, endY);
|
||||
}
|
||||
for (let y = startY; y < endY; y += gridSize) {
|
||||
ctx.moveTo(startX, y);
|
||||
ctx.lineTo(endX, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
},
|
||||
|
||||
_drawConnections(ctx) {
|
||||
const nodes = FlowEditor._nodes;
|
||||
const nw = FlowEditor.NODE_WIDTH;
|
||||
const nh = FlowEditor.NODE_HEIGHT;
|
||||
|
||||
for (let i = 0; i < nodes.length - 1; i++) {
|
||||
const a = nodes[i];
|
||||
const b = nodes[i + 1];
|
||||
|
||||
const ax = a.x + nw / 2;
|
||||
const ay = a.y + nh;
|
||||
const bx = b.x + nw / 2;
|
||||
const by = b.y;
|
||||
|
||||
const midY = (ay + by) / 2;
|
||||
|
||||
const grad = ctx.createLinearGradient(ax, ay, bx, by);
|
||||
grad.addColorStop(0, 'rgba(99,102,241,0.6)');
|
||||
grad.addColorStop(1, 'rgba(139,92,246,0.6)');
|
||||
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ax, ay);
|
||||
ctx.bezierCurveTo(ax, midY, bx, midY, bx, by);
|
||||
ctx.stroke();
|
||||
|
||||
const arrowSize = 6;
|
||||
const angle = Math.atan2(by - midY, bx - bx) || Math.PI / 2;
|
||||
ctx.fillStyle = 'rgba(139,92,246,0.8)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx, by);
|
||||
ctx.lineTo(bx - arrowSize * Math.cos(angle - 0.4), by - arrowSize * Math.sin(angle - 0.4));
|
||||
ctx.lineTo(bx - arrowSize * Math.cos(angle + 0.4), by - arrowSize * Math.sin(angle + 0.4));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
if (b.requiresApproval) {
|
||||
const iconX = (ax + bx) / 2;
|
||||
const iconY = midY;
|
||||
ctx.fillStyle = '#0a0a0f';
|
||||
ctx.beginPath();
|
||||
ctx.arc(iconX, iconY, 10, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(245,158,11,0.8)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#f59e0b';
|
||||
ctx.font = 'bold 10px Inter, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('!', iconX, iconY);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_renderNodes() {
|
||||
const container = FlowEditor._nodesContainer;
|
||||
if (!container) return;
|
||||
|
||||
const ox = FlowEditor._panOffset.x;
|
||||
const oy = FlowEditor._panOffset.y;
|
||||
const scale = FlowEditor._scale;
|
||||
|
||||
let existingEls = container.querySelectorAll('.flow-node');
|
||||
const existingMap = {};
|
||||
existingEls.forEach((el) => { existingMap[el.dataset.nodeId] = el; });
|
||||
|
||||
FlowEditor._nodes.forEach((node, i) => {
|
||||
const screenX = node.x * scale + ox;
|
||||
const screenY = node.y * scale + oy;
|
||||
const isSelected = FlowEditor._selectedNode === i;
|
||||
|
||||
let el = existingMap[node.id];
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.className = 'flow-node';
|
||||
el.dataset.nodeId = node.id;
|
||||
el.dataset.nodeIndex = i;
|
||||
container.appendChild(el);
|
||||
}
|
||||
|
||||
el.dataset.nodeIndex = i;
|
||||
el.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`;
|
||||
el.style.width = FlowEditor.NODE_WIDTH + 'px';
|
||||
el.style.height = FlowEditor.NODE_HEIGHT + 'px';
|
||||
el.classList.toggle('flow-node--selected', isSelected);
|
||||
|
||||
const stepNum = i + 1;
|
||||
const name = Utils.escapeHtml(node.agentName || 'Selecionar agente...');
|
||||
const approvalBadge = node.requiresApproval && i > 0
|
||||
? '<span class="flow-node-approval">Aprovação</span>'
|
||||
: '';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="flow-node-header">
|
||||
<span class="flow-node-number">${stepNum}</span>
|
||||
<span class="flow-node-name" title="${name}">${name}</span>
|
||||
${approvalBadge}
|
||||
</div>
|
||||
<div class="flow-node-sub">
|
||||
${node.inputTemplate ? Utils.escapeHtml(Utils.truncate(node.inputTemplate, 40)) : '<span class="flow-node-placeholder">Sem template de input</span>'}
|
||||
</div>
|
||||
`;
|
||||
|
||||
delete existingMap[node.id];
|
||||
});
|
||||
|
||||
Object.values(existingMap).forEach((el) => el.remove());
|
||||
},
|
||||
|
||||
_centerView() {
|
||||
const canvas = FlowEditor._canvas;
|
||||
if (!canvas || FlowEditor._nodes.length === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.width / dpr;
|
||||
const h = canvas.height / dpr;
|
||||
|
||||
const nw = FlowEditor.NODE_WIDTH;
|
||||
const nh = FlowEditor.NODE_HEIGHT;
|
||||
const nodes = FlowEditor._nodes;
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
nodes.forEach((n) => {
|
||||
minX = Math.min(minX, n.x);
|
||||
minY = Math.min(minY, n.y);
|
||||
maxX = Math.max(maxX, n.x + nw);
|
||||
maxY = Math.max(maxY, n.y + nh);
|
||||
});
|
||||
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
const padding = 80;
|
||||
const scaleX = (w - padding * 2) / contentW;
|
||||
const scaleY = (h - padding * 2) / contentH;
|
||||
const scale = Math.min(Math.max(Math.min(scaleX, scaleY), 0.3), 1.5);
|
||||
|
||||
FlowEditor._scale = scale;
|
||||
FlowEditor._panOffset = {
|
||||
x: (w - contentW * scale) / 2 - minX * scale,
|
||||
y: (h - contentH * scale) / 2 - minY * scale,
|
||||
};
|
||||
|
||||
FlowEditor._updateZoomLabel();
|
||||
FlowEditor._render();
|
||||
},
|
||||
|
||||
_zoom(delta) {
|
||||
const oldScale = FlowEditor._scale;
|
||||
FlowEditor._scale = Math.min(Math.max(oldScale + delta, 0.2), 2.5);
|
||||
FlowEditor._updateZoomLabel();
|
||||
FlowEditor._render();
|
||||
},
|
||||
|
||||
_updateZoomLabel() {
|
||||
const el = document.getElementById('flow-zoom-label');
|
||||
if (el) el.textContent = Math.round(FlowEditor._scale * 100) + '%';
|
||||
},
|
||||
|
||||
_onPointerDown(e) {
|
||||
const nodeEl = e.target.closest('.flow-node');
|
||||
|
||||
if (nodeEl) {
|
||||
const idx = parseInt(nodeEl.dataset.nodeIndex, 10);
|
||||
FlowEditor._selectedNode = idx;
|
||||
|
||||
if (e.detail === 2) {
|
||||
FlowEditor._openNodePanel(idx);
|
||||
FlowEditor._render();
|
||||
return;
|
||||
}
|
||||
|
||||
const node = FlowEditor._nodes[idx];
|
||||
FlowEditor._dragState = {
|
||||
type: 'node',
|
||||
index: idx,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origX: node.x,
|
||||
origY: node.y,
|
||||
moved: false,
|
||||
};
|
||||
|
||||
nodeEl.setPointerCapture(e.pointerId);
|
||||
FlowEditor._render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target.closest('.flow-editor-panel') || e.target.closest('.flow-editor-header')) return;
|
||||
|
||||
FlowEditor._selectedNode = null;
|
||||
FlowEditor._panStart = {
|
||||
x: e.clientX - FlowEditor._panOffset.x,
|
||||
y: e.clientY - FlowEditor._panOffset.y,
|
||||
};
|
||||
|
||||
FlowEditor._render();
|
||||
},
|
||||
|
||||
_onPointerMove(e) {
|
||||
if (FlowEditor._dragState) {
|
||||
const ds = FlowEditor._dragState;
|
||||
const dx = (e.clientX - ds.startX) / FlowEditor._scale;
|
||||
const dy = (e.clientY - ds.startY) / FlowEditor._scale;
|
||||
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) ds.moved = true;
|
||||
|
||||
FlowEditor._nodes[ds.index].x = ds.origX + dx;
|
||||
FlowEditor._nodes[ds.index].y = ds.origY + dy;
|
||||
FlowEditor._render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (FlowEditor._panStart) {
|
||||
FlowEditor._panOffset.x = e.clientX - FlowEditor._panStart.x;
|
||||
FlowEditor._panOffset.y = e.clientY - FlowEditor._panStart.y;
|
||||
FlowEditor._render();
|
||||
}
|
||||
},
|
||||
|
||||
_onPointerUp(e) {
|
||||
if (FlowEditor._dragState) {
|
||||
const ds = FlowEditor._dragState;
|
||||
if (!ds.moved) {
|
||||
FlowEditor._openNodePanel(ds.index);
|
||||
} else {
|
||||
FlowEditor._markDirty();
|
||||
}
|
||||
FlowEditor._dragState = null;
|
||||
FlowEditor._render();
|
||||
return;
|
||||
}
|
||||
|
||||
FlowEditor._panStart = null;
|
||||
},
|
||||
|
||||
_onWheel(e) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.08 : 0.08;
|
||||
const oldScale = FlowEditor._scale;
|
||||
const newScale = Math.min(Math.max(oldScale + delta, 0.2), 2.5);
|
||||
|
||||
const rect = FlowEditor._canvas.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
FlowEditor._panOffset.x = mx - (mx - FlowEditor._panOffset.x) * (newScale / oldScale);
|
||||
FlowEditor._panOffset.y = my - (my - FlowEditor._panOffset.y) * (newScale / oldScale);
|
||||
FlowEditor._scale = newScale;
|
||||
|
||||
FlowEditor._updateZoomLabel();
|
||||
FlowEditor._render();
|
||||
},
|
||||
|
||||
_onKeyDown(e) {
|
||||
if (!FlowEditor._overlay || FlowEditor._overlay.hidden) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (FlowEditor._editingNode !== null) {
|
||||
FlowEditor._closePanel();
|
||||
} else {
|
||||
FlowEditor._close();
|
||||
}
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' && FlowEditor._selectedNode !== null && FlowEditor._editingNode === null) {
|
||||
FlowEditor._removeNode(FlowEditor._selectedNode);
|
||||
}
|
||||
},
|
||||
|
||||
_openNodePanel(index) {
|
||||
const node = FlowEditor._nodes[index];
|
||||
if (!node) return;
|
||||
|
||||
FlowEditor._editingNode = index;
|
||||
FlowEditor._selectedNode = index;
|
||||
|
||||
const panel = document.getElementById('flow-editor-panel');
|
||||
const title = document.getElementById('flow-panel-title');
|
||||
const body = document.getElementById('flow-panel-body');
|
||||
if (!panel || !body) return;
|
||||
|
||||
if (title) title.textContent = `Passo ${index + 1}`;
|
||||
panel.hidden = false;
|
||||
|
||||
const agentOptions = FlowEditor._agents
|
||||
.map((a) => {
|
||||
const aName = Utils.escapeHtml(a.agent_name || a.name);
|
||||
const selected = a.id === node.agentId ? 'selected' : '';
|
||||
return `<option value="${a.id}" ${selected}>${aName}</option>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const approvalChecked = node.requiresApproval ? 'checked' : '';
|
||||
const showApproval = index > 0;
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="flow-panel-field">
|
||||
<label class="flow-panel-label">Agente</label>
|
||||
<select class="flow-panel-select" id="flow-panel-agent">
|
||||
<option value="">Selecionar agente...</option>
|
||||
${agentOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flow-panel-field">
|
||||
<label class="flow-panel-label">Template de Input</label>
|
||||
<textarea class="flow-panel-textarea" id="flow-panel-template" rows="4" placeholder="{{input}} será substituído pelo output anterior">${Utils.escapeHtml(node.inputTemplate || '')}</textarea>
|
||||
<span class="flow-panel-hint">Use <code>{{input}}</code> para referenciar o output do passo anterior</span>
|
||||
</div>
|
||||
${showApproval ? `
|
||||
<div class="flow-panel-field">
|
||||
<label class="flow-panel-checkbox">
|
||||
<input type="checkbox" id="flow-panel-approval" ${approvalChecked} />
|
||||
<span>Requer aprovação antes de executar</span>
|
||||
</label>
|
||||
</div>` : ''}
|
||||
<div class="flow-panel-field flow-panel-actions-group">
|
||||
<button class="flow-btn flow-btn--ghost flow-btn--sm flow-btn--full" id="flow-panel-move-up" ${index === 0 ? 'disabled' : ''}>
|
||||
<i data-lucide="chevron-up" style="width:14px;height:14px"></i> Mover acima
|
||||
</button>
|
||||
<button class="flow-btn flow-btn--ghost flow-btn--sm flow-btn--full" id="flow-panel-move-down" ${index === FlowEditor._nodes.length - 1 ? 'disabled' : ''}>
|
||||
<i data-lucide="chevron-down" style="width:14px;height:14px"></i> Mover abaixo
|
||||
</button>
|
||||
<button class="flow-btn flow-btn--danger flow-btn--sm flow-btn--full" id="flow-panel-delete">
|
||||
<i data-lucide="trash-2" style="width:14px;height:14px"></i> Remover passo
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Utils.refreshIcons(body);
|
||||
|
||||
document.getElementById('flow-panel-agent')?.addEventListener('change', (ev) => {
|
||||
const val = ev.target.value;
|
||||
node.agentId = val;
|
||||
const agent = FlowEditor._agents.find((a) => a.id === val);
|
||||
node.agentName = agent ? (agent.agent_name || agent.name) : 'Selecionar agente...';
|
||||
FlowEditor._markDirty();
|
||||
FlowEditor._render();
|
||||
});
|
||||
|
||||
document.getElementById('flow-panel-template')?.addEventListener('input', (ev) => {
|
||||
node.inputTemplate = ev.target.value;
|
||||
FlowEditor._markDirty();
|
||||
FlowEditor._render();
|
||||
});
|
||||
|
||||
document.getElementById('flow-panel-approval')?.addEventListener('change', (ev) => {
|
||||
node.requiresApproval = ev.target.checked;
|
||||
FlowEditor._markDirty();
|
||||
FlowEditor._render();
|
||||
});
|
||||
|
||||
document.getElementById('flow-panel-move-up')?.addEventListener('click', () => {
|
||||
FlowEditor._swapNodes(index, index - 1);
|
||||
});
|
||||
|
||||
document.getElementById('flow-panel-move-down')?.addEventListener('click', () => {
|
||||
FlowEditor._swapNodes(index, index + 1);
|
||||
});
|
||||
|
||||
document.getElementById('flow-panel-delete')?.addEventListener('click', () => {
|
||||
FlowEditor._removeNode(index);
|
||||
});
|
||||
},
|
||||
|
||||
_closePanel() {
|
||||
const panel = document.getElementById('flow-editor-panel');
|
||||
if (panel) panel.hidden = true;
|
||||
FlowEditor._editingNode = null;
|
||||
},
|
||||
|
||||
_addNode() {
|
||||
const lastNode = FlowEditor._nodes[FlowEditor._nodes.length - 1];
|
||||
const newY = lastNode
|
||||
? lastNode.y + FlowEditor.NODE_HEIGHT + FlowEditor.NODE_GAP_Y
|
||||
: FlowEditor.START_Y;
|
||||
const newX = lastNode ? lastNode.x : FlowEditor.START_X;
|
||||
|
||||
FlowEditor._nodes.push({
|
||||
id: 'step-new-' + Date.now(),
|
||||
index: FlowEditor._nodes.length,
|
||||
x: newX,
|
||||
y: newY,
|
||||
agentId: '',
|
||||
agentName: 'Selecionar agente...',
|
||||
inputTemplate: '',
|
||||
requiresApproval: false,
|
||||
description: '',
|
||||
});
|
||||
|
||||
FlowEditor._markDirty();
|
||||
FlowEditor._render();
|
||||
|
||||
const newIdx = FlowEditor._nodes.length - 1;
|
||||
FlowEditor._selectedNode = newIdx;
|
||||
FlowEditor._openNodePanel(newIdx);
|
||||
},
|
||||
|
||||
_removeNode(index) {
|
||||
if (FlowEditor._nodes.length <= 2) {
|
||||
Toast.warning('O pipeline precisa de pelo menos 2 passos');
|
||||
return;
|
||||
}
|
||||
|
||||
FlowEditor._nodes.splice(index, 1);
|
||||
FlowEditor._nodes.forEach((n, i) => { n.index = i; });
|
||||
|
||||
if (FlowEditor._editingNode === index) FlowEditor._closePanel();
|
||||
if (FlowEditor._selectedNode === index) FlowEditor._selectedNode = null;
|
||||
|
||||
FlowEditor._markDirty();
|
||||
FlowEditor._render();
|
||||
},
|
||||
|
||||
_swapNodes(a, b) {
|
||||
if (b < 0 || b >= FlowEditor._nodes.length) return;
|
||||
|
||||
const tempX = FlowEditor._nodes[a].x;
|
||||
const tempY = FlowEditor._nodes[a].y;
|
||||
FlowEditor._nodes[a].x = FlowEditor._nodes[b].x;
|
||||
FlowEditor._nodes[a].y = FlowEditor._nodes[b].y;
|
||||
FlowEditor._nodes[b].x = tempX;
|
||||
FlowEditor._nodes[b].y = tempY;
|
||||
|
||||
const temp = FlowEditor._nodes[a];
|
||||
FlowEditor._nodes[a] = FlowEditor._nodes[b];
|
||||
FlowEditor._nodes[b] = temp;
|
||||
|
||||
FlowEditor._nodes.forEach((n, i) => { n.index = i; });
|
||||
|
||||
FlowEditor._selectedNode = b;
|
||||
FlowEditor._editingNode = b;
|
||||
FlowEditor._markDirty();
|
||||
FlowEditor._openNodePanel(b);
|
||||
FlowEditor._render();
|
||||
},
|
||||
|
||||
_markDirty() {
|
||||
FlowEditor._dirty = true;
|
||||
const btn = document.getElementById('flow-editor-save-btn');
|
||||
if (btn) btn.classList.remove('flow-btn--disabled');
|
||||
},
|
||||
|
||||
async _save() {
|
||||
if (!FlowEditor._dirty) return;
|
||||
|
||||
const invalidNode = FlowEditor._nodes.find((n) => !n.agentId);
|
||||
if (invalidNode) {
|
||||
Toast.warning('Todos os passos devem ter um agente selecionado');
|
||||
return;
|
||||
}
|
||||
|
||||
if (FlowEditor._nodes.length < 2) {
|
||||
Toast.warning('O pipeline precisa de pelo menos 2 passos');
|
||||
return;
|
||||
}
|
||||
|
||||
const steps = FlowEditor._nodes.map((n) => ({
|
||||
agentId: n.agentId,
|
||||
inputTemplate: n.inputTemplate || '',
|
||||
requiresApproval: !!n.requiresApproval,
|
||||
}));
|
||||
|
||||
try {
|
||||
await API.pipelines.update(FlowEditor._pipelineId, {
|
||||
name: FlowEditor._pipeline.name,
|
||||
description: FlowEditor._pipeline.description,
|
||||
steps,
|
||||
});
|
||||
|
||||
FlowEditor._dirty = false;
|
||||
const btn = document.getElementById('flow-editor-save-btn');
|
||||
if (btn) btn.classList.add('flow-btn--disabled');
|
||||
|
||||
Toast.success('Pipeline atualizado com sucesso');
|
||||
|
||||
if (typeof PipelinesUI !== 'undefined') PipelinesUI.load();
|
||||
} catch (err) {
|
||||
Toast.error('Erro ao salvar: ' + err.message);
|
||||
}
|
||||
},
|
||||
|
||||
_close() {
|
||||
if (FlowEditor._dirty) {
|
||||
const leave = confirm('Existem alterações não salvas. Deseja sair mesmo assim?');
|
||||
if (!leave) return;
|
||||
}
|
||||
|
||||
const overlay = FlowEditor._overlay;
|
||||
if (!overlay) return;
|
||||
|
||||
overlay.classList.remove('active');
|
||||
setTimeout(() => { overlay.hidden = true; }, 200);
|
||||
|
||||
FlowEditor._closePanel();
|
||||
|
||||
if (FlowEditor._resizeObserver) {
|
||||
FlowEditor._resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
document.removeEventListener('keydown', FlowEditor._onKeyDown);
|
||||
|
||||
FlowEditor._editingNode = null;
|
||||
FlowEditor._selectedNode = null;
|
||||
FlowEditor._dragState = null;
|
||||
FlowEditor._panStart = null;
|
||||
},
|
||||
};
|
||||
|
||||
window.FlowEditor = FlowEditor;
|
||||
@@ -7,7 +7,17 @@ const HistoryUI = {
|
||||
_currentType: '',
|
||||
_currentStatus: '',
|
||||
|
||||
_exportListenerAdded: false,
|
||||
|
||||
async load() {
|
||||
if (!HistoryUI._exportListenerAdded) {
|
||||
HistoryUI._exportListenerAdded = true;
|
||||
const exportBtn = document.getElementById('history-export-csv');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', () => API.executions.exportCsv());
|
||||
}
|
||||
}
|
||||
|
||||
const params = { limit: HistoryUI.pageSize, offset: HistoryUI.page * HistoryUI.pageSize };
|
||||
if (HistoryUI._currentType) params.type = HistoryUI._currentType;
|
||||
if (HistoryUI._currentStatus) params.status = HistoryUI._currentStatus;
|
||||
@@ -38,12 +48,12 @@ const HistoryUI = {
|
||||
<p class="empty-state-text">O histórico de execuções aparecerá aqui.</p>
|
||||
</div>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = HistoryUI.executions.map((exec) => HistoryUI._renderCard(exec)).join('');
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
_renderCard(exec) {
|
||||
@@ -55,9 +65,10 @@ const HistoryUI = {
|
||||
const name = exec.type === 'pipeline'
|
||||
? (exec.pipelineName || 'Pipeline')
|
||||
: (exec.agentName || 'Agente');
|
||||
const task = exec.type === 'pipeline'
|
||||
const taskRaw = exec.type === 'pipeline'
|
||||
? (exec.input || '')
|
||||
: (exec.task || '');
|
||||
const task = taskRaw.length > 150 ? taskRaw.slice(0, 150) + '…' : taskRaw;
|
||||
const date = HistoryUI._formatDate(exec.startedAt);
|
||||
const duration = HistoryUI._formatDuration(exec.startedAt, exec.endedAt);
|
||||
const cost = exec.costUsd || exec.totalCostUsd || 0;
|
||||
@@ -70,28 +81,36 @@ const HistoryUI = {
|
||||
<div class="history-card-header">
|
||||
<div class="history-card-identity">
|
||||
${typeBadge}
|
||||
<span class="history-card-name">${HistoryUI._escapeHtml(name)}</span>
|
||||
</div>
|
||||
<div class="history-card-status">
|
||||
<span class="history-card-name">${Utils.escapeHtml(name)}</span>
|
||||
${statusBadge}
|
||||
<span class="history-card-date">${date}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-card-meta">
|
||||
<span class="history-card-task">${HistoryUI._escapeHtml(task)}</span>
|
||||
<span class="history-card-duration-group">
|
||||
<span class="history-card-duration">
|
||||
<i data-lucide="clock" aria-hidden="true"></i>
|
||||
${duration}
|
||||
</span>
|
||||
${costHtml}
|
||||
<div class="history-card-task" title="${Utils.escapeHtml(taskRaw)}">${Utils.escapeHtml(task)}</div>
|
||||
<div class="history-card-info">
|
||||
<span class="history-card-date">
|
||||
<i data-lucide="calendar" aria-hidden="true"></i>
|
||||
${date}
|
||||
</span>
|
||||
<span class="history-card-duration">
|
||||
<i data-lucide="clock" aria-hidden="true"></i>
|
||||
${duration}
|
||||
</span>
|
||||
${costHtml}
|
||||
</div>
|
||||
<div class="history-card-actions">
|
||||
<button class="btn btn-ghost btn-sm" data-action="view-execution" data-id="${exec.id}" type="button">
|
||||
<i data-lucide="eye"></i>
|
||||
Ver detalhes
|
||||
</button>
|
||||
${(exec.status === 'error' && exec.type === 'pipeline') ? `
|
||||
<button class="btn btn-ghost btn-sm" data-action="resume-pipeline" data-id="${exec.id}" type="button" title="Retomar do passo ${(exec.failedAtStep || 0) + 1}">
|
||||
<i data-lucide="play"></i>
|
||||
Retomar
|
||||
</button>` : ''}
|
||||
${(exec.status === 'error' || exec.status === 'canceled') ? `
|
||||
<button class="btn btn-ghost btn-sm" data-action="retry" data-id="${exec.id}" type="button" title="Reexecutar">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
</button>` : ''}
|
||||
<button class="btn btn-ghost btn-sm btn-danger" data-action="delete-execution" data-id="${exec.id}" type="button" aria-label="Excluir execução">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
@@ -132,7 +151,7 @@ const HistoryUI = {
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
|
||||
document.getElementById('history-prev-btn')?.addEventListener('click', () => {
|
||||
HistoryUI.page--;
|
||||
@@ -172,7 +191,11 @@ const HistoryUI = {
|
||||
: HistoryUI._renderAgentDetail(exec);
|
||||
|
||||
Modal.open('execution-detail-modal-overlay');
|
||||
if (window.lucide) lucide.createIcons({ nodes: [content] });
|
||||
Utils.refreshIcons(content);
|
||||
|
||||
content.querySelector('[data-action="download-result-md"]')?.addEventListener('click', () => {
|
||||
HistoryUI._downloadResultMd(exec);
|
||||
});
|
||||
|
||||
content.querySelectorAll('.pipeline-step-prompt-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
@@ -195,18 +218,24 @@ const HistoryUI = {
|
||||
const endDate = exec.endedAt ? HistoryUI._formatDate(exec.endedAt) : '—';
|
||||
|
||||
const resultBlock = exec.result
|
||||
? `<div class="execution-result" role="region" aria-label="Resultado da execução">${HistoryUI._escapeHtml(exec.result)}</div>`
|
||||
? `<div class="execution-result" role="region" aria-label="Resultado da execução">${Utils.escapeHtml(exec.result)}</div>`
|
||||
: '';
|
||||
|
||||
const errorBlock = exec.error
|
||||
? `<div class="execution-result execution-result--error" role="alert">${HistoryUI._escapeHtml(exec.error)}</div>`
|
||||
? `<div class="execution-result execution-result--error" role="alert">${Utils.escapeHtml(exec.error)}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
${exec.result ? `
|
||||
<div class="report-actions">
|
||||
<button class="btn btn-ghost btn-sm" data-action="download-result-md" type="button">
|
||||
<i data-lucide="download"></i> Download .md
|
||||
</button>
|
||||
</div>` : ''}
|
||||
<div class="execution-detail-meta">
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Agente</span>
|
||||
<span class="execution-detail-value">${HistoryUI._escapeHtml(exec.agentName || exec.agentId || '—')}</span>
|
||||
<span class="execution-detail-value">${Utils.escapeHtml(exec.agentName || exec.agentId || '—')}</span>
|
||||
</div>
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Status</span>
|
||||
@@ -243,7 +272,7 @@ const HistoryUI = {
|
||||
${exec.task ? `
|
||||
<div class="execution-detail-section">
|
||||
<h3 class="execution-detail-section-title">Tarefa</h3>
|
||||
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.task)}</p>
|
||||
<p class="execution-detail-task">${Utils.escapeHtml(exec.task)}</p>
|
||||
</div>` : ''}
|
||||
${resultBlock ? `
|
||||
<div class="execution-detail-section">
|
||||
@@ -279,7 +308,7 @@ const HistoryUI = {
|
||||
<div class="pipeline-step-detail">
|
||||
<div class="pipeline-step-header">
|
||||
<div class="pipeline-step-identity">
|
||||
<span class="pipeline-step-agent">${HistoryUI._escapeHtml(step.agentName || step.agentId || 'Agente')}</span>
|
||||
<span class="pipeline-step-agent">${Utils.escapeHtml(step.agentName || step.agentId || 'Agente')}</span>
|
||||
${HistoryUI._statusBadge(step.status)}
|
||||
</div>
|
||||
<span class="pipeline-step-meta-group">
|
||||
@@ -297,13 +326,13 @@ const HistoryUI = {
|
||||
Prompt utilizado
|
||||
</button>
|
||||
<div class="pipeline-step-prompt-body" hidden>
|
||||
<div class="execution-result execution-result--prompt">${HistoryUI._escapeHtml(step.prompt)}</div>
|
||||
<div class="execution-result execution-result--prompt">${Utils.escapeHtml(step.prompt)}</div>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
${step.result ? `
|
||||
<div class="pipeline-step-result">
|
||||
<span class="pipeline-step-result-label">Resultado</span>
|
||||
<div class="execution-result">${HistoryUI._escapeHtml(step.result)}</div>
|
||||
<div class="execution-result">${Utils.escapeHtml(step.result)}</div>
|
||||
</div>` : ''}
|
||||
${step.status === 'error' ? `
|
||||
<div class="execution-result execution-result--error">Passo falhou.</div>` : ''}
|
||||
@@ -312,11 +341,18 @@ const HistoryUI = {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const hasResults = steps.some(s => s.result);
|
||||
return `
|
||||
${hasResults ? `
|
||||
<div class="report-actions">
|
||||
<button class="btn btn-ghost btn-sm" data-action="download-result-md" type="button">
|
||||
<i data-lucide="download"></i> Download .md
|
||||
</button>
|
||||
</div>` : ''}
|
||||
<div class="execution-detail-meta">
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Pipeline</span>
|
||||
<span class="execution-detail-value">${HistoryUI._escapeHtml(exec.pipelineName || exec.pipelineId || '—')}</span>
|
||||
<span class="execution-detail-value">${Utils.escapeHtml(exec.pipelineName || exec.pipelineId || '—')}</span>
|
||||
</div>
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Status</span>
|
||||
@@ -343,7 +379,7 @@ const HistoryUI = {
|
||||
${exec.input ? `
|
||||
<div class="execution-detail-section">
|
||||
<h3 class="execution-detail-section-title">Input Inicial</h3>
|
||||
<p class="execution-detail-task">${HistoryUI._escapeHtml(exec.input)}</p>
|
||||
<p class="execution-detail-task">${Utils.escapeHtml(exec.input)}</p>
|
||||
</div>` : ''}
|
||||
${steps.length > 0 ? `
|
||||
<div class="execution-detail-section">
|
||||
@@ -355,11 +391,61 @@ const HistoryUI = {
|
||||
${exec.error ? `
|
||||
<div class="execution-detail-section">
|
||||
<h3 class="execution-detail-section-title">Erro</h3>
|
||||
<div class="execution-result execution-result--error">${HistoryUI._escapeHtml(exec.error)}</div>
|
||||
<div class="execution-result execution-result--error">${Utils.escapeHtml(exec.error)}</div>
|
||||
</div>` : ''}
|
||||
`;
|
||||
},
|
||||
|
||||
_downloadResultMd(exec) {
|
||||
let md = '';
|
||||
const name = exec.type === 'pipeline'
|
||||
? (exec.pipelineName || 'Pipeline')
|
||||
: (exec.agentName || 'Agente');
|
||||
|
||||
if (exec.type === 'pipeline') {
|
||||
md += `# ${name}\n\n`;
|
||||
const steps = Array.isArray(exec.steps) ? exec.steps : [];
|
||||
steps.forEach((step, i) => {
|
||||
md += `## Passo ${i + 1} — ${step.agentName || 'Agente'}\n\n`;
|
||||
if (step.result) md += `${step.result}\n\n`;
|
||||
});
|
||||
} else {
|
||||
md += exec.result || '';
|
||||
}
|
||||
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
const filename = `${slug}-${new Date(exec.startedAt || Date.now()).toISOString().slice(0, 10)}.md`;
|
||||
|
||||
const blob = new Blob([md], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
Toast.success('Download iniciado');
|
||||
},
|
||||
|
||||
async resumePipeline(executionId) {
|
||||
try {
|
||||
await API.pipelines.resume(executionId);
|
||||
Toast.info('Pipeline retomado');
|
||||
App.navigateTo('terminal');
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao retomar pipeline: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async retryExecution(id) {
|
||||
try {
|
||||
await API.executions.retry(id);
|
||||
Toast.success('Execução reiniciada');
|
||||
App.navigateTo('terminal');
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao reexecutar: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteExecution(id) {
|
||||
const confirmed = await Modal.confirm(
|
||||
'Excluir execução',
|
||||
@@ -435,15 +521,6 @@ const HistoryUI = {
|
||||
});
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
};
|
||||
|
||||
window.HistoryUI = HistoryUI;
|
||||
|
||||
153
public/js/components/notifications.js
Normal file
153
public/js/components/notifications.js
Normal file
@@ -0,0 +1,153 @@
|
||||
const NotificationsUI = {
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
pollInterval: null,
|
||||
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.startPolling();
|
||||
},
|
||||
|
||||
setupEventListeners() {
|
||||
const bell = document.getElementById('notification-bell');
|
||||
const panel = document.getElementById('notification-panel');
|
||||
|
||||
if (bell) {
|
||||
bell.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
panel.classList.toggle('hidden');
|
||||
if (!panel.classList.contains('hidden')) this.load();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (panel && !panel.contains(e.target) && e.target !== bell) {
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
const markAllBtn = document.getElementById('mark-all-read');
|
||||
if (markAllBtn) {
|
||||
markAllBtn.addEventListener('click', () => this.markAllRead());
|
||||
}
|
||||
|
||||
const clearBtn = document.getElementById('clear-notifications');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => this.clearAll());
|
||||
}
|
||||
},
|
||||
|
||||
startPolling() {
|
||||
this.pollInterval = setInterval(() => this.loadCount(), 15000);
|
||||
this.loadCount();
|
||||
},
|
||||
|
||||
async loadCount() {
|
||||
try {
|
||||
const data = await API.request('GET', '/notifications');
|
||||
this.unreadCount = data.unreadCount || 0;
|
||||
this.updateBadge();
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const data = await API.request('GET', '/notifications');
|
||||
this.notifications = data.notifications || [];
|
||||
this.unreadCount = data.unreadCount || 0;
|
||||
this.updateBadge();
|
||||
this.render();
|
||||
} catch (e) {
|
||||
console.error('Erro ao carregar notificações:', e);
|
||||
}
|
||||
},
|
||||
|
||||
updateBadge() {
|
||||
const badge = document.getElementById('notification-badge');
|
||||
if (!badge) return;
|
||||
if (this.unreadCount > 0) {
|
||||
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
},
|
||||
|
||||
render() {
|
||||
const list = document.getElementById('notification-list');
|
||||
if (!list) return;
|
||||
|
||||
if (this.notifications.length === 0) {
|
||||
list.innerHTML = '<div class="notification-empty">Nenhuma notificação</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = this.notifications.map(n => {
|
||||
const iconClass = n.type === 'success' ? 'success' : n.type === 'error' ? 'error' : 'info';
|
||||
const icon = n.type === 'success' ? '✓' : n.type === 'error' ? '✕' : 'ℹ';
|
||||
const time = this.timeAgo(n.createdAt);
|
||||
const unread = n.read ? '' : ' unread';
|
||||
return `<div class="notification-item${unread}" data-id="${n.id}">
|
||||
<div class="notification-item-icon ${iconClass}">${icon}</div>
|
||||
<div class="notification-item-content">
|
||||
<div class="notification-item-title">${Utils.escapeHtml(n.title)}</div>
|
||||
<div class="notification-item-message">${Utils.escapeHtml(n.message)}</div>
|
||||
<div class="notification-item-time">${time}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
list.querySelectorAll('.notification-item').forEach(item => {
|
||||
item.addEventListener('click', () => this.markAsRead(item.dataset.id));
|
||||
});
|
||||
},
|
||||
|
||||
async markAsRead(id) {
|
||||
try {
|
||||
await API.request('POST', `/notifications/${id}/read`);
|
||||
const n = this.notifications.find(n => n.id === id);
|
||||
if (n) n.read = true;
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||||
this.updateBadge();
|
||||
this.render();
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async markAllRead() {
|
||||
try {
|
||||
await API.request('POST', '/notifications/read-all');
|
||||
this.notifications.forEach(n => n.read = true);
|
||||
this.unreadCount = 0;
|
||||
this.updateBadge();
|
||||
this.render();
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async clearAll() {
|
||||
try {
|
||||
await API.request('DELETE', '/notifications');
|
||||
this.notifications = [];
|
||||
this.unreadCount = 0;
|
||||
this.updateBadge();
|
||||
this.render();
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
timeAgo(dateStr) {
|
||||
const now = new Date();
|
||||
const date = new Date(dateStr);
|
||||
const diff = Math.floor((now - date) / 1000);
|
||||
if (diff < 60) return 'agora';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}min atrás`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h atrás`;
|
||||
return `${Math.floor(diff / 86400)}d atrás`;
|
||||
},
|
||||
|
||||
showBrowserNotification(title, body) {
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(title, { body, icon: '/favicon.ico' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.NotificationsUI = NotificationsUI;
|
||||
@@ -44,7 +44,7 @@ const PipelinesUI = {
|
||||
if (!emptyState) {
|
||||
grid.insertAdjacentHTML('beforeend', PipelinesUI.renderEmpty());
|
||||
}
|
||||
if (window.lucide) lucide.createIcons({ nodes: [grid] });
|
||||
Utils.refreshIcons(grid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ const PipelinesUI = {
|
||||
|
||||
grid.appendChild(fragment);
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [grid] });
|
||||
Utils.refreshIcons(grid);
|
||||
},
|
||||
|
||||
renderEmpty() {
|
||||
@@ -83,8 +83,7 @@ const PipelinesUI = {
|
||||
const stepCount = steps.length;
|
||||
|
||||
const flowHtml = steps.map((step, index) => {
|
||||
const agentName = step.agentName || step.agentId || 'Agente';
|
||||
const isLast = index === steps.length - 1;
|
||||
const agentName = Utils.escapeHtml(step.agentName || step.agentId || 'Agente');
|
||||
const approvalIcon = step.requiresApproval && index > 0
|
||||
? '<i data-lucide="shield-check" style="width:10px;height:10px;color:var(--warning)"></i> '
|
||||
: '';
|
||||
@@ -93,7 +92,6 @@ const PipelinesUI = {
|
||||
<span class="pipeline-step-number">${index + 1}</span>
|
||||
${approvalIcon}${agentName}
|
||||
</span>
|
||||
${!isLast ? '<span class="pipeline-flow-arrow">→</span>' : ''}
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
@@ -102,12 +100,14 @@ const PipelinesUI = {
|
||||
<div class="agent-card-body">
|
||||
<div class="agent-card-top">
|
||||
<div class="agent-info">
|
||||
<h3 class="agent-name">${pipeline.name || 'Sem nome'}</h3>
|
||||
<h3 class="agent-name">${Utils.escapeHtml(pipeline.name || 'Sem nome')}</h3>
|
||||
<span class="badge badge-active">${stepCount} ${stepCount === 1 ? 'passo' : 'passos'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${pipeline.description ? `<p class="agent-description">${pipeline.description}</p>` : ''}
|
||||
${pipeline.description ? `<p class="agent-description">${Utils.escapeHtml(pipeline.description)}</p>` : ''}
|
||||
|
||||
${pipeline.workingDirectory ? `<div class="pipeline-workdir-badge"><i data-lucide="folder" style="width:12px;height:12px"></i> <code>${Utils.escapeHtml(pipeline.workingDirectory)}</code></div>` : ''}
|
||||
|
||||
<div class="pipeline-flow">
|
||||
${flowHtml || '<span class="agent-description">Nenhum passo configurado</span>'}
|
||||
@@ -119,23 +119,29 @@ const PipelinesUI = {
|
||||
<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 class="agent-actions-icons">
|
||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="flow-pipeline" data-id="${pipeline.id}" title="Editor de fluxo">
|
||||
<i data-lucide="workflow"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm" data-action="edit-pipeline" data-id="${pipeline.id}" title="Editar pipeline">
|
||||
<i data-lucide="pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-icon btn-sm btn-danger" data-action="delete-pipeline" data-id="${pipeline.id}" title="Excluir pipeline">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
_basePath: '/home/projetos/',
|
||||
|
||||
openCreateModal() {
|
||||
PipelinesUI._editingId = null;
|
||||
PipelinesUI._steps = [
|
||||
{ agentId: '', inputTemplate: '', requiresApproval: false },
|
||||
{ agentId: '', inputTemplate: '', requiresApproval: false },
|
||||
{ agentId: '', inputTemplate: '', description: '', promptMode: 'simple', requiresApproval: false },
|
||||
{ agentId: '', inputTemplate: '', description: '', promptMode: 'simple', requiresApproval: false },
|
||||
];
|
||||
|
||||
const titleEl = document.getElementById('pipeline-modal-title');
|
||||
@@ -150,6 +156,9 @@ const PipelinesUI = {
|
||||
const descEl = document.getElementById('pipeline-description');
|
||||
if (descEl) descEl.value = '';
|
||||
|
||||
const workdirEl = document.getElementById('pipeline-workdir');
|
||||
if (workdirEl) workdirEl.value = PipelinesUI._basePath;
|
||||
|
||||
PipelinesUI.renderSteps();
|
||||
Modal.open('pipeline-modal-overlay');
|
||||
},
|
||||
@@ -160,7 +169,13 @@ const PipelinesUI = {
|
||||
|
||||
PipelinesUI._editingId = pipelineId;
|
||||
PipelinesUI._steps = Array.isArray(pipeline.steps)
|
||||
? pipeline.steps.map((s) => ({ agentId: s.agentId || '', inputTemplate: s.inputTemplate || '', requiresApproval: !!s.requiresApproval }))
|
||||
? pipeline.steps.map((s) => ({
|
||||
agentId: s.agentId || '',
|
||||
inputTemplate: s.inputTemplate || '',
|
||||
description: s.description || '',
|
||||
promptMode: s.description ? 'simple' : 'advanced',
|
||||
requiresApproval: !!s.requiresApproval,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const titleEl = document.getElementById('pipeline-modal-title');
|
||||
@@ -175,6 +190,9 @@ const PipelinesUI = {
|
||||
const descEl = document.getElementById('pipeline-description');
|
||||
if (descEl) descEl.value = pipeline.description || '';
|
||||
|
||||
const workdirEl = document.getElementById('pipeline-workdir');
|
||||
if (workdirEl) workdirEl.value = pipeline.workingDirectory || PipelinesUI._basePath;
|
||||
|
||||
PipelinesUI.renderSteps();
|
||||
Modal.open('pipeline-modal-overlay');
|
||||
} catch (err) {
|
||||
@@ -192,7 +210,7 @@ const PipelinesUI = {
|
||||
}
|
||||
|
||||
const agentOptions = PipelinesUI.agents
|
||||
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`)
|
||||
.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
|
||||
.join('');
|
||||
|
||||
container.innerHTML = PipelinesUI._steps.map((step, index) => {
|
||||
@@ -211,6 +229,46 @@ const PipelinesUI = {
|
||||
</label>`
|
||||
: '';
|
||||
|
||||
const isSimple = step.promptMode !== 'advanced';
|
||||
const inputContext = isFirst
|
||||
? 'O input inicial do pipeline'
|
||||
: 'O resultado (sumarizado) do passo anterior';
|
||||
|
||||
const promptHtml = isSimple
|
||||
? `<textarea
|
||||
class="textarea"
|
||||
rows="2"
|
||||
placeholder="Ex: Analise os requisitos e crie um plano técnico detalhado"
|
||||
data-step-field="description"
|
||||
data-step-index="${index}"
|
||||
>${Utils.escapeHtml(step.description || '')}</textarea>
|
||||
<div class="pipeline-step-hints">
|
||||
<span class="pipeline-step-hint">
|
||||
<i data-lucide="info" style="width:11px;height:11px"></i>
|
||||
${inputContext} será injetado via <code>{{input}}</code> automaticamente no final.
|
||||
</span>
|
||||
<span class="pipeline-step-hint">
|
||||
<i data-lucide="lightbulb" style="width:11px;height:11px"></i>
|
||||
Dica: use <code><tags></code> XML para organizar melhor. Ex: <code><contexto></code> <code><regras></code> <code><formato_saida></code>
|
||||
</span>
|
||||
</div>`
|
||||
: `<textarea
|
||||
class="textarea"
|
||||
rows="3"
|
||||
placeholder="Use {{input}} para posicionar o output do passo anterior. Estruture com <tags> XML."
|
||||
data-step-field="inputTemplate"
|
||||
data-step-index="${index}"
|
||||
>${Utils.escapeHtml(step.inputTemplate || '')}</textarea>
|
||||
<div class="pipeline-step-hints">
|
||||
<span class="pipeline-step-hint">
|
||||
<i data-lucide="lightbulb" style="width:11px;height:11px"></i>
|
||||
Dica: use <code><tags></code> XML para organizar. Ex: <code><contexto>{{input}}</contexto></code> <code><regras></code> <code><formato_saida></code>
|
||||
</span>
|
||||
</div>`;
|
||||
|
||||
const modeIcon = isSimple ? 'code' : 'text';
|
||||
const modeLabel = isSimple ? 'Avançado' : 'Simples';
|
||||
|
||||
return `
|
||||
<div class="pipeline-step-row" data-step-index="${index}">
|
||||
<span class="pipeline-step-number-lg">${index + 1}</span>
|
||||
@@ -219,14 +277,14 @@ const PipelinesUI = {
|
||||
<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>
|
||||
${approvalHtml}
|
||||
${promptHtml}
|
||||
<div class="pipeline-step-footer">
|
||||
${approvalHtml}
|
||||
<button type="button" class="pipeline-mode-toggle" data-step-action="toggle-mode" data-step-index="${index}" title="Alternar entre modo simples e avançado">
|
||||
<i data-lucide="${modeIcon}" style="width:12px;height:12px"></i>
|
||||
${modeLabel}
|
||||
</button>
|
||||
</div>
|
||||
</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' : ''}>
|
||||
@@ -249,7 +307,7 @@ const PipelinesUI = {
|
||||
select.value = PipelinesUI._steps[index].agentId || '';
|
||||
});
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
_syncStepsFromDOM() {
|
||||
@@ -269,9 +327,41 @@ const PipelinesUI = {
|
||||
});
|
||||
},
|
||||
|
||||
_generateTemplate(description, stepIndex) {
|
||||
if (!description) return '';
|
||||
if (stepIndex === 0) {
|
||||
return `${description}\n\n{{input}}`;
|
||||
}
|
||||
return `${description}\n\nResultado do passo anterior:\n{{input}}`;
|
||||
},
|
||||
|
||||
toggleMode(index) {
|
||||
PipelinesUI._syncStepsFromDOM();
|
||||
const step = PipelinesUI._steps[index];
|
||||
if (!step) return;
|
||||
|
||||
if (step.promptMode === 'advanced') {
|
||||
step.promptMode = 'simple';
|
||||
if (step.inputTemplate && !step.description) {
|
||||
step.description = step.inputTemplate
|
||||
.replace(/\{\{input\}\}/g, '')
|
||||
.replace(/Resultado do passo anterior:\s*/g, '')
|
||||
.replace(/Input:\s*/g, '')
|
||||
.trim();
|
||||
}
|
||||
} else {
|
||||
step.promptMode = 'advanced';
|
||||
if (step.description && !step.inputTemplate) {
|
||||
step.inputTemplate = PipelinesUI._generateTemplate(step.description, index);
|
||||
}
|
||||
}
|
||||
|
||||
PipelinesUI.renderSteps();
|
||||
},
|
||||
|
||||
addStep() {
|
||||
PipelinesUI._syncStepsFromDOM();
|
||||
PipelinesUI._steps.push({ agentId: '', inputTemplate: '', requiresApproval: false });
|
||||
PipelinesUI._steps.push({ agentId: '', inputTemplate: '', description: '', promptMode: 'simple', requiresApproval: false });
|
||||
PipelinesUI.renderSteps();
|
||||
},
|
||||
|
||||
@@ -311,14 +401,29 @@ const PipelinesUI = {
|
||||
return;
|
||||
}
|
||||
|
||||
const workingDirectory = document.getElementById('pipeline-workdir')?.value.trim() || '';
|
||||
if (workingDirectory && !workingDirectory.startsWith('/')) {
|
||||
Toast.warning('O diretório do projeto deve ser um caminho absoluto (começar com /)');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
name,
|
||||
description: document.getElementById('pipeline-description')?.value.trim() || '',
|
||||
steps: PipelinesUI._steps.map((s) => ({
|
||||
agentId: s.agentId,
|
||||
inputTemplate: s.inputTemplate || '',
|
||||
requiresApproval: !!s.requiresApproval,
|
||||
})),
|
||||
workingDirectory,
|
||||
steps: PipelinesUI._steps.map((s, index) => {
|
||||
const isSimple = s.promptMode !== 'advanced';
|
||||
const inputTemplate = isSimple
|
||||
? PipelinesUI._generateTemplate(s.description, index)
|
||||
: (s.inputTemplate || '');
|
||||
|
||||
return {
|
||||
agentId: s.agentId,
|
||||
inputTemplate,
|
||||
description: isSimple ? (s.description || '') : '',
|
||||
requiresApproval: !!s.requiresApproval,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -367,7 +472,14 @@ const PipelinesUI = {
|
||||
if (inputEl) inputEl.value = '';
|
||||
|
||||
const workdirEl = document.getElementById('pipeline-execute-workdir');
|
||||
if (workdirEl) workdirEl.value = '';
|
||||
if (workdirEl) workdirEl.value = (pipeline && pipeline.workingDirectory) || PipelinesUI._basePath;
|
||||
|
||||
if (App._pipelineDropzone) App._pipelineDropzone.reset();
|
||||
|
||||
const repoSelect = document.getElementById('pipeline-execute-repo');
|
||||
if (repoSelect) { repoSelect.value = ''; repoSelect.dispatchEvent(new Event('change')); }
|
||||
App._reposCache = null;
|
||||
App._loadRepos('pipeline-execute-repo');
|
||||
|
||||
Modal.open('pipeline-execute-modal-overlay');
|
||||
},
|
||||
@@ -382,8 +494,24 @@ const PipelinesUI = {
|
||||
return;
|
||||
}
|
||||
|
||||
if (workingDirectory && !workingDirectory.startsWith('/')) {
|
||||
Toast.warning('O diretório de trabalho deve ser um caminho absoluto (começar com /)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.pipelines.execute(pipelineId, input, workingDirectory);
|
||||
let contextFiles = null;
|
||||
const dropzone = App._pipelineDropzone;
|
||||
if (dropzone && dropzone.getFiles().length > 0) {
|
||||
Toast.info('Fazendo upload dos arquivos...');
|
||||
const uploadResult = await API.uploads.send(dropzone.getFiles());
|
||||
contextFiles = uploadResult.files;
|
||||
}
|
||||
|
||||
const repoName = document.getElementById('pipeline-execute-repo')?.value || '';
|
||||
const repoBranch = document.getElementById('pipeline-execute-repo-branch')?.value || '';
|
||||
await API.pipelines.execute(pipelineId, input, workingDirectory, contextFiles, repoName, repoBranch);
|
||||
if (dropzone) dropzone.reset();
|
||||
Modal.close('pipeline-execute-modal-overlay');
|
||||
App.navigateTo('terminal');
|
||||
Toast.info('Pipeline iniciado');
|
||||
|
||||
@@ -28,7 +28,7 @@ const SchedulesUI = {
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [tbody] });
|
||||
Utils.refreshIcons(tbody);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ const SchedulesUI = {
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${schedule.agentName || '—'}</td>
|
||||
<td class="schedule-task-cell" title="${schedule.taskDescription || ''}">${schedule.taskDescription || '—'}</td>
|
||||
<td>${Utils.escapeHtml(schedule.agentName || '—')}</td>
|
||||
<td class="schedule-task-cell" title="${Utils.escapeHtml(schedule.taskDescription || '')}">${Utils.escapeHtml(schedule.taskDescription || '—')}</td>
|
||||
<td>
|
||||
<code class="font-mono">${cronExpr}</code>
|
||||
</td>
|
||||
@@ -77,7 +77,7 @@ const SchedulesUI = {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [tbody] });
|
||||
Utils.refreshIcons(tbody);
|
||||
},
|
||||
|
||||
filter(searchText, statusFilter) {
|
||||
@@ -106,7 +106,7 @@ const SchedulesUI = {
|
||||
select.innerHTML = '<option value="">Selecionar agente...</option>' +
|
||||
agents
|
||||
.filter((a) => a.status === 'active')
|
||||
.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`)
|
||||
.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
@@ -208,7 +208,11 @@ const SchedulesUI = {
|
||||
if (!container) return;
|
||||
|
||||
if (history.length === 0) {
|
||||
container.innerHTML = '<p class="empty-state-desc">Nenhum disparo registrado</p>';
|
||||
const hasSchedules = SchedulesUI.schedules.length > 0;
|
||||
const msg = hasSchedules
|
||||
? 'Nenhum disparo registrado ainda. As tarefas agendadas aparecerão aqui após a próxima execução.'
|
||||
: 'Nenhum disparo registrado. Crie um agendamento para começar.';
|
||||
container.innerHTML = `<p class="empty-state-desc">${msg}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -233,12 +237,12 @@ const SchedulesUI = {
|
||||
const duration = SchedulesUI._formatDuration(exec.startedAt, exec.endedAt);
|
||||
const cost = exec.costUsd || exec.totalCostUsd || 0;
|
||||
const costStr = cost > 0 ? `$${cost.toFixed(4)}` : '—';
|
||||
const taskStr = SchedulesUI._escapeHtml(SchedulesUI._truncate(exec.task || '', 60));
|
||||
const taskStr = Utils.escapeHtml(Utils.truncate(exec.task || '', 60));
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${SchedulesUI._escapeHtml(exec.agentName || '—')}</td>
|
||||
<td title="${SchedulesUI._escapeHtml(exec.task || '')}">${taskStr}</td>
|
||||
<td>${Utils.escapeHtml(exec.agentName || '—')}</td>
|
||||
<td title="${Utils.escapeHtml(exec.task || '')}">${taskStr}</td>
|
||||
<td>${status}</td>
|
||||
<td>${date}</td>
|
||||
<td>${duration}</td>
|
||||
@@ -256,7 +260,7 @@ const SchedulesUI = {
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
_statusBadge(status) {
|
||||
@@ -283,16 +287,6 @@ const SchedulesUI = {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
},
|
||||
|
||||
_truncate(str, max) {
|
||||
if (!str || str.length <= max) return str;
|
||||
return str.slice(0, max) + '…';
|
||||
},
|
||||
|
||||
cronToHuman(expression) {
|
||||
if (!expression) return '—';
|
||||
|
||||
|
||||
@@ -8,11 +8,20 @@ const SettingsUI = {
|
||||
|
||||
SettingsUI.populateForm(settings);
|
||||
SettingsUI.populateSystemInfo(info);
|
||||
SettingsUI.updateThemeInfo();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao carregar configurações: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
updateThemeInfo() {
|
||||
const themeEl = document.getElementById('info-current-theme');
|
||||
if (themeEl) {
|
||||
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
themeEl.textContent = theme === 'dark' ? 'Escuro' : 'Claro';
|
||||
}
|
||||
},
|
||||
|
||||
populateForm(settings) {
|
||||
const fields = {
|
||||
'settings-default-model': settings.defaultModel || 'claude-sonnet-4-6',
|
||||
|
||||
@@ -39,7 +39,7 @@ const TasksUI = {
|
||||
|
||||
container.appendChild(fragment);
|
||||
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
filter(searchText, categoryFilter) {
|
||||
@@ -65,10 +65,10 @@ const TasksUI = {
|
||||
return `
|
||||
<div class="task-card" data-task-id="${task.id}">
|
||||
<div class="task-card-header">
|
||||
<h4 class="task-card-name">${task.name}</h4>
|
||||
<span class="badge ${categoryClass}">${categoryLabel}</span>
|
||||
<h4 class="task-card-name">${Utils.escapeHtml(task.name)}</h4>
|
||||
<span class="badge ${categoryClass}">${Utils.escapeHtml(categoryLabel)}</span>
|
||||
</div>
|
||||
${task.description ? `<p class="task-card-description">${task.description}</p>` : ''}
|
||||
${task.description ? `<p class="task-card-description" title="${Utils.escapeHtml(task.description)}">${Utils.escapeHtml(task.description.length > 240 ? task.description.slice(0, 240) + '…' : task.description)}</p>` : ''}
|
||||
<div class="task-card-footer">
|
||||
<span class="task-card-date">
|
||||
<i data-lucide="calendar"></i>
|
||||
@@ -117,7 +117,7 @@ const TasksUI = {
|
||||
<div class="task-card task-card--form" id="task-inline-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-inline-name">${title}</label>
|
||||
<input type="text" id="task-inline-name" class="input" placeholder="Ex: Code Review de PR" required autocomplete="off" value="${task.name || ''}">
|
||||
<input type="text" id="task-inline-name" class="input" placeholder="Ex: Code Review de PR" required autocomplete="off" value="${Utils.escapeHtml(task.name || '')}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-inline-category">Categoria</label>
|
||||
@@ -133,7 +133,7 @@ const TasksUI = {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="task-inline-description">Descrição</label>
|
||||
<textarea id="task-inline-description" class="textarea" rows="2" placeholder="Descreva o objetivo desta tarefa...">${task.description || ''}</textarea>
|
||||
<textarea id="task-inline-description" class="textarea" rows="2" placeholder="Descreva o objetivo desta tarefa...">${Utils.escapeHtml(task.description || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn--primary" id="btn-save-inline-task" type="button">${btnLabel}</button>
|
||||
@@ -228,7 +228,7 @@ const TasksUI = {
|
||||
const selectEl = document.getElementById('execute-agent-select');
|
||||
if (selectEl) {
|
||||
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
|
||||
activeAgents.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`).join('');
|
||||
activeAgents.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`).join('');
|
||||
selectEl.value = '';
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ const Terminal = {
|
||||
executionFilter: null,
|
||||
_processingInterval: null,
|
||||
_chatSession: null,
|
||||
searchMatches: [],
|
||||
searchIndex: -1,
|
||||
_toolbarInitialized: false,
|
||||
|
||||
enableChat(agentId, agentName, sessionId) {
|
||||
Terminal._chatSession = { agentId, agentName, sessionId };
|
||||
@@ -83,7 +86,121 @@ const Terminal = {
|
||||
if (output) output.scrollTop = output.scrollHeight;
|
||||
},
|
||||
|
||||
initToolbar() {
|
||||
if (Terminal._toolbarInitialized) return;
|
||||
Terminal._toolbarInitialized = true;
|
||||
|
||||
const searchToggle = document.getElementById('terminal-search-toggle');
|
||||
const searchBar = document.getElementById('terminal-search-bar');
|
||||
const searchInput = document.getElementById('terminal-search-input');
|
||||
const searchClose = document.getElementById('terminal-search-close');
|
||||
const searchPrev = document.getElementById('terminal-search-prev');
|
||||
const searchNext = document.getElementById('terminal-search-next');
|
||||
const downloadBtn = document.getElementById('terminal-download');
|
||||
const copyBtn = document.getElementById('terminal-copy');
|
||||
const autoScrollCheck = document.getElementById('terminal-autoscroll');
|
||||
|
||||
if (searchToggle && searchBar) {
|
||||
searchToggle.addEventListener('click', () => {
|
||||
searchBar.classList.toggle('hidden');
|
||||
if (!searchBar.classList.contains('hidden') && searchInput) searchInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', () => Terminal.search(searchInput.value));
|
||||
}
|
||||
|
||||
if (searchClose && searchBar) {
|
||||
searchClose.addEventListener('click', () => {
|
||||
searchBar.classList.add('hidden');
|
||||
Terminal.clearSearch();
|
||||
});
|
||||
}
|
||||
|
||||
if (searchPrev) searchPrev.addEventListener('click', () => Terminal.searchPrev());
|
||||
if (searchNext) searchNext.addEventListener('click', () => Terminal.searchNext());
|
||||
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', () => Terminal.downloadOutput());
|
||||
}
|
||||
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => Terminal.copyOutput());
|
||||
}
|
||||
|
||||
if (autoScrollCheck) {
|
||||
autoScrollCheck.addEventListener('change', (e) => {
|
||||
Terminal.autoScroll = e.target.checked;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
search(query) {
|
||||
const output = document.getElementById('terminal-output');
|
||||
if (!output || !query) { Terminal.clearSearch(); return; }
|
||||
|
||||
const text = output.textContent;
|
||||
Terminal.searchMatches = [];
|
||||
Terminal.searchIndex = -1;
|
||||
|
||||
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
Terminal.searchMatches.push(match.index);
|
||||
}
|
||||
|
||||
const countEl = document.getElementById('terminal-search-count');
|
||||
if (countEl) countEl.textContent = Terminal.searchMatches.length > 0 ? `0/${Terminal.searchMatches.length}` : '0/0';
|
||||
|
||||
if (Terminal.searchMatches.length > 0) Terminal.searchNext();
|
||||
},
|
||||
|
||||
searchNext() {
|
||||
if (Terminal.searchMatches.length === 0) return;
|
||||
Terminal.searchIndex = (Terminal.searchIndex + 1) % Terminal.searchMatches.length;
|
||||
const countEl = document.getElementById('terminal-search-count');
|
||||
if (countEl) countEl.textContent = `${Terminal.searchIndex + 1}/${Terminal.searchMatches.length}`;
|
||||
},
|
||||
|
||||
searchPrev() {
|
||||
if (Terminal.searchMatches.length === 0) return;
|
||||
Terminal.searchIndex = Terminal.searchIndex <= 0 ? Terminal.searchMatches.length - 1 : Terminal.searchIndex - 1;
|
||||
const countEl = document.getElementById('terminal-search-count');
|
||||
if (countEl) countEl.textContent = `${Terminal.searchIndex + 1}/${Terminal.searchMatches.length}`;
|
||||
},
|
||||
|
||||
clearSearch() {
|
||||
Terminal.searchMatches = [];
|
||||
Terminal.searchIndex = -1;
|
||||
const countEl = document.getElementById('terminal-search-count');
|
||||
if (countEl) countEl.textContent = '0/0';
|
||||
},
|
||||
|
||||
downloadOutput() {
|
||||
const output = document.getElementById('terminal-output');
|
||||
if (!output) return;
|
||||
const text = output.textContent;
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `terminal_${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
if (typeof Toast !== 'undefined') Toast.success('Saída baixada');
|
||||
},
|
||||
|
||||
copyOutput() {
|
||||
const output = document.getElementById('terminal-output');
|
||||
if (!output) return;
|
||||
navigator.clipboard.writeText(output.textContent).then(() => {
|
||||
if (typeof Toast !== 'undefined') Toast.success('Saída copiada');
|
||||
});
|
||||
},
|
||||
|
||||
render() {
|
||||
Terminal.initToolbar();
|
||||
const output = document.getElementById('terminal-output');
|
||||
if (!output) return;
|
||||
|
||||
@@ -102,7 +219,7 @@ const Terminal = {
|
||||
|
||||
const html = lines.map((line) => {
|
||||
const typeClass = line.type && line.type !== 'default' ? ' ' + line.type : '';
|
||||
const escaped = Terminal._escapeHtml(line.content);
|
||||
const escaped = Utils.escapeHtml(line.content);
|
||||
const formatted = escaped.replace(/\n/g, '<br>');
|
||||
|
||||
return `<div class="terminal-line${typeClass}">
|
||||
@@ -120,14 +237,6 @@ const Terminal = {
|
||||
if (Terminal.autoScroll) Terminal.scrollToBottom();
|
||||
},
|
||||
|
||||
_escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
};
|
||||
|
||||
window.Terminal = Terminal;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const Toast = {
|
||||
iconMap: {
|
||||
success: 'check-circle',
|
||||
error: 'x-circle',
|
||||
success: 'circle-check',
|
||||
error: 'circle-x',
|
||||
info: 'info',
|
||||
warning: 'alert-triangle',
|
||||
warning: 'triangle-alert',
|
||||
},
|
||||
|
||||
colorMap: {
|
||||
@@ -35,9 +35,7 @@ const Toast = {
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
if (window.lucide) {
|
||||
lucide.createIcons({ nodes: [toast] });
|
||||
}
|
||||
Utils.refreshIcons(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('toast-show');
|
||||
|
||||
@@ -44,12 +44,12 @@ const WebhooksUI = {
|
||||
<p class="empty-state-desc">Crie webhooks para disparar agentes ou pipelines via HTTP.</p>
|
||||
</div>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = webhooks.map((w) => WebhooksUI._renderCard(w)).join('');
|
||||
if (window.lucide) lucide.createIcons({ nodes: [container] });
|
||||
Utils.refreshIcons(container);
|
||||
},
|
||||
|
||||
_renderCard(webhook) {
|
||||
@@ -71,7 +71,7 @@ const WebhooksUI = {
|
||||
<article class="webhook-card">
|
||||
<div class="webhook-card-header">
|
||||
<div class="webhook-card-identity">
|
||||
<span class="webhook-card-name">${WebhooksUI._escapeHtml(webhook.name)}</span>
|
||||
<span class="webhook-card-name">${Utils.escapeHtml(webhook.name)}</span>
|
||||
${typeBadge}
|
||||
${statusBadge}
|
||||
</div>
|
||||
@@ -79,6 +79,12 @@ const WebhooksUI = {
|
||||
<button class="btn btn-ghost btn-sm btn-icon" data-action="toggle-webhook" data-id="${webhook.id}" title="${webhook.active ? 'Desativar' : 'Ativar'}">
|
||||
<i data-lucide="${webhook.active ? 'pause' : 'play'}"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm btn-icon" data-action="edit-webhook" data-id="${webhook.id}" title="Editar">
|
||||
<i data-lucide="pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm btn-icon" data-action="test-webhook" data-id="${webhook.id}" title="Testar">
|
||||
<i data-lucide="zap"></i>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm btn-icon btn-danger" data-action="delete-webhook" data-id="${webhook.id}" title="Excluir">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
@@ -87,7 +93,7 @@ const WebhooksUI = {
|
||||
<div class="webhook-card-body">
|
||||
<div class="webhook-card-target">
|
||||
<span class="webhook-card-label">Destino</span>
|
||||
<span class="webhook-card-value">${WebhooksUI._escapeHtml(targetName)}</span>
|
||||
<span class="webhook-card-value">${Utils.escapeHtml(targetName)}</span>
|
||||
</div>
|
||||
<div class="webhook-card-url">
|
||||
<span class="webhook-card-label">URL</span>
|
||||
@@ -141,19 +147,59 @@ const WebhooksUI = {
|
||||
WebhooksUI._updateTargetSelect('agent');
|
||||
}
|
||||
|
||||
const submitBtn = document.getElementById('webhook-form-submit');
|
||||
if (submitBtn) submitBtn.dataset.editId = '';
|
||||
|
||||
Modal.open('webhook-modal-overlay');
|
||||
},
|
||||
|
||||
openEditModal(webhookId) {
|
||||
const webhook = WebhooksUI.webhooks.find(w => w.id === webhookId);
|
||||
if (!webhook) return;
|
||||
|
||||
const titleEl = document.getElementById('webhook-modal-title');
|
||||
if (titleEl) titleEl.textContent = 'Editar Webhook';
|
||||
|
||||
const nameEl = document.getElementById('webhook-name');
|
||||
if (nameEl) nameEl.value = webhook.name || '';
|
||||
|
||||
const typeEl = document.getElementById('webhook-target-type');
|
||||
if (typeEl) {
|
||||
typeEl.value = webhook.targetType || 'agent';
|
||||
WebhooksUI._updateTargetSelect(webhook.targetType || 'agent');
|
||||
}
|
||||
|
||||
const targetEl = document.getElementById('webhook-target-id');
|
||||
if (targetEl) targetEl.value = webhook.targetId || '';
|
||||
|
||||
const submitBtn = document.getElementById('webhook-form-submit');
|
||||
if (submitBtn) submitBtn.dataset.editId = webhookId;
|
||||
|
||||
Modal.open('webhook-modal-overlay');
|
||||
},
|
||||
|
||||
async test(webhookId) {
|
||||
try {
|
||||
const result = await API.webhooks.test(webhookId);
|
||||
Toast.success(result.message || 'Webhook disparado com sucesso');
|
||||
if (result.executionId || result.pipelineId) {
|
||||
App.navigateTo('terminal');
|
||||
}
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao testar webhook: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
_updateTargetSelect(targetType) {
|
||||
const selectEl = document.getElementById('webhook-target-id');
|
||||
if (!selectEl) return;
|
||||
|
||||
if (targetType === 'agent') {
|
||||
selectEl.innerHTML = '<option value="">Selecionar agente...</option>' +
|
||||
WebhooksUI.agents.map((a) => `<option value="${a.id}">${a.agent_name || a.name}</option>`).join('');
|
||||
WebhooksUI.agents.map((a) => `<option value="${a.id}">${Utils.escapeHtml(a.agent_name || a.name)}</option>`).join('');
|
||||
} else {
|
||||
selectEl.innerHTML = '<option value="">Selecionar pipeline...</option>' +
|
||||
WebhooksUI.pipelines.map((p) => `<option value="${p.id}">${p.name}</option>`).join('');
|
||||
WebhooksUI.pipelines.map((p) => `<option value="${p.id}">${Utils.escapeHtml(p.name)}</option>`).join('');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -161,17 +207,25 @@ const WebhooksUI = {
|
||||
const name = document.getElementById('webhook-name')?.value.trim();
|
||||
const targetType = document.getElementById('webhook-target-type')?.value;
|
||||
const targetId = document.getElementById('webhook-target-id')?.value;
|
||||
const submitBtn = document.getElementById('webhook-form-submit');
|
||||
const editId = submitBtn?.dataset.editId || '';
|
||||
|
||||
if (!name) { Toast.warning('Nome do webhook é obrigatório'); return; }
|
||||
if (!targetId) { Toast.warning('Selecione um destino'); return; }
|
||||
|
||||
try {
|
||||
await API.webhooks.create({ name, targetType, targetId });
|
||||
Modal.close('webhook-modal-overlay');
|
||||
Toast.success('Webhook criado com sucesso');
|
||||
if (editId) {
|
||||
await API.webhooks.update(editId, { name, targetType, targetId });
|
||||
Modal.close('webhook-modal-overlay');
|
||||
Toast.success('Webhook atualizado com sucesso');
|
||||
} else {
|
||||
await API.webhooks.create({ name, targetType, targetId });
|
||||
Modal.close('webhook-modal-overlay');
|
||||
Toast.success('Webhook criado com sucesso');
|
||||
}
|
||||
await WebhooksUI.load();
|
||||
} catch (err) {
|
||||
Toast.error(`Erro ao criar webhook: ${err.message}`);
|
||||
Toast.error(`Erro ao salvar webhook: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -243,15 +297,6 @@ const WebhooksUI = {
|
||||
}
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
};
|
||||
|
||||
window.WebhooksUI = WebhooksUI;
|
||||
|
||||
16688
public/js/lucide.js
Normal file
16688
public/js/lucide.js
Normal file
File diff suppressed because it is too large
Load Diff
114
public/js/utils.js
Normal file
114
public/js/utils.js
Normal file
@@ -0,0 +1,114 @@
|
||||
const Utils = {
|
||||
escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
|
||||
formatDuration(ms) {
|
||||
if (!ms || ms < 0) return '—';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const m = Math.floor(ms / 60000);
|
||||
const s = Math.floor((ms % 60000) / 1000);
|
||||
return `${m}m ${s}s`;
|
||||
},
|
||||
|
||||
formatCost(usd) {
|
||||
if (!usd || usd === 0) return '$0.0000';
|
||||
return `$${Number(usd).toFixed(4)}`;
|
||||
},
|
||||
|
||||
truncate(str, max = 80) {
|
||||
if (!str) return '';
|
||||
return str.length > max ? str.slice(0, max) + '…' : str;
|
||||
},
|
||||
|
||||
refreshIcons(container) {
|
||||
if (!window.lucide) return;
|
||||
const target = container || document;
|
||||
const pending = target.querySelectorAll('i[data-lucide]');
|
||||
if (pending.length === 0) return;
|
||||
lucide.createIcons();
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
},
|
||||
|
||||
initDropzone(dropzoneId, fileInputId, fileListId) {
|
||||
const zone = document.getElementById(dropzoneId);
|
||||
const input = document.getElementById(fileInputId);
|
||||
const list = document.getElementById(fileListId);
|
||||
if (!zone || !input || !list) return null;
|
||||
|
||||
const state = { files: [] };
|
||||
|
||||
function render() {
|
||||
list.innerHTML = state.files.map((f, i) => `
|
||||
<li class="dropzone-file">
|
||||
<span class="dropzone-file-name">${Utils.escapeHtml(f.name)}</span>
|
||||
<span class="dropzone-file-size">${Utils.formatFileSize(f.size)}</span>
|
||||
<button type="button" class="dropzone-file-remove" data-index="${i}" title="Remover">×</button>
|
||||
</li>
|
||||
`).join('');
|
||||
|
||||
const content = zone.querySelector('.dropzone-content');
|
||||
if (content) content.style.display = state.files.length > 0 ? 'none' : '';
|
||||
}
|
||||
|
||||
function addFiles(fileList) {
|
||||
for (const f of fileList) {
|
||||
if (state.files.length >= 20) break;
|
||||
if (f.size > 10 * 1024 * 1024) continue;
|
||||
const dupe = state.files.some(x => x.name === f.name && x.size === f.size);
|
||||
if (!dupe) state.files.push(f);
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
const browseBtn = zone.querySelector('.dropzone-browse');
|
||||
if (browseBtn) {
|
||||
browseBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
zone.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.dropzone-file-remove')) {
|
||||
const idx = parseInt(e.target.closest('.dropzone-file-remove').dataset.index);
|
||||
state.files.splice(idx, 1);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('.dropzone-browse')) return;
|
||||
if (!e.target.closest('.dropzone-file')) input.click();
|
||||
});
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
if (input.files.length > 0) addFiles(input.files);
|
||||
input.value = '';
|
||||
});
|
||||
|
||||
zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('dragover'); });
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
||||
zone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('dragover');
|
||||
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
state.reset = () => { state.files = []; render(); };
|
||||
state.getFiles = () => state.files;
|
||||
return state;
|
||||
},
|
||||
};
|
||||
|
||||
window.Utils = Utils;
|
||||
79
scripts/deploy.sh
Executable file
79
scripts/deploy.sh
Executable file
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
VPS_HOST="fred@192.168.1.151"
|
||||
VPS_PORT=2222
|
||||
VPS_APP_DIR="/home/fred/vps/apps/agents-orchestrator"
|
||||
VPS_COMPOSE_DIR="/home/fred/vps"
|
||||
SSH="ssh -p $VPS_PORT $VPS_HOST"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GREEN}[deploy]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[deploy]${NC} $1"; }
|
||||
error() { echo -e "${RED}[deploy]${NC} $1"; }
|
||||
|
||||
SKIP_PUSH=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-push) SKIP_PUSH=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$SKIP_PUSH" = false ]; then
|
||||
info "Fazendo push para origin..."
|
||||
git push origin main
|
||||
info "Fazendo push para nitro..."
|
||||
git push nitro main 2>/dev/null || warn "Push para nitro falhou (não crítico)"
|
||||
fi
|
||||
|
||||
info "Verificando dados no VPS antes do deploy..."
|
||||
DATA_FILES=$($SSH "ls -1 $VPS_APP_DIR/data/*.json 2>/dev/null | wc -l")
|
||||
info "Arquivos de dados encontrados: $DATA_FILES"
|
||||
|
||||
if [ "$DATA_FILES" -gt 0 ]; then
|
||||
info "Criando backup dos dados..."
|
||||
$SSH "cp -r $VPS_APP_DIR/data $VPS_APP_DIR/data-backup-\$(date +%Y%m%d-%H%M%S)"
|
||||
fi
|
||||
|
||||
info "Sincronizando código com o VPS..."
|
||||
rsync -avz --delete \
|
||||
--exclude='node_modules' \
|
||||
--exclude='data' \
|
||||
--exclude='data-backup-*' \
|
||||
--exclude='.git' \
|
||||
--exclude='.env' \
|
||||
--exclude='*.log' \
|
||||
-e "ssh -p $VPS_PORT" \
|
||||
./ "$VPS_HOST:$VPS_APP_DIR/"
|
||||
|
||||
info "Corrigindo permissões do diretório data..."
|
||||
$SSH "sudo chown -R 1000:1000 $VPS_APP_DIR/data"
|
||||
|
||||
info "Rebuilding container..."
|
||||
$SSH "cd $VPS_COMPOSE_DIR && docker compose up -d --build agents-orchestrator 2>&1 | tail -5"
|
||||
|
||||
info "Verificando container..."
|
||||
sleep 2
|
||||
STATUS=$($SSH "docker ps --filter name=agents-orchestrator --format '{{.Status}}'")
|
||||
if echo "$STATUS" | grep -q "Up"; then
|
||||
info "Container rodando: $STATUS"
|
||||
else
|
||||
error "Container não está rodando! Status: $STATUS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DATA_AFTER=$($SSH "ls -1 $VPS_APP_DIR/data/*.json 2>/dev/null | wc -l")
|
||||
info "Arquivos de dados após deploy: $DATA_AFTER"
|
||||
|
||||
if [ "$DATA_AFTER" -lt "$DATA_FILES" ]; then
|
||||
error "ALERTA: Menos arquivos de dados após deploy! ($DATA_FILES -> $DATA_AFTER)"
|
||||
error "Backup disponível em data-backup-*"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$SSH "ls -dt $VPS_APP_DIR/data-backup-* 2>/dev/null | tail -n +4 | xargs rm -rf 2>/dev/null" || true
|
||||
info "Deploy concluído com sucesso!"
|
||||
159
server.js
159
server.js
@@ -4,44 +4,122 @@ import { WebSocketServer } from 'ws';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import crypto from 'crypto';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/routes/api.js';
|
||||
import * as manager from './src/agents/manager.js';
|
||||
import { setGlobalBroadcast } from './src/agents/manager.js';
|
||||
import { cancelAllExecutions } from './src/agents/executor.js';
|
||||
import { stopAll as stopAllSchedules } from './src/agents/scheduler.js';
|
||||
import { flushAllStores } from './src/store/db.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const HOST = process.env.HOST || '127.0.0.1';
|
||||
const AUTH_TOKEN = process.env.AUTH_TOKEN || '';
|
||||
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || '';
|
||||
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'http://localhost:3000';
|
||||
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '';
|
||||
|
||||
|
||||
function timingSafeCompare(a, b) {
|
||||
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||||
const hashA = crypto.createHash('sha256').update(a).digest();
|
||||
const hashB = crypto.createHash('sha256').update(b).digest();
|
||||
return crypto.timingSafeEqual(hashA, hashB);
|
||||
}
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 100,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Limite de requisições excedido. Tente novamente em breve.' },
|
||||
});
|
||||
|
||||
const hookLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Limite de requisições de webhook excedido.' },
|
||||
});
|
||||
|
||||
function verifyWebhookSignature(req, res, next) {
|
||||
if (!WEBHOOK_SECRET) return next();
|
||||
const sig = req.headers['x-hub-signature-256'];
|
||||
if (!sig) return res.status(401).json({ error: 'Assinatura ausente' });
|
||||
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
|
||||
hmac.update(req.rawBody || '');
|
||||
const expected = 'sha256=' + hmac.digest('hex');
|
||||
try {
|
||||
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
|
||||
return res.status(401).json({ error: 'Assinatura inválida' });
|
||||
}
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Assinatura inválida' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
const httpServer = createServer(app);
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const origin = ALLOWED_ORIGIN || req.headers.origin || '*';
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Origin', ALLOWED_ORIGIN);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Client-Id');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Client-Id, X-Correlation-ID');
|
||||
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
||||
next();
|
||||
});
|
||||
|
||||
if (AUTH_TOKEN) {
|
||||
app.use('/api', (req, res, next) => {
|
||||
const header = req.headers.authorization || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : req.query.token;
|
||||
if (token !== AUTH_TOKEN) {
|
||||
return res.status(401).json({ error: 'Token de autenticação inválido' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
app.use((req, res, next) => {
|
||||
req.correlationId = req.headers['x-correlation-id'] || crypto.randomUUID();
|
||||
res.setHeader('X-Correlation-ID', req.correlationId);
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.json());
|
||||
app.use('/hook', hookRouter);
|
||||
app.use(express.static(join(__dirname, 'public')));
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor(process.uptime()),
|
||||
});
|
||||
});
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
}));
|
||||
|
||||
app.use(compression());
|
||||
|
||||
app.use('/api', apiLimiter);
|
||||
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (!AUTH_TOKEN) return next();
|
||||
const header = req.headers.authorization || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : req.query.token;
|
||||
if (!timingSafeCompare(token, AUTH_TOKEN)) {
|
||||
return res.status(401).json({ error: 'Token de autenticação inválido' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.json({
|
||||
verify: (req, res, buf) => { req.rawBody = buf || Buffer.alloc(0); },
|
||||
}));
|
||||
app.use('/hook', hookLimiter, verifyWebhookSignature, hookRouter);
|
||||
app.use(express.static(join(__dirname, 'public'), {
|
||||
etag: true,
|
||||
setHeaders(res, filePath) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
},
|
||||
}));
|
||||
app.use('/api', apiRouter);
|
||||
|
||||
const connectedClients = new Map();
|
||||
@@ -51,20 +129,30 @@ wss.on('connection', (ws, req) => {
|
||||
|
||||
if (AUTH_TOKEN) {
|
||||
const token = new URL(req.url, 'http://localhost').searchParams.get('token');
|
||||
if (token !== AUTH_TOKEN) {
|
||||
if (!timingSafeCompare(token, AUTH_TOKEN)) {
|
||||
ws.close(4001, 'Token inválido');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ws.clientId = clientId;
|
||||
ws.isAlive = true;
|
||||
connectedClients.set(clientId, ws);
|
||||
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
ws.on('close', () => connectedClients.delete(clientId));
|
||||
ws.on('error', () => connectedClients.delete(clientId));
|
||||
ws.send(JSON.stringify({ type: 'connected', clientId }));
|
||||
});
|
||||
|
||||
const wsHeartbeat = setInterval(() => {
|
||||
wss.clients.forEach(ws => {
|
||||
if (!ws.isAlive) return ws.terminate();
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
function broadcast(message) {
|
||||
const payload = JSON.stringify(message);
|
||||
for (const [, client] of connectedClients) {
|
||||
@@ -86,30 +174,51 @@ setGlobalBroadcast(broadcast);
|
||||
function gracefulShutdown(signal) {
|
||||
console.log(`\nSinal ${signal} recebido. Encerrando servidor...`);
|
||||
|
||||
stopAllSchedules();
|
||||
console.log('Agendamentos parados.');
|
||||
|
||||
cancelAllExecutions();
|
||||
console.log('Execuções ativas canceladas.');
|
||||
|
||||
flushAllStores();
|
||||
console.log('Dados persistidos.');
|
||||
|
||||
httpServer.close(() => {
|
||||
console.log('Servidor HTTP encerrado.');
|
||||
process.exit(0);
|
||||
clearInterval(wsHeartbeat);
|
||||
|
||||
for (const client of wss.clients) {
|
||||
client.close(1001, 'Servidor encerrando');
|
||||
}
|
||||
connectedClients.clear();
|
||||
|
||||
wss.close(() => {
|
||||
console.log('WebSocket server encerrado.');
|
||||
httpServer.close(() => {
|
||||
console.log('Servidor HTTP encerrado.');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
console.error('Forçando encerramento após timeout.');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
}, 10000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[FATAL] Exceção não capturada:', err.message);
|
||||
console.error(err.stack);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[WARN] Promise rejeitada não tratada:', reason);
|
||||
});
|
||||
|
||||
manager.restoreSchedules();
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`Painel administrativo disponível em http://localhost:${PORT}`);
|
||||
httpServer.listen(PORT, HOST, () => {
|
||||
console.log(`Painel administrativo disponível em http://${HOST}:${PORT}`);
|
||||
console.log(`WebSocket server ativo na mesma porta.`);
|
||||
if (AUTH_TOKEN) console.log('Autenticação por token ativada.');
|
||||
});
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { settingsStore } from '../store/db.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const AGENT_SETTINGS = path.resolve(__dirname, '..', '..', 'data', 'agent-settings.json');
|
||||
const CLAUDE_BIN = resolveBin();
|
||||
const activeExecutions = new Map();
|
||||
const MAX_OUTPUT_SIZE = 512 * 1024;
|
||||
const MAX_ERROR_SIZE = 100 * 1024;
|
||||
const ALLOWED_DIRECTORIES = (process.env.ALLOWED_DIRECTORIES || '').split(',').map(d => d.trim()).filter(Boolean);
|
||||
|
||||
let maxConcurrent = settingsStore.get().maxConcurrent || 5;
|
||||
|
||||
@@ -12,6 +19,12 @@ export function updateMaxConcurrent(value) {
|
||||
maxConcurrent = Math.max(1, Math.min(20, parseInt(value) || 5));
|
||||
}
|
||||
|
||||
function isDirectoryAllowed(dir) {
|
||||
if (ALLOWED_DIRECTORIES.length === 0) return true;
|
||||
const resolved = path.resolve(dir);
|
||||
return ALLOWED_DIRECTORIES.some(allowed => resolved.startsWith(path.resolve(allowed)));
|
||||
}
|
||||
|
||||
function resolveBin() {
|
||||
if (process.env.CLAUDE_BIN) return process.env.CLAUDE_BIN;
|
||||
const home = process.env.HOME || '';
|
||||
@@ -34,19 +47,24 @@ function sanitizeText(str) {
|
||||
.slice(0, 50000);
|
||||
}
|
||||
|
||||
function cleanEnv() {
|
||||
function cleanEnv(agentSecrets) {
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
if (!env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
|
||||
env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '128000';
|
||||
env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '128000';
|
||||
if (agentSecrets && typeof agentSecrets === 'object') {
|
||||
Object.assign(env, agentSecrets);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function buildArgs(agentConfig, prompt) {
|
||||
function buildArgs(agentConfig) {
|
||||
const model = agentConfig.model || 'claude-sonnet-4-6';
|
||||
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--model', model];
|
||||
const args = ['--output-format', 'stream-json', '--verbose', '--model', model];
|
||||
|
||||
if (existsSync(AGENT_SETTINGS)) {
|
||||
args.push('--settings', AGENT_SETTINGS);
|
||||
}
|
||||
|
||||
if (agentConfig.systemPrompt) {
|
||||
args.push('--system-prompt', agentConfig.systemPrompt);
|
||||
@@ -111,58 +129,101 @@ function extractText(event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function execute(agentConfig, task, callbacks = {}) {
|
||||
if (activeExecutions.size >= maxConcurrent) {
|
||||
const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`);
|
||||
if (callbacks.onError) callbacks.onError(err, uuidv4());
|
||||
return null;
|
||||
}
|
||||
function extractToolInfo(event) {
|
||||
if (!event) return null;
|
||||
|
||||
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;
|
||||
if (event.type === 'assistant' && event.message?.content) {
|
||||
const toolBlocks = event.message.content.filter((b) => b.type === 'tool_use');
|
||||
if (toolBlocks.length > 0) {
|
||||
return toolBlocks.map((b) => {
|
||||
const name = b.name || 'unknown';
|
||||
const input = b.input || {};
|
||||
let detail = '';
|
||||
if (input.command) detail = input.command.slice(0, 120);
|
||||
else if (input.file_path) detail = input.file_path;
|
||||
else if (input.pattern) detail = input.pattern;
|
||||
else if (input.query) detail = input.query;
|
||||
else if (input.path) detail = input.path;
|
||||
else if (input.prompt) detail = input.prompt.slice(0, 80);
|
||||
else if (input.description) detail = input.description.slice(0, 80);
|
||||
return { name, detail };
|
||||
});
|
||||
}
|
||||
spawnOptions.cwd = agentConfig.workingDirectory;
|
||||
}
|
||||
|
||||
console.log(`[executor] Iniciando: ${executionId} | Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`);
|
||||
if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
||||
return [{ name: event.content_block.name || 'tool', detail: '' }];
|
||||
}
|
||||
|
||||
const child = spawn(CLAUDE_BIN, args, spawnOptions);
|
||||
let hadError = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
activeExecutions.set(executionId, {
|
||||
process: child,
|
||||
agentConfig,
|
||||
task,
|
||||
startedAt: new Date().toISOString(),
|
||||
executionId,
|
||||
});
|
||||
function extractSystemInfo(event) {
|
||||
if (!event) return null;
|
||||
|
||||
if (event.type === 'system' && event.message) return event.message;
|
||||
if (event.type === 'error') return event.error?.message || event.message || 'Erro desconhecido';
|
||||
|
||||
if (event.type === 'result') {
|
||||
const parts = [];
|
||||
if (event.num_turns) parts.push(`${event.num_turns} turnos`);
|
||||
if (event.cost_usd) parts.push(`custo: $${event.cost_usd.toFixed(4)}`);
|
||||
if (event.duration_ms) {
|
||||
const s = (event.duration_ms / 1000).toFixed(1);
|
||||
parts.push(`duração: ${s}s`);
|
||||
}
|
||||
if (event.session_id) parts.push(`sessão: ${event.session_id.slice(0, 8)}...`);
|
||||
return parts.length > 0 ? `Resultado: ${parts.join(' | ')}` : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function processChildOutput(child, executionId, callbacks, options = {}) {
|
||||
const { onData, onError, onComplete } = callbacks;
|
||||
const timeoutMs = options.timeout || 1800000;
|
||||
const sessionIdOverride = options.sessionIdOverride || null;
|
||||
let outputBuffer = '';
|
||||
let errorBuffer = '';
|
||||
let fullText = '';
|
||||
let resultMeta = null;
|
||||
let turnCount = 0;
|
||||
let hadError = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => { if (!child.killed) child.kill('SIGKILL'); }, 5000);
|
||||
}, timeoutMs);
|
||||
|
||||
function processEvent(parsed) {
|
||||
if (!parsed) return;
|
||||
|
||||
const tools = extractToolInfo(parsed);
|
||||
if (tools) {
|
||||
for (const t of tools) {
|
||||
const msg = t.detail ? `${t.name}: ${t.detail}` : t.name;
|
||||
if (onData) onData({ type: 'tool', content: msg, toolName: t.name }, executionId);
|
||||
}
|
||||
}
|
||||
|
||||
const text = extractText(parsed);
|
||||
if (text) {
|
||||
fullText += text;
|
||||
if (fullText.length < MAX_OUTPUT_SIZE) {
|
||||
fullText += text;
|
||||
}
|
||||
if (onData) onData({ type: 'chunk', content: text }, executionId);
|
||||
}
|
||||
|
||||
const sysInfo = extractSystemInfo(parsed);
|
||||
if (sysInfo) {
|
||||
if (onData) onData({ type: 'system', content: sysInfo }, executionId);
|
||||
}
|
||||
|
||||
if (parsed.type === 'assistant') {
|
||||
turnCount++;
|
||||
if (onData) onData({ type: 'turn', content: `Turno ${turnCount}`, turn: turnCount }, executionId);
|
||||
}
|
||||
|
||||
if (parsed.type === 'result') {
|
||||
resultMeta = {
|
||||
costUsd: parsed.cost_usd || 0,
|
||||
@@ -170,7 +231,7 @@ export function execute(agentConfig, task, callbacks = {}) {
|
||||
durationMs: parsed.duration_ms || 0,
|
||||
durationApiMs: parsed.duration_api_ms || 0,
|
||||
numTurns: parsed.num_turns || 0,
|
||||
sessionId: parsed.session_id || '',
|
||||
sessionId: parsed.session_id || sessionIdOverride || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -182,10 +243,18 @@ export function execute(agentConfig, task, callbacks = {}) {
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
errorBuffer += chunk.toString();
|
||||
const str = chunk.toString();
|
||||
if (errorBuffer.length < MAX_ERROR_SIZE) {
|
||||
errorBuffer += str;
|
||||
}
|
||||
const lines = str.split('\n').filter(l => l.trim());
|
||||
for (const line of lines) {
|
||||
if (onData) onData({ type: 'stderr', content: line.trim() }, executionId);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
console.log(`[executor][error] ${err.message}`);
|
||||
hadError = true;
|
||||
activeExecutions.delete(executionId);
|
||||
@@ -193,21 +262,87 @@ export function execute(agentConfig, task, callbacks = {}) {
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
const wasCanceled = activeExecutions.get(executionId)?.canceled || false;
|
||||
activeExecutions.delete(executionId);
|
||||
if (hadError) return;
|
||||
|
||||
if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer));
|
||||
|
||||
if (onComplete) {
|
||||
onComplete({
|
||||
executionId,
|
||||
exitCode: code,
|
||||
result: fullText,
|
||||
stderr: errorBuffer,
|
||||
canceled: wasCanceled,
|
||||
...(resultMeta || {}),
|
||||
}, executionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateWorkingDirectory(agentConfig, executionId, onError) {
|
||||
if (!agentConfig.workingDirectory || !agentConfig.workingDirectory.trim()) return true;
|
||||
|
||||
if (!isDirectoryAllowed(agentConfig.workingDirectory)) {
|
||||
const err = new Error(`Diretório de trabalho não permitido: ${agentConfig.workingDirectory}`);
|
||||
if (onError) onError(err, executionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!existsSync(agentConfig.workingDirectory)) {
|
||||
try {
|
||||
mkdirSync(agentConfig.workingDirectory, { recursive: true });
|
||||
} catch (e) {
|
||||
const err = new Error(`Não foi possível criar o diretório: ${agentConfig.workingDirectory} (${e.message})`);
|
||||
if (onError) onError(err, executionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function execute(agentConfig, task, callbacks = {}, secrets = null) {
|
||||
if (activeExecutions.size >= maxConcurrent) {
|
||||
const err = new Error(`Limite de ${maxConcurrent} execuções simultâneas atingido`);
|
||||
if (callbacks.onError) callbacks.onError(err, uuidv4());
|
||||
return null;
|
||||
}
|
||||
|
||||
const executionId = uuidv4();
|
||||
const { onData, onError, onComplete } = callbacks;
|
||||
|
||||
if (!validateWorkingDirectory(agentConfig, executionId, onError)) return null;
|
||||
|
||||
const prompt = buildPrompt(task.description || task, task.instructions);
|
||||
const args = buildArgs(agentConfig);
|
||||
|
||||
const spawnOptions = {
|
||||
env: cleanEnv(secrets),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
};
|
||||
|
||||
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) {
|
||||
spawnOptions.cwd = agentConfig.workingDirectory;
|
||||
}
|
||||
|
||||
console.log(`[executor] Iniciando: ${executionId} | Modelo: ${agentConfig.model || 'claude-sonnet-4-6'}`);
|
||||
|
||||
const child = spawn(CLAUDE_BIN, args, spawnOptions);
|
||||
child.stdin.write(prompt);
|
||||
child.stdin.end();
|
||||
|
||||
activeExecutions.set(executionId, {
|
||||
process: child,
|
||||
agentConfig,
|
||||
task,
|
||||
startedAt: new Date().toISOString(),
|
||||
executionId,
|
||||
});
|
||||
|
||||
processChildOutput(child, executionId, { onData, onError, onComplete }, {
|
||||
timeout: agentConfig.timeout || 1800000,
|
||||
});
|
||||
|
||||
return executionId;
|
||||
}
|
||||
@@ -222,6 +357,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
||||
const executionId = uuidv4();
|
||||
const { onData, onError, onComplete } = callbacks;
|
||||
|
||||
if (!validateWorkingDirectory(agentConfig, executionId, onError)) return null;
|
||||
|
||||
const model = agentConfig.model || 'claude-sonnet-4-6';
|
||||
const args = [
|
||||
'--resume', sessionId,
|
||||
@@ -232,6 +369,10 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
||||
'--permission-mode', agentConfig.permissionMode || 'bypassPermissions',
|
||||
];
|
||||
|
||||
if (existsSync(AGENT_SETTINGS)) {
|
||||
args.push('--settings', AGENT_SETTINGS);
|
||||
}
|
||||
|
||||
if (agentConfig.maxTurns && agentConfig.maxTurns > 0) {
|
||||
args.push('--max-turns', String(agentConfig.maxTurns));
|
||||
}
|
||||
@@ -242,18 +383,12 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
||||
};
|
||||
|
||||
if (agentConfig.workingDirectory && agentConfig.workingDirectory.trim()) {
|
||||
if (!existsSync(agentConfig.workingDirectory)) {
|
||||
const err = new Error(`Diretório de trabalho não encontrado: ${agentConfig.workingDirectory}`);
|
||||
if (onError) onError(err, executionId);
|
||||
return executionId;
|
||||
}
|
||||
spawnOptions.cwd = agentConfig.workingDirectory;
|
||||
}
|
||||
|
||||
console.log(`[executor] Resumindo sessão: ${sessionId} | Execução: ${executionId}`);
|
||||
|
||||
const child = spawn(CLAUDE_BIN, args, spawnOptions);
|
||||
let hadError = false;
|
||||
|
||||
activeExecutions.set(executionId, {
|
||||
process: child,
|
||||
@@ -263,60 +398,9 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
||||
executionId,
|
||||
});
|
||||
|
||||
let outputBuffer = '';
|
||||
let errorBuffer = '';
|
||||
let fullText = '';
|
||||
let resultMeta = null;
|
||||
|
||||
function processEvent(parsed) {
|
||||
if (!parsed) return;
|
||||
const text = extractText(parsed);
|
||||
if (text) {
|
||||
fullText += text;
|
||||
if (onData) onData({ type: 'chunk', content: text }, executionId);
|
||||
}
|
||||
if (parsed.type === 'result') {
|
||||
resultMeta = {
|
||||
costUsd: parsed.cost_usd || 0,
|
||||
totalCostUsd: parsed.total_cost_usd || 0,
|
||||
durationMs: parsed.duration_ms || 0,
|
||||
durationApiMs: parsed.duration_api_ms || 0,
|
||||
numTurns: parsed.num_turns || 0,
|
||||
sessionId: parsed.session_id || sessionId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
const lines = (outputBuffer + chunk.toString()).split('\n');
|
||||
outputBuffer = lines.pop();
|
||||
for (const line of lines) processEvent(parseStreamLine(line));
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
errorBuffer += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.log(`[executor][error] ${err.message}`);
|
||||
hadError = true;
|
||||
activeExecutions.delete(executionId);
|
||||
if (onError) onError(err, executionId);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
activeExecutions.delete(executionId);
|
||||
if (hadError) return;
|
||||
if (outputBuffer.trim()) processEvent(parseStreamLine(outputBuffer));
|
||||
if (onComplete) {
|
||||
onComplete({
|
||||
executionId,
|
||||
exitCode: code,
|
||||
result: fullText,
|
||||
stderr: errorBuffer,
|
||||
...(resultMeta || {}),
|
||||
}, executionId);
|
||||
}
|
||||
processChildOutput(child, executionId, { onData, onError, onComplete }, {
|
||||
timeout: agentConfig.timeout || 1800000,
|
||||
sessionIdOverride: sessionId,
|
||||
});
|
||||
|
||||
return executionId;
|
||||
@@ -325,8 +409,8 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
|
||||
export function cancel(executionId) {
|
||||
const execution = activeExecutions.get(executionId);
|
||||
if (!execution) return false;
|
||||
execution.canceled = true;
|
||||
execution.process.kill('SIGTERM');
|
||||
activeExecutions.delete(executionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -343,6 +427,66 @@ export function getActiveExecutions() {
|
||||
}));
|
||||
}
|
||||
|
||||
export function summarize(text, threshold = 1500) {
|
||||
return new Promise((resolve) => {
|
||||
if (!text || text.length <= threshold) {
|
||||
resolve(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = `Resuma o conteúdo abaixo de forma estruturada e concisa. Preserve TODAS as informações críticas:
|
||||
- Decisões técnicas e justificativas
|
||||
- Trechos de código essenciais
|
||||
- Dados, números e métricas
|
||||
- Problemas encontrados e soluções
|
||||
- Recomendações e próximos passos
|
||||
|
||||
Organize o resumo usando <tags> XML (ex: <decisoes>, <codigo>, <problemas>, <recomendacoes>).
|
||||
NÃO omita informações que seriam necessárias para outro profissional continuar o trabalho.
|
||||
|
||||
<conteudo_para_resumir>
|
||||
${text}
|
||||
</conteudo_para_resumir>`;
|
||||
|
||||
const args = [
|
||||
'--output-format', 'text',
|
||||
'--model', 'claude-haiku-4-5-20251001',
|
||||
'--max-turns', '1',
|
||||
'--permission-mode', 'bypassPermissions',
|
||||
];
|
||||
|
||||
if (existsSync(AGENT_SETTINGS)) {
|
||||
args.push('--settings', AGENT_SETTINGS);
|
||||
}
|
||||
|
||||
const child = spawn(CLAUDE_BIN, args, {
|
||||
env: cleanEnv(),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
child.stdin.write(prompt);
|
||||
child.stdin.end();
|
||||
|
||||
let output = '';
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
}, 120000);
|
||||
|
||||
child.stdout.on('data', (chunk) => { output += chunk.toString(); });
|
||||
|
||||
child.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
const result = output.trim();
|
||||
console.log(`[executor] Sumarização: ${text.length} → ${result.length} chars`);
|
||||
resolve(result || text);
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(text);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getBinPath() {
|
||||
return CLAUDE_BIN;
|
||||
}
|
||||
|
||||
117
src/agents/git-integration.js
Normal file
117
src/agents/git-integration.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
|
||||
const PROJECTS_DIR = '/home/projetos';
|
||||
const GITEA_URL = () => process.env.GITEA_URL || 'http://gitea:3000';
|
||||
const GITEA_USER = () => process.env.GITEA_USER || 'fred';
|
||||
const GITEA_PASS = () => process.env.GITEA_PASS || '';
|
||||
const DOMAIN = () => process.env.DOMAIN || 'nitro-cloud.duckdns.org';
|
||||
|
||||
function exec(cmd, cwd) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('sh', ['-c', cmd], {
|
||||
cwd,
|
||||
env: { ...process.env, HOME: '/tmp', GIT_TERMINAL_PROMPT: '0' },
|
||||
});
|
||||
let stdout = '', stderr = '';
|
||||
proc.stdout.on('data', d => stdout += d);
|
||||
proc.stderr.on('data', d => stderr += d);
|
||||
proc.on('close', code =>
|
||||
code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `exit ${code}`))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function authHeader() {
|
||||
return 'Basic ' + Buffer.from(`${GITEA_USER()}:${GITEA_PASS()}`).toString('base64');
|
||||
}
|
||||
|
||||
function repoCloneUrl(repoName) {
|
||||
return `${GITEA_URL().replace('://', `://${GITEA_USER()}:${GITEA_PASS()}@`)}/${GITEA_USER()}/${repoName}.git`;
|
||||
}
|
||||
|
||||
export async function listRepos() {
|
||||
const url = `${GITEA_URL()}/api/v1/user/repos?limit=50&sort=updated`;
|
||||
const res = await fetch(url, { headers: { Authorization: authHeader() } });
|
||||
if (!res.ok) throw new Error('Erro ao listar repositórios');
|
||||
const repos = await res.json();
|
||||
return repos.map(r => ({
|
||||
name: r.name,
|
||||
fullName: r.full_name,
|
||||
description: r.description || '',
|
||||
defaultBranch: r.default_branch || 'main',
|
||||
updatedAt: r.updated_at,
|
||||
htmlUrl: r.html_url,
|
||||
cloneUrl: r.clone_url,
|
||||
empty: r.empty,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listBranches(repoName) {
|
||||
const url = `${GITEA_URL()}/api/v1/repos/${GITEA_USER()}/${repoName}/branches?limit=50`;
|
||||
const res = await fetch(url, { headers: { Authorization: authHeader() } });
|
||||
if (!res.ok) return [];
|
||||
const branches = await res.json();
|
||||
return branches.map(b => b.name);
|
||||
}
|
||||
|
||||
export async function cloneOrPull(repoName, branch) {
|
||||
const targetDir = join(PROJECTS_DIR, repoName);
|
||||
const cloneUrl = repoCloneUrl(repoName);
|
||||
|
||||
if (existsSync(join(targetDir, '.git'))) {
|
||||
await exec(`git remote set-url origin "${cloneUrl}"`, targetDir);
|
||||
await exec('git fetch origin', targetDir);
|
||||
if (branch) {
|
||||
try {
|
||||
await exec(`git checkout ${branch}`, targetDir);
|
||||
} catch {
|
||||
await exec(`git checkout -b ${branch} origin/${branch}`, targetDir);
|
||||
}
|
||||
await exec(`git reset --hard origin/${branch}`, targetDir);
|
||||
} else {
|
||||
const currentBranch = await exec('git rev-parse --abbrev-ref HEAD', targetDir);
|
||||
await exec(`git reset --hard origin/${currentBranch}`, targetDir);
|
||||
}
|
||||
return { dir: targetDir, action: 'pull' };
|
||||
}
|
||||
|
||||
const branchArg = branch ? `-b ${branch}` : '';
|
||||
await exec(`git clone ${branchArg} "${cloneUrl}" "${targetDir}"`);
|
||||
return { dir: targetDir, action: 'clone' };
|
||||
}
|
||||
|
||||
export async function commitAndPush(repoDir, agentName, taskSummary) {
|
||||
try {
|
||||
const status = await exec('git status --porcelain', repoDir);
|
||||
if (!status) return { changed: false };
|
||||
|
||||
await exec('git add -A', repoDir);
|
||||
|
||||
const summary = taskSummary
|
||||
? taskSummary.slice(0, 100).replace(/"/g, '\\"')
|
||||
: 'Alterações automáticas';
|
||||
|
||||
const message = `${summary}\n\nExecutado por: ${agentName}`;
|
||||
await exec(
|
||||
`git -c user.name="Agents Orchestrator" -c user.email="agents@${DOMAIN()}" commit -m "${message}"`,
|
||||
repoDir
|
||||
);
|
||||
|
||||
await exec('git push origin HEAD', repoDir);
|
||||
|
||||
const commitHash = await exec('git rev-parse --short HEAD', repoDir);
|
||||
const branch = await exec('git rev-parse --abbrev-ref HEAD', repoDir);
|
||||
const repoName = basename(repoDir);
|
||||
const commitUrl = `https://git.${DOMAIN()}/${GITEA_USER()}/${repoName}/commit/${commitHash}`;
|
||||
|
||||
return { changed: true, commitHash, branch, commitUrl, filesChanged: status.split('\n').length };
|
||||
} catch (err) {
|
||||
return { changed: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
export function getProjectDir(repoName) {
|
||||
return join(PROJECTS_DIR, repoName);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { agentsStore, schedulesStore, executionsStore } from '../store/db.js';
|
||||
import cron from 'node-cron';
|
||||
import { agentsStore, schedulesStore, executionsStore, notificationsStore, secretsStore, agentVersionsStore, withLock } from '../store/db.js';
|
||||
import * as executor from './executor.js';
|
||||
import * as scheduler from './scheduler.js';
|
||||
import { generateAgentReport } from '../reports/generator.js';
|
||||
import * as gitIntegration from './git-integration.js';
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
model: 'claude-sonnet-4-6',
|
||||
@@ -25,6 +28,14 @@ function getWsCallback(wsCallback) {
|
||||
return wsCallback || globalBroadcast || null;
|
||||
}
|
||||
|
||||
function createNotification(type, title, message, metadata = {}) {
|
||||
notificationsStore.create({
|
||||
type, title, message, metadata,
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
let dailyExecutionCount = 0;
|
||||
let dailyCountDate = new Date().toDateString();
|
||||
|
||||
@@ -90,6 +101,13 @@ export function createAgent(data) {
|
||||
export function updateAgent(id, data) {
|
||||
const existing = agentsStore.getById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
agentVersionsStore.create({
|
||||
agentId: id,
|
||||
version: existing,
|
||||
changedFields: Object.keys(data).filter(k => k !== 'id'),
|
||||
});
|
||||
|
||||
const updateData = {};
|
||||
if (data.agent_name !== undefined) updateData.agent_name = data.agent_name;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
@@ -105,25 +123,44 @@ export function deleteAgent(id) {
|
||||
return agentsStore.delete(id);
|
||||
}
|
||||
|
||||
function loadAgentSecrets(agentId) {
|
||||
const all = secretsStore.getAll();
|
||||
const agentSecrets = all.filter(s => s.agentId === agentId);
|
||||
if (agentSecrets.length === 0) return null;
|
||||
const env = {};
|
||||
for (const s of agentSecrets) env[s.name] = s.value;
|
||||
return env;
|
||||
}
|
||||
|
||||
export function executeTask(agentId, task, instructions, wsCallback, metadata = {}) {
|
||||
const agent = agentsStore.getById(agentId);
|
||||
if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
|
||||
if (agent.status !== 'active') throw new Error(`Agente ${agentId} está inativo`);
|
||||
|
||||
const retryEnabled = agent.config?.retryOnFailure === true;
|
||||
const maxRetries = Math.min(Math.max(parseInt(agent.config?.maxRetries) || 1, 1), 3);
|
||||
const attempt = metadata._retryAttempt || 1;
|
||||
|
||||
const cb = getWsCallback(wsCallback);
|
||||
const taskText = typeof task === 'string' ? task : task.description;
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
const historyRecord = executionsStore.create({
|
||||
type: 'agent',
|
||||
...metadata,
|
||||
agentId,
|
||||
agentName: agent.agent_name,
|
||||
task: taskText,
|
||||
instructions: instructions || '',
|
||||
status: 'running',
|
||||
startedAt,
|
||||
});
|
||||
const historyRecord = metadata._historyRecordId
|
||||
? { id: metadata._historyRecordId }
|
||||
: executionsStore.create({
|
||||
type: 'agent',
|
||||
...metadata,
|
||||
agentId,
|
||||
agentName: agent.agent_name,
|
||||
task: taskText,
|
||||
instructions: instructions || '',
|
||||
status: 'running',
|
||||
startedAt,
|
||||
});
|
||||
|
||||
if (metadata._retryAttempt) {
|
||||
executionsStore.update(historyRecord.id, { status: 'running', error: null });
|
||||
}
|
||||
|
||||
const execRecord = {
|
||||
executionId: null,
|
||||
@@ -134,9 +171,28 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
status: 'running',
|
||||
};
|
||||
|
||||
const agentSecrets = loadAgentSecrets(agentId);
|
||||
|
||||
let effectiveInstructions = instructions || '';
|
||||
const tags = agent.tags || [];
|
||||
if (tags.includes('coordinator')) {
|
||||
const allAgents = agentsStore.getAll().filter(a => a.id !== agentId && a.status === 'active');
|
||||
const agentList = allAgents.map(a => `- **${a.agent_name}**: ${a.description || 'Sem descrição'}`).join('\n');
|
||||
effectiveInstructions += `\n\n<agentes_disponiveis>\n${agentList}\n</agentes_disponiveis>`;
|
||||
}
|
||||
|
||||
if (metadata.repoName) {
|
||||
effectiveInstructions += `\n\n<git_repository>\nVocê está trabalhando no repositório "${metadata.repoName}". NÃO faça git init, git commit, git push ou qualquer operação git. O sistema fará commit e push automaticamente ao final da execução. Foque apenas no código.\n</git_repository>`;
|
||||
}
|
||||
|
||||
const effectiveConfig = { ...agent.config };
|
||||
if (metadata.workingDirectoryOverride) {
|
||||
effectiveConfig.workingDirectory = metadata.workingDirectoryOverride;
|
||||
}
|
||||
|
||||
const executionId = executor.execute(
|
||||
agent.config,
|
||||
{ description: task, instructions },
|
||||
effectiveConfig,
|
||||
{ description: task, instructions: effectiveInstructions },
|
||||
{
|
||||
onData: (parsed, execId) => {
|
||||
if (cb) cb({ type: 'execution_output', executionId: execId, agentId, data: parsed });
|
||||
@@ -144,7 +200,33 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
onError: (err, execId) => {
|
||||
const endedAt = new Date().toISOString();
|
||||
updateExecutionRecord(agentId, execId, { status: 'error', error: err.message, endedAt });
|
||||
|
||||
if (retryEnabled && attempt < maxRetries) {
|
||||
const delayMs = attempt * 5000;
|
||||
executionsStore.update(historyRecord.id, { status: 'retrying', error: err.message, attempt, endedAt });
|
||||
if (cb) cb({
|
||||
type: 'execution_retry',
|
||||
executionId: execId,
|
||||
agentId,
|
||||
data: { attempt, maxRetries, nextRetryIn: delayMs / 1000, reason: err.message },
|
||||
});
|
||||
setTimeout(() => {
|
||||
try {
|
||||
executeTask(agentId, task, instructions, wsCallback, {
|
||||
...metadata,
|
||||
_retryAttempt: attempt + 1,
|
||||
_historyRecordId: historyRecord.id,
|
||||
});
|
||||
} catch (retryErr) {
|
||||
executionsStore.update(historyRecord.id, { status: 'error', error: retryErr.message, endedAt: new Date().toISOString() });
|
||||
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: retryErr.message } });
|
||||
}
|
||||
}, delayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
executionsStore.update(historyRecord.id, { status: 'error', error: err.message, endedAt });
|
||||
createNotification('error', 'Execução falhou', `Agente "${agent.agent_name}" encontrou um erro`, { agentId, executionId: execId });
|
||||
if (cb) cb({ type: 'execution_error', executionId: execId, agentId, data: { error: err.message } });
|
||||
},
|
||||
onComplete: (result, execId) => {
|
||||
@@ -161,9 +243,61 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
numTurns: result.numTurns || 0,
|
||||
sessionId: result.sessionId || '',
|
||||
});
|
||||
createNotification('success', 'Execução concluída', `Agente "${agent.agent_name}" finalizou a tarefa`, { agentId, executionId: execId });
|
||||
try {
|
||||
const updated = executionsStore.getById(historyRecord.id);
|
||||
if (updated) {
|
||||
const report = generateAgentReport(updated);
|
||||
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
|
||||
}
|
||||
} catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); }
|
||||
if (metadata.repoName && result.result) {
|
||||
const repoDir = gitIntegration.getProjectDir(metadata.repoName);
|
||||
gitIntegration.commitAndPush(repoDir, agent.agent_name, taskText.slice(0, 100))
|
||||
.then(gitResult => {
|
||||
if (gitResult.changed) {
|
||||
console.log(`[manager] Auto-commit: ${gitResult.commitHash} em ${metadata.repoName}`);
|
||||
if (cb) cb({
|
||||
type: 'execution_output', executionId: execId, agentId,
|
||||
data: { type: 'success', content: `Git: commit ${gitResult.commitHash} pushed para ${metadata.repoName} (${gitResult.filesChanged} arquivos) → ${gitResult.commitUrl}` },
|
||||
});
|
||||
} else if (gitResult.error) {
|
||||
console.error(`[manager] Erro no auto-commit:`, gitResult.error);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error(`[manager] Erro no auto-commit:`, err.message));
|
||||
}
|
||||
|
||||
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
|
||||
|
||||
const isPipelineStep = !!metadata.pipelineExecutionId;
|
||||
const delegateTo = agent.config?.delegateTo;
|
||||
if (!isPipelineStep && delegateTo && result.result) {
|
||||
const delegateAgent = agentsStore.getById(delegateTo);
|
||||
if (delegateAgent && delegateAgent.status === 'active') {
|
||||
console.log(`[manager] Auto-delegando de "${agent.agent_name}" para "${delegateAgent.agent_name}"`);
|
||||
if (cb) cb({
|
||||
type: 'execution_output',
|
||||
executionId: execId,
|
||||
agentId,
|
||||
data: { type: 'system', content: `Delegando para ${delegateAgent.agent_name}...` },
|
||||
});
|
||||
setTimeout(() => {
|
||||
try {
|
||||
executeTask(delegateTo, result.result, null, wsCallback, {
|
||||
delegatedFrom: agent.agent_name,
|
||||
originalTask: taskText,
|
||||
});
|
||||
} catch (delegateErr) {
|
||||
console.error(`[manager] Erro ao delegar para "${delegateAgent.agent_name}":`, delegateErr.message);
|
||||
if (cb) cb({ type: 'execution_error', executionId: execId, agentId: delegateTo, data: { error: delegateErr.message } });
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
agentSecrets
|
||||
);
|
||||
|
||||
if (!executionId) {
|
||||
@@ -185,28 +319,46 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
return executionId;
|
||||
}
|
||||
|
||||
function updateRecentBuffer(executionId, updates) {
|
||||
const entry = recentExecBuffer.find((e) => e.executionId === executionId);
|
||||
if (entry) Object.assign(entry, updates);
|
||||
}
|
||||
|
||||
function updateExecutionRecord(agentId, executionId, updates) {
|
||||
const agent = agentsStore.getById(agentId);
|
||||
if (!agent) return;
|
||||
const executions = (agent.executions || []).map((exec) =>
|
||||
exec.executionId === executionId ? { ...exec, ...updates } : exec
|
||||
);
|
||||
agentsStore.update(agentId, { executions });
|
||||
async function updateExecutionRecord(agentId, executionId, updates) {
|
||||
await withLock(`agent:${agentId}`, () => {
|
||||
const agent = agentsStore.getById(agentId);
|
||||
if (!agent) return;
|
||||
const executions = (agent.executions || []).map((exec) =>
|
||||
exec.executionId === executionId ? { ...exec, ...updates } : exec
|
||||
);
|
||||
agentsStore.update(agentId, { executions });
|
||||
});
|
||||
}
|
||||
|
||||
export function getRecentExecutions(limit = 20) {
|
||||
return recentExecBuffer.slice(0, Math.min(limit, MAX_RECENT));
|
||||
}
|
||||
|
||||
async function executeWithRetry(agentId, taskDescription, metadata, maxRetries = 10, baseDelay = 30000) {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
executeTask(agentId, taskDescription, null, null, metadata);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (err.message.includes('Limite de execuções simultâneas') && attempt < maxRetries) {
|
||||
const delay = baseDelay + Math.random() * 10000;
|
||||
console.log(`[manager] Agendamento aguardando slot (tentativa ${attempt}/${maxRetries}), retry em ${(delay / 1000).toFixed(0)}s`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleTask(agentId, taskDescription, cronExpression, wsCallback) {
|
||||
const agent = agentsStore.getById(agentId);
|
||||
if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
|
||||
|
||||
if (!cron.validate(cronExpression)) {
|
||||
throw new Error(`Expressão cron inválida: ${cronExpression}`);
|
||||
}
|
||||
|
||||
const scheduleId = uuidv4();
|
||||
const items = schedulesStore.getAll();
|
||||
items.push({
|
||||
@@ -222,7 +374,9 @@ export function scheduleTask(agentId, taskDescription, cronExpression, wsCallbac
|
||||
schedulesStore.save(items);
|
||||
|
||||
scheduler.schedule(scheduleId, cronExpression, () => {
|
||||
executeTask(agentId, taskDescription, null, null, { source: 'schedule', scheduleId });
|
||||
executeWithRetry(agentId, taskDescription, { source: 'schedule', scheduleId }).catch(err => {
|
||||
console.log(`[manager] Agendamento ${scheduleId} falhou após retries: ${err.message}`);
|
||||
});
|
||||
}, false);
|
||||
|
||||
return { scheduleId, agentId, agentName: agent.agent_name, taskDescription, cronExpression };
|
||||
@@ -240,7 +394,9 @@ export function updateScheduleTask(scheduleId, data, wsCallback) {
|
||||
const cronExpression = data.cronExpression || stored.cronExpression;
|
||||
|
||||
scheduler.updateSchedule(scheduleId, cronExpression, () => {
|
||||
executeTask(agentId, taskDescription, null, null, { source: 'schedule', scheduleId });
|
||||
executeWithRetry(agentId, taskDescription, { source: 'schedule', scheduleId }).catch(err => {
|
||||
console.log(`[manager] Agendamento ${scheduleId} falhou após retries: ${err.message}`);
|
||||
});
|
||||
});
|
||||
|
||||
schedulesStore.update(scheduleId, { agentId, agentName: agent.agent_name, taskDescription, cronExpression });
|
||||
@@ -290,6 +446,13 @@ export function continueConversation(agentId, sessionId, message, wsCallback) {
|
||||
numTurns: result.numTurns || 0,
|
||||
sessionId: result.sessionId || sessionId,
|
||||
});
|
||||
try {
|
||||
const updated = executionsStore.getById(historyRecord.id);
|
||||
if (updated) {
|
||||
const report = generateAgentReport(updated);
|
||||
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
|
||||
}
|
||||
} catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); }
|
||||
if (cb) cb({ type: 'execution_complete', executionId: execId, agentId, data: result });
|
||||
},
|
||||
}
|
||||
@@ -343,10 +506,8 @@ export function importAgent(data) {
|
||||
|
||||
export function restoreSchedules() {
|
||||
scheduler.restoreSchedules((agentId, taskDescription, scheduleId) => {
|
||||
try {
|
||||
executeTask(agentId, taskDescription, null, null, { source: 'schedule', scheduleId });
|
||||
} catch (err) {
|
||||
executeWithRetry(agentId, taskDescription, { source: 'schedule', scheduleId }).catch(err => {
|
||||
console.log(`[manager] Erro ao executar tarefa agendada: ${err.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { pipelinesStore, agentsStore, executionsStore } from '../store/db.js';
|
||||
import * as executor from './executor.js';
|
||||
import * as gitIntegration from './git-integration.js';
|
||||
import { mem } from '../cache/index.js';
|
||||
import { generatePipelineReport } from '../reports/generator.js';
|
||||
|
||||
const activePipelines = new Map();
|
||||
const AGENT_MAP_TTL = 30_000;
|
||||
@@ -85,6 +87,7 @@ function executeStepAsPromise(agentConfig, prompt, pipelineState, wsCallback, pi
|
||||
costUsd: result.costUsd || 0,
|
||||
durationMs: result.durationMs || 0,
|
||||
numTurns: result.numTurns || 0,
|
||||
sessionId: result.sessionId || '',
|
||||
});
|
||||
},
|
||||
}
|
||||
@@ -99,9 +102,9 @@ function executeStepAsPromise(agentConfig, prompt, pipelineState, wsCallback, pi
|
||||
});
|
||||
}
|
||||
|
||||
function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCallback) {
|
||||
function waitForApproval(executionId, pipelineId, stepIndex, previousOutput, agentName, wsCallback) {
|
||||
return new Promise((resolve) => {
|
||||
const state = activePipelines.get(pipelineId);
|
||||
const state = activePipelines.get(executionId);
|
||||
if (!state) { resolve(false); return; }
|
||||
|
||||
state.pendingApproval = {
|
||||
@@ -115,6 +118,7 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal
|
||||
wsCallback({
|
||||
type: 'pipeline_approval_required',
|
||||
pipelineId,
|
||||
executionId,
|
||||
stepIndex,
|
||||
agentName,
|
||||
previousOutput: previousOutput.slice(0, 3000),
|
||||
@@ -123,8 +127,16 @@ function waitForApproval(pipelineId, stepIndex, previousOutput, agentName, wsCal
|
||||
});
|
||||
}
|
||||
|
||||
export function approvePipelineStep(pipelineId) {
|
||||
const state = activePipelines.get(pipelineId);
|
||||
function findPipelineState(idOrExecId) {
|
||||
if (activePipelines.has(idOrExecId)) return activePipelines.get(idOrExecId);
|
||||
for (const [, state] of activePipelines) {
|
||||
if (state.pipelineId === idOrExecId) return state;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function approvePipelineStep(id) {
|
||||
const state = findPipelineState(id);
|
||||
if (!state?.pendingApproval) return false;
|
||||
const { resolve } = state.pendingApproval;
|
||||
state.pendingApproval = null;
|
||||
@@ -132,8 +144,8 @@ export function approvePipelineStep(pipelineId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function rejectPipelineStep(pipelineId) {
|
||||
const state = activePipelines.get(pipelineId);
|
||||
export function rejectPipelineStep(id) {
|
||||
const state = findPipelineState(id);
|
||||
if (!state?.pendingApproval) return false;
|
||||
const { resolve } = state.pendingApproval;
|
||||
state.pendingApproval = null;
|
||||
@@ -144,9 +156,11 @@ export function rejectPipelineStep(pipelineId) {
|
||||
export async function executePipeline(pipelineId, initialInput, wsCallback, options = {}) {
|
||||
const pl = pipelinesStore.getById(pipelineId);
|
||||
if (!pl) throw new Error(`Pipeline ${pipelineId} não encontrado`);
|
||||
if (pl.status !== 'active') throw new Error(`Pipeline "${pl.name}" está desativado`);
|
||||
|
||||
const pipelineState = { currentExecutionId: null, currentStep: 0, canceled: false, pendingApproval: null };
|
||||
activePipelines.set(pipelineId, pipelineState);
|
||||
const executionId = uuidv4();
|
||||
const pipelineState = { pipelineId, currentExecutionId: null, currentStep: 0, canceled: false, pendingApproval: null };
|
||||
activePipelines.set(executionId, pipelineState);
|
||||
|
||||
const historyRecord = executionsStore.create({
|
||||
type: 'pipeline',
|
||||
@@ -180,7 +194,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
wsCallback({ type: 'pipeline_status', pipelineId, status: 'awaiting_approval', stepIndex: i });
|
||||
}
|
||||
|
||||
const approved = await waitForApproval(pipelineId, i, currentInput, prevAgentName, wsCallback);
|
||||
const approved = await waitForApproval(executionId, pipelineId, i, currentInput, prevAgentName, wsCallback);
|
||||
|
||||
if (!approved) {
|
||||
pipelineState.canceled = true;
|
||||
@@ -201,8 +215,9 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
if (agent.status !== 'active') throw new Error(`Agente ${agent.agent_name} está inativo`);
|
||||
|
||||
const stepConfig = { ...agent.config };
|
||||
if (options.workingDirectory) {
|
||||
stepConfig.workingDirectory = options.workingDirectory;
|
||||
const effectiveWorkdir = options.workingDirectory || pl.workingDirectory;
|
||||
if (effectiveWorkdir) {
|
||||
stepConfig.workingDirectory = effectiveWorkdir;
|
||||
}
|
||||
|
||||
const prompt = applyTemplate(step.inputTemplate, currentInput);
|
||||
@@ -225,7 +240,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
|
||||
totalCost += stepResult.costUsd;
|
||||
currentInput = stepResult.text;
|
||||
results.push({ stepId: step.id, agentName: agent.agent_name, result: stepResult.text });
|
||||
results.push({ stepId: step.id, agentId: step.agentId, agentName: agent.agent_name, result: stepResult.text, sessionId: stepResult.sessionId });
|
||||
|
||||
const current = executionsStore.getById(historyRecord.id);
|
||||
const savedSteps = current ? (current.steps || []) : [];
|
||||
@@ -254,9 +269,22 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
costUsd: stepResult.costUsd,
|
||||
});
|
||||
}
|
||||
|
||||
if (i < steps.length - 1 && !pipelineState.canceled) {
|
||||
if (wsCallback) {
|
||||
wsCallback({ type: 'pipeline_summarizing', pipelineId, stepIndex: i, originalLength: currentInput.length });
|
||||
}
|
||||
const summarized = await executor.summarize(currentInput);
|
||||
if (summarized !== currentInput) {
|
||||
if (wsCallback) {
|
||||
wsCallback({ type: 'pipeline_summarized', pipelineId, stepIndex: i, originalLength: currentInput.length, summarizedLength: summarized.length });
|
||||
}
|
||||
currentInput = summarized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activePipelines.delete(pipelineId);
|
||||
activePipelines.delete(executionId);
|
||||
|
||||
const finalStatus = pipelineState.canceled ? 'canceled' : 'completed';
|
||||
executionsStore.update(historyRecord.id, {
|
||||
@@ -265,16 +293,48 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
totalCostUsd: totalCost,
|
||||
});
|
||||
|
||||
if (!pipelineState.canceled && wsCallback) {
|
||||
wsCallback({ type: 'pipeline_complete', pipelineId, results, totalCostUsd: totalCost });
|
||||
if (!pipelineState.canceled) {
|
||||
if (options.repoName) {
|
||||
try {
|
||||
const repoDir = gitIntegration.getProjectDir(options.repoName);
|
||||
const gitResult = await gitIntegration.commitAndPush(repoDir, pl.name, `Pipeline: ${pl.name}`);
|
||||
if (gitResult.changed && wsCallback) {
|
||||
wsCallback({
|
||||
type: 'pipeline_step_output', pipelineId, stepIndex: steps.length - 1,
|
||||
data: { type: 'success', content: `Git: commit ${gitResult.commitHash} pushed para ${options.repoName} (${gitResult.filesChanged} arquivos) → ${gitResult.commitUrl}` },
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error('[pipeline] Erro no auto-commit:', e.message); }
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = executionsStore.getById(historyRecord.id);
|
||||
if (updated) {
|
||||
const report = generatePipelineReport(updated);
|
||||
if (wsCallback) wsCallback({ type: 'report_generated', pipelineId, reportFile: report.filename });
|
||||
}
|
||||
} catch (e) { console.error('[pipeline] Erro ao gerar relatório:', e.message); }
|
||||
const lastResult = results.length > 0 ? results[results.length - 1] : null;
|
||||
if (wsCallback) wsCallback({
|
||||
type: 'pipeline_complete',
|
||||
pipelineId,
|
||||
executionId,
|
||||
results,
|
||||
totalCostUsd: totalCost,
|
||||
lastAgentId: lastResult?.agentId || '',
|
||||
lastAgentName: lastResult?.agentName || '',
|
||||
lastSessionId: lastResult?.sessionId || '',
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
return { executionId, results };
|
||||
} catch (err) {
|
||||
activePipelines.delete(pipelineId);
|
||||
activePipelines.delete(executionId);
|
||||
executionsStore.update(historyRecord.id, {
|
||||
status: 'error',
|
||||
error: err.message,
|
||||
failedAtStep: pipelineState.currentStep,
|
||||
lastStepInput: currentInput.slice(0, 50000),
|
||||
endedAt: new Date().toISOString(),
|
||||
totalCostUsd: totalCost,
|
||||
});
|
||||
@@ -290,8 +350,14 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelPipeline(pipelineId) {
|
||||
const state = activePipelines.get(pipelineId);
|
||||
export function cancelPipeline(id) {
|
||||
let executionId = id;
|
||||
let state = activePipelines.get(id);
|
||||
if (!state) {
|
||||
for (const [execId, s] of activePipelines) {
|
||||
if (s.pipelineId === id) { state = s; executionId = execId; break; }
|
||||
}
|
||||
}
|
||||
if (!state) return false;
|
||||
state.canceled = true;
|
||||
if (state.pendingApproval) {
|
||||
@@ -299,13 +365,190 @@ export function cancelPipeline(pipelineId) {
|
||||
state.pendingApproval = null;
|
||||
}
|
||||
if (state.currentExecutionId) executor.cancel(state.currentExecutionId);
|
||||
activePipelines.delete(pipelineId);
|
||||
activePipelines.delete(executionId);
|
||||
|
||||
const allExecs = executionsStore.getAll();
|
||||
const exec = allExecs.find(e => e.pipelineId === state.pipelineId && (e.status === 'running' || e.status === 'awaiting_approval'));
|
||||
if (exec) {
|
||||
executionsStore.update(exec.id, { status: 'canceled', endedAt: new Date().toISOString() });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function resumePipeline(executionId, wsCallback, options = {}) {
|
||||
const prevExec = executionsStore.getById(executionId);
|
||||
if (!prevExec) throw new Error('Execução não encontrada');
|
||||
if (prevExec.status !== 'error') throw new Error('Só é possível retomar execuções com erro');
|
||||
if (prevExec.type !== 'pipeline') throw new Error('Execução não é de pipeline');
|
||||
|
||||
const pl = pipelinesStore.getById(prevExec.pipelineId);
|
||||
if (!pl) throw new Error(`Pipeline ${prevExec.pipelineId} não encontrado`);
|
||||
|
||||
const startStep = prevExec.failedAtStep || 0;
|
||||
const initialInput = prevExec.lastStepInput || prevExec.input;
|
||||
|
||||
const newExecId = uuidv4();
|
||||
const pipelineState = { pipelineId: prevExec.pipelineId, currentExecutionId: null, currentStep: startStep, canceled: false, pendingApproval: null };
|
||||
activePipelines.set(newExecId, pipelineState);
|
||||
|
||||
const prevSteps = Array.isArray(prevExec.steps) ? [...prevExec.steps] : [];
|
||||
const prevCost = prevExec.totalCostUsd || 0;
|
||||
|
||||
const historyRecord = executionsStore.create({
|
||||
type: 'pipeline',
|
||||
pipelineId: prevExec.pipelineId,
|
||||
pipelineName: pl.name,
|
||||
input: prevExec.input,
|
||||
resumedFrom: executionId,
|
||||
resumedAtStep: startStep,
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
steps: prevSteps,
|
||||
totalCostUsd: prevCost,
|
||||
});
|
||||
|
||||
const steps = buildSteps(pl.steps);
|
||||
const results = prevSteps.map(s => ({ stepId: s.stepId, agentId: s.agentId, agentName: s.agentName, result: s.result, sessionId: '' }));
|
||||
let currentInput = initialInput;
|
||||
let totalCost = prevCost;
|
||||
|
||||
try {
|
||||
for (let i = startStep; i < steps.length; i++) {
|
||||
if (pipelineState.canceled) break;
|
||||
|
||||
const step = steps[i];
|
||||
pipelineState.currentStep = i;
|
||||
|
||||
if (step.requiresApproval && i > 0) {
|
||||
const prevAgentName = results.length > 0 ? results[results.length - 1].agentName : '';
|
||||
executionsStore.update(historyRecord.id, { status: 'awaiting_approval' });
|
||||
if (wsCallback) wsCallback({ type: 'pipeline_status', pipelineId: prevExec.pipelineId, status: 'awaiting_approval', stepIndex: i });
|
||||
const approved = await waitForApproval(newExecId, prevExec.pipelineId, i, currentInput, prevAgentName, wsCallback);
|
||||
if (!approved) {
|
||||
pipelineState.canceled = true;
|
||||
executionsStore.update(historyRecord.id, { status: 'rejected', endedAt: new Date().toISOString(), totalCostUsd: totalCost });
|
||||
if (wsCallback) wsCallback({ type: 'pipeline_rejected', pipelineId: prevExec.pipelineId, stepIndex: i });
|
||||
break;
|
||||
}
|
||||
executionsStore.update(historyRecord.id, { status: 'running' });
|
||||
}
|
||||
|
||||
if (pipelineState.canceled) break;
|
||||
|
||||
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 stepConfig = { ...agent.config };
|
||||
const effectiveWorkdir = options.workingDirectory || pl.workingDirectory;
|
||||
if (effectiveWorkdir) stepConfig.workingDirectory = effectiveWorkdir;
|
||||
|
||||
const prompt = applyTemplate(step.inputTemplate, currentInput);
|
||||
const stepStart = new Date().toISOString();
|
||||
|
||||
if (wsCallback) {
|
||||
wsCallback({
|
||||
type: 'pipeline_step_start',
|
||||
pipelineId: prevExec.pipelineId,
|
||||
stepIndex: i,
|
||||
stepId: step.id,
|
||||
agentName: agent.agent_name,
|
||||
totalSteps: steps.length,
|
||||
resumed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const stepResult = await executeStepAsPromise(stepConfig, prompt, pipelineState, wsCallback, prevExec.pipelineId, i);
|
||||
if (pipelineState.canceled) break;
|
||||
|
||||
totalCost += stepResult.costUsd;
|
||||
currentInput = stepResult.text;
|
||||
results.push({ stepId: step.id, agentId: step.agentId, agentName: agent.agent_name, result: stepResult.text, sessionId: stepResult.sessionId });
|
||||
|
||||
const current = executionsStore.getById(historyRecord.id);
|
||||
const savedSteps = current ? (current.steps || []) : [];
|
||||
savedSteps.push({
|
||||
stepIndex: i,
|
||||
agentId: step.agentId,
|
||||
agentName: agent.agent_name,
|
||||
prompt: prompt.slice(0, 5000),
|
||||
result: stepResult.text,
|
||||
startedAt: stepStart,
|
||||
endedAt: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
costUsd: stepResult.costUsd,
|
||||
durationMs: stepResult.durationMs,
|
||||
numTurns: stepResult.numTurns,
|
||||
});
|
||||
executionsStore.update(historyRecord.id, { steps: savedSteps, totalCostUsd: totalCost });
|
||||
|
||||
if (wsCallback) {
|
||||
wsCallback({
|
||||
type: 'pipeline_step_complete',
|
||||
pipelineId: prevExec.pipelineId,
|
||||
stepIndex: i,
|
||||
stepId: step.id,
|
||||
result: stepResult.text.slice(0, 500),
|
||||
costUsd: stepResult.costUsd,
|
||||
});
|
||||
}
|
||||
|
||||
if (i < steps.length - 1 && !pipelineState.canceled) {
|
||||
if (wsCallback) wsCallback({ type: 'pipeline_summarizing', pipelineId: prevExec.pipelineId, stepIndex: i, originalLength: currentInput.length });
|
||||
const summarized = await executor.summarize(currentInput);
|
||||
if (summarized !== currentInput) {
|
||||
if (wsCallback) wsCallback({ type: 'pipeline_summarized', pipelineId: prevExec.pipelineId, stepIndex: i, originalLength: currentInput.length, summarizedLength: summarized.length });
|
||||
currentInput = summarized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activePipelines.delete(newExecId);
|
||||
const finalStatus = pipelineState.canceled ? 'canceled' : 'completed';
|
||||
executionsStore.update(historyRecord.id, { status: finalStatus, endedAt: new Date().toISOString(), totalCostUsd: totalCost });
|
||||
|
||||
if (!pipelineState.canceled) {
|
||||
try {
|
||||
const updated = executionsStore.getById(historyRecord.id);
|
||||
if (updated) {
|
||||
const report = generatePipelineReport(updated);
|
||||
if (wsCallback) wsCallback({ type: 'report_generated', pipelineId: prevExec.pipelineId, reportFile: report.filename });
|
||||
}
|
||||
} catch (e) { console.error('[pipeline] Erro ao gerar relatório:', e.message); }
|
||||
const lastResult = results.length > 0 ? results[results.length - 1] : null;
|
||||
if (wsCallback) wsCallback({
|
||||
type: 'pipeline_complete',
|
||||
pipelineId: prevExec.pipelineId,
|
||||
executionId: newExecId,
|
||||
results,
|
||||
totalCostUsd: totalCost,
|
||||
lastAgentId: lastResult?.agentId || '',
|
||||
lastAgentName: lastResult?.agentName || '',
|
||||
lastSessionId: lastResult?.sessionId || '',
|
||||
});
|
||||
}
|
||||
|
||||
return { executionId: newExecId, results };
|
||||
} catch (err) {
|
||||
activePipelines.delete(newExecId);
|
||||
executionsStore.update(historyRecord.id, {
|
||||
status: 'error',
|
||||
error: err.message,
|
||||
failedAtStep: pipelineState.currentStep,
|
||||
lastStepInput: currentInput.slice(0, 50000),
|
||||
endedAt: new Date().toISOString(),
|
||||
totalCostUsd: totalCost,
|
||||
});
|
||||
if (wsCallback) wsCallback({ type: 'pipeline_error', pipelineId: prevExec.pipelineId, stepIndex: pipelineState.currentStep, error: err.message });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function getActivePipelines() {
|
||||
return Array.from(activePipelines.entries()).map(([id, state]) => ({
|
||||
pipelineId: id,
|
||||
executionId: id,
|
||||
pipelineId: state.pipelineId,
|
||||
currentStep: state.currentStep,
|
||||
currentExecutionId: state.currentExecutionId,
|
||||
pendingApproval: !!state.pendingApproval,
|
||||
@@ -318,6 +561,7 @@ export function createPipeline(data) {
|
||||
return pipelinesStore.create({
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
workingDirectory: data.workingDirectory || '',
|
||||
steps: buildSteps(data.steps),
|
||||
status: data.status || 'active',
|
||||
});
|
||||
@@ -329,6 +573,7 @@ export function updatePipeline(id, data) {
|
||||
const updateData = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.workingDirectory !== undefined) updateData.workingDirectory = data.workingDirectory;
|
||||
if (data.status !== undefined) updateData.status = data.status;
|
||||
if (data.steps !== undefined) updateData.steps = buildSteps(data.steps);
|
||||
return pipelinesStore.update(id, updateData);
|
||||
|
||||
@@ -17,7 +17,11 @@ function addToHistory(entry) {
|
||||
|
||||
function matchesCronPart(part, value) {
|
||||
if (part === '*') return true;
|
||||
if (part.startsWith('*/')) return value % parseInt(part.slice(2)) === 0;
|
||||
if (part.startsWith('*/')) {
|
||||
const divisor = parseInt(part.slice(2));
|
||||
if (!divisor || divisor <= 0) return false;
|
||||
return value % divisor === 0;
|
||||
}
|
||||
if (part.includes(',')) return part.split(',').map(Number).includes(value);
|
||||
if (part.includes('-')) {
|
||||
const [start, end] = part.split('-').map(Number);
|
||||
@@ -66,6 +70,17 @@ export function schedule(taskId, cronExpr, callback, persist = true) {
|
||||
if (schedules.has(taskId)) unschedule(taskId, false);
|
||||
if (!cron.validate(cronExpr)) throw new Error(`Expressão cron inválida: ${cronExpr}`);
|
||||
|
||||
const MIN_INTERVAL_PARTS = cronExpr.split(' ');
|
||||
if (MIN_INTERVAL_PARTS[0] === '*' && MIN_INTERVAL_PARTS[1] === '*') {
|
||||
throw new Error('Intervalo mínimo de agendamento é 5 minutos. Use */5 ou maior.');
|
||||
}
|
||||
if (MIN_INTERVAL_PARTS[0].startsWith('*/')) {
|
||||
const interval = parseInt(MIN_INTERVAL_PARTS[0].slice(2));
|
||||
if (interval < 5 && MIN_INTERVAL_PARTS[1] === '*') {
|
||||
throw new Error(`Intervalo mínimo de agendamento é 5 minutos. Recebido: ${cronExpr}`);
|
||||
}
|
||||
}
|
||||
|
||||
const task = cron.schedule(
|
||||
cronExpr,
|
||||
() => {
|
||||
@@ -145,6 +160,13 @@ export function restoreSchedules(executeFn) {
|
||||
if (restored > 0) console.log(`[scheduler] ${restored} agendamento(s) restaurado(s)`);
|
||||
}
|
||||
|
||||
export function stopAll() {
|
||||
for (const [, entry] of schedules) {
|
||||
entry.task.stop();
|
||||
}
|
||||
schedules.clear();
|
||||
}
|
||||
|
||||
export function on(event, listener) {
|
||||
emitter.on(event, listener);
|
||||
}
|
||||
|
||||
194
src/reports/generator.js
Normal file
194
src/reports/generator.js
Normal file
@@ -0,0 +1,194 @@
|
||||
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPORTS_DIR = join(__dirname, '..', '..', 'data', 'reports');
|
||||
const EXTERNAL_REPORTS_DIR = process.env.AGENT_REPORTS_DIR || join(process.env.HOME || '/home/fred', 'agent_reports');
|
||||
|
||||
function ensureDir() {
|
||||
if (!existsSync(REPORTS_DIR)) mkdirSync(REPORTS_DIR, { recursive: true });
|
||||
if (!existsSync(EXTERNAL_REPORTS_DIR)) mkdirSync(EXTERNAL_REPORTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function sanitizeFilename(name) {
|
||||
return name.replace(/[^a-zA-Z0-9À-ÿ_-]/g, '_').slice(0, 60);
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('pt-BR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(startIso, endIso) {
|
||||
if (!startIso || !endIso) return '—';
|
||||
const ms = new Date(endIso) - new Date(startIso);
|
||||
if (ms < 0) return '—';
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
if (h > 0) return `${h}h ${m % 60}m ${s % 60}s`;
|
||||
return `${m}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
export function generateAgentReport(execution) {
|
||||
ensureDir();
|
||||
|
||||
const name = execution.agentName || 'Agente';
|
||||
const filename = `agente_${sanitizeFilename(name)}_${timestamp()}.md`;
|
||||
const filepath = join(REPORTS_DIR, filename);
|
||||
|
||||
const status = execution.status === 'completed' ? '✅ Concluído' : '❌ Erro';
|
||||
const cost = (execution.costUsd || execution.totalCostUsd || 0).toFixed(4);
|
||||
|
||||
const lines = [
|
||||
`# Relatório de Execução — ${name}`,
|
||||
'',
|
||||
`**Data:** ${formatDate(execution.startedAt)}`,
|
||||
`**Status:** ${status}`,
|
||||
`**Duração:** ${formatDuration(execution.startedAt, execution.endedAt)}`,
|
||||
`**Custo:** $${cost}`,
|
||||
`**Turnos:** ${execution.numTurns || '—'}`,
|
||||
`**Session ID:** \`${execution.sessionId || '—'}\``,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Tarefa',
|
||||
'',
|
||||
execution.task || '_(sem tarefa definida)_',
|
||||
'',
|
||||
];
|
||||
|
||||
if (execution.instructions) {
|
||||
lines.push('## Instruções Adicionais', '', execution.instructions, '');
|
||||
}
|
||||
|
||||
lines.push('---', '', '## Resultado', '');
|
||||
|
||||
if (execution.status === 'error' && execution.error) {
|
||||
lines.push('### Erro', '', '```', execution.error, '```', '');
|
||||
}
|
||||
|
||||
if (execution.result) {
|
||||
lines.push(execution.result);
|
||||
} else {
|
||||
lines.push('_(sem resultado textual)_');
|
||||
}
|
||||
|
||||
lines.push('', '---', '', `_Relatório gerado automaticamente em ${formatDate(new Date().toISOString())}_`);
|
||||
|
||||
const content = lines.join('\n');
|
||||
writeFileSync(filepath, content, 'utf-8');
|
||||
try { writeFileSync(join(EXTERNAL_REPORTS_DIR, filename), content, 'utf-8'); } catch {}
|
||||
return { filename, filepath };
|
||||
}
|
||||
|
||||
export function generatePipelineReport(execution) {
|
||||
ensureDir();
|
||||
|
||||
const name = execution.pipelineName || 'Pipeline';
|
||||
const filename = `pipeline_${sanitizeFilename(name)}_${timestamp()}.md`;
|
||||
const filepath = join(REPORTS_DIR, filename);
|
||||
|
||||
const status = execution.status === 'completed' ? '✅ Concluído'
|
||||
: execution.status === 'error' ? '❌ Erro'
|
||||
: execution.status === 'canceled' ? '⚠️ Cancelado'
|
||||
: execution.status;
|
||||
|
||||
const totalCost = (execution.totalCostUsd || 0).toFixed(4);
|
||||
const steps = Array.isArray(execution.steps) ? execution.steps : [];
|
||||
|
||||
const lines = [
|
||||
`# Relatório de Pipeline — ${name}`,
|
||||
'',
|
||||
`**Data:** ${formatDate(execution.startedAt)}`,
|
||||
`**Status:** ${status}`,
|
||||
`**Duração:** ${formatDuration(execution.startedAt, execution.endedAt)}`,
|
||||
`**Custo Total:** $${totalCost}`,
|
||||
`**Passos:** ${steps.length}`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Input Inicial',
|
||||
'',
|
||||
execution.input || '_(sem input)_',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Execução dos Passos',
|
||||
'',
|
||||
];
|
||||
|
||||
steps.forEach((step, i) => {
|
||||
const stepStatus = step.status === 'completed' ? '✅' : step.status === 'error' ? '❌' : '⏳';
|
||||
const stepCost = (step.costUsd || 0).toFixed(4);
|
||||
const stepDuration = formatDuration(step.startedAt, step.endedAt);
|
||||
|
||||
lines.push(
|
||||
`### Passo ${i + 1} — ${step.agentName || 'Agente'} ${stepStatus}`,
|
||||
'',
|
||||
`| Propriedade | Valor |`,
|
||||
`|-------------|-------|`,
|
||||
`| Status | ${step.status || '—'} |`,
|
||||
`| Duração | ${stepDuration} |`,
|
||||
`| Custo | $${stepCost} |`,
|
||||
`| Turnos | ${step.numTurns || '—'} |`,
|
||||
'',
|
||||
);
|
||||
|
||||
if (step.prompt) {
|
||||
lines.push(
|
||||
'<details>',
|
||||
'<summary>Prompt utilizado</summary>',
|
||||
'',
|
||||
'```',
|
||||
step.prompt,
|
||||
'```',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
if (step.result) {
|
||||
lines.push('**Resultado:**', '', step.result, '');
|
||||
} else if (step.status === 'error') {
|
||||
lines.push('**Erro:** Passo falhou durante a execução.', '');
|
||||
}
|
||||
|
||||
if (i < steps.length - 1) {
|
||||
lines.push('---', '');
|
||||
}
|
||||
});
|
||||
|
||||
if (execution.error) {
|
||||
lines.push('---', '', '## Erro da Pipeline', '', '```', execution.error, '```', '');
|
||||
}
|
||||
|
||||
const lastStep = steps[steps.length - 1];
|
||||
if (execution.status === 'completed' && lastStep?.result) {
|
||||
lines.push(
|
||||
'---',
|
||||
'',
|
||||
'## Resultado Final',
|
||||
'',
|
||||
lastStep.result,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('---', '', `_Relatório gerado automaticamente em ${formatDate(new Date().toISOString())}_`);
|
||||
|
||||
const content = lines.join('\n');
|
||||
writeFileSync(filepath, content, 'utf-8');
|
||||
try { writeFileSync(join(EXTERNAL_REPORTS_DIR, filename), content, 'utf-8'); } catch {}
|
||||
return { filename, filepath };
|
||||
}
|
||||
@@ -1,15 +1,43 @@
|
||||
import { Router } from 'express';
|
||||
import { execSync } from 'child_process';
|
||||
import { execFile, spawn as spawnProcess } from 'child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import crypto from 'crypto';
|
||||
import os from 'os';
|
||||
import multer from 'multer';
|
||||
import * as manager from '../agents/manager.js';
|
||||
import { tasksStore, settingsStore, executionsStore, webhooksStore } from '../store/db.js';
|
||||
import { tasksStore, settingsStore, executionsStore, webhooksStore, notificationsStore, secretsStore, agentVersionsStore } from '../store/db.js';
|
||||
import * as scheduler from '../agents/scheduler.js';
|
||||
import * as pipeline from '../agents/pipeline.js';
|
||||
import { getBinPath, updateMaxConcurrent } from '../agents/executor.js';
|
||||
import * as gitIntegration from '../agents/git-integration.js';
|
||||
import { getBinPath, updateMaxConcurrent, cancelAllExecutions, getActiveExecutions } from '../agents/executor.js';
|
||||
import { invalidateAgentMapCache } from '../agents/pipeline.js';
|
||||
import { cached } from '../cache/index.js';
|
||||
import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, statSync, createReadStream, rmSync } from 'fs';
|
||||
import { join, dirname, resolve as pathResolve, extname, basename, relative } from 'path';
|
||||
import { createGzip } from 'zlib';
|
||||
import { Readable } from 'stream';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __apiDirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPORTS_DIR = join(__apiDirname, '..', '..', 'data', 'reports');
|
||||
const UPLOADS_DIR = join(__apiDirname, '..', '..', 'data', 'uploads');
|
||||
|
||||
if (!existsSync(UPLOADS_DIR)) mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const sessionDir = join(UPLOADS_DIR, req.uploadSessionId || 'tmp');
|
||||
if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true });
|
||||
cb(null, sessionDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const safe = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200);
|
||||
cb(null, `${Date.now()}-${safe}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024, files: 20 },
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
export const hookRouter = Router();
|
||||
@@ -116,12 +144,47 @@ router.delete('/agents/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/agents/:id/execute', (req, res) => {
|
||||
router.post('/uploads', (req, res, next) => {
|
||||
req.uploadSessionId = uuidv4();
|
||||
next();
|
||||
}, upload.array('files', 20), (req, res) => {
|
||||
try {
|
||||
const { task, instructions } = req.body;
|
||||
const files = (req.files || []).map(f => ({
|
||||
originalName: f.originalname,
|
||||
path: f.path,
|
||||
size: f.size,
|
||||
}));
|
||||
res.json({ sessionId: req.uploadSessionId, files });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
function buildContextFilesPrompt(contextFiles) {
|
||||
if (!Array.isArray(contextFiles) || contextFiles.length === 0) return '';
|
||||
const lines = contextFiles.map(f => `- ${f.path} (${f.originalName})`);
|
||||
return `\n\nArquivos de contexto anexados (leia cada um deles antes de iniciar):\n${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
router.post('/agents/:id/execute', async (req, res) => {
|
||||
try {
|
||||
const { task, instructions, contextFiles, workingDirectory, repoName, repoBranch } = req.body;
|
||||
if (!task) return res.status(400).json({ error: 'task é obrigatório' });
|
||||
const clientId = req.headers['x-client-id'] || null;
|
||||
const executionId = manager.executeTask(req.params.id, task, instructions, (msg) => wsCallback(msg, clientId));
|
||||
const filesPrompt = buildContextFilesPrompt(contextFiles);
|
||||
const fullTask = task + filesPrompt;
|
||||
const metadata = {};
|
||||
|
||||
if (repoName) {
|
||||
const syncResult = await gitIntegration.cloneOrPull(repoName, repoBranch || null);
|
||||
metadata.workingDirectoryOverride = syncResult.dir;
|
||||
metadata.repoName = repoName;
|
||||
metadata.repoBranch = repoBranch || null;
|
||||
} else if (workingDirectory) {
|
||||
metadata.workingDirectoryOverride = workingDirectory;
|
||||
}
|
||||
|
||||
const executionId = manager.executeTask(req.params.id, fullTask, instructions, (msg) => wsCallback(msg, clientId), metadata);
|
||||
res.status(202).json({ executionId, status: 'started' });
|
||||
} catch (err) {
|
||||
const status = err.message.includes('não encontrado') ? 404 : 400;
|
||||
@@ -163,6 +226,111 @@ router.get('/agents/:id/export', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/agents/:id/secrets', (req, res) => {
|
||||
try {
|
||||
const agent = manager.getAgentById(req.params.id);
|
||||
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
||||
const all = secretsStore.getAll();
|
||||
const agentSecrets = all
|
||||
.filter((s) => s.agentId === req.params.id)
|
||||
.map((s) => ({ name: s.name, created_at: s.created_at }));
|
||||
res.json(agentSecrets);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/agents/:id/secrets', (req, res) => {
|
||||
try {
|
||||
const agent = manager.getAgentById(req.params.id);
|
||||
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
||||
const { name, value } = req.body;
|
||||
if (!name || !value) return res.status(400).json({ error: 'name e value são obrigatórios' });
|
||||
const all = secretsStore.getAll();
|
||||
const existing = all.find((s) => s.agentId === req.params.id && s.name === name);
|
||||
if (existing) {
|
||||
secretsStore.update(existing.id, { value });
|
||||
return res.json({ name, updated: true });
|
||||
}
|
||||
secretsStore.create({ agentId: req.params.id, name, value });
|
||||
res.status(201).json({ name, created: true });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/agents/:id/secrets/:name', (req, res) => {
|
||||
try {
|
||||
const secretName = decodeURIComponent(req.params.name);
|
||||
const all = secretsStore.getAll();
|
||||
const secret = all.find((s) => s.agentId === req.params.id && s.name === secretName);
|
||||
if (!secret) return res.status(404).json({ error: 'Secret não encontrado' });
|
||||
secretsStore.delete(secret.id);
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/agents/:id/versions', (req, res) => {
|
||||
try {
|
||||
const agent = manager.getAgentById(req.params.id);
|
||||
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
||||
const all = agentVersionsStore.getAll();
|
||||
const versions = all
|
||||
.filter((v) => v.agentId === req.params.id)
|
||||
.sort((a, b) => b.version - a.version);
|
||||
res.json(versions);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/agents/:id/versions/:version/restore', (req, res) => {
|
||||
try {
|
||||
const agent = manager.getAgentById(req.params.id);
|
||||
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
||||
const versionNum = parseInt(req.params.version);
|
||||
const all = agentVersionsStore.getAll();
|
||||
const target = all.find((v) => v.agentId === req.params.id && v.version === versionNum);
|
||||
if (!target) return res.status(404).json({ error: 'Versão não encontrada' });
|
||||
if (!target.snapshot) return res.status(400).json({ error: 'Snapshot da versão não disponível' });
|
||||
const { id, created_at, updated_at, ...snapshotData } = target.snapshot;
|
||||
const restored = manager.updateAgent(req.params.id, snapshotData);
|
||||
if (!restored) return res.status(500).json({ error: 'Falha ao restaurar versão' });
|
||||
invalidateAgentMapCache();
|
||||
agentVersionsStore.create({
|
||||
agentId: req.params.id,
|
||||
version: Math.max(...all.filter((v) => v.agentId === req.params.id).map((v) => v.version), 0) + 1,
|
||||
changes: ['restore'],
|
||||
changelog: `Restaurado para versão ${versionNum}`,
|
||||
snapshot: structuredClone(restored),
|
||||
});
|
||||
res.json(restored);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/agents/:id/duplicate', async (req, res) => {
|
||||
try {
|
||||
const agent = manager.getAgentById(req.params.id);
|
||||
if (!agent) return res.status(404).json({ error: 'Agente não encontrado' });
|
||||
const { id, created_at, updated_at, executions, ...rest } = agent;
|
||||
const duplicate = {
|
||||
...rest,
|
||||
agent_name: `${agent.agent_name} (cópia)`,
|
||||
executions: [],
|
||||
status: 'active',
|
||||
};
|
||||
const created = manager.createAgent(duplicate);
|
||||
invalidateAgentMapCache();
|
||||
res.status(201).json(created);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tasks', (req, res) => {
|
||||
try {
|
||||
res.json(tasksStore.getAll());
|
||||
@@ -304,17 +472,29 @@ router.delete('/pipelines/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/pipelines/:id/execute', (req, res) => {
|
||||
router.post('/pipelines/:id/execute', async (req, res) => {
|
||||
try {
|
||||
const { input, workingDirectory } = req.body;
|
||||
const { input, workingDirectory, contextFiles, repoName, repoBranch } = req.body;
|
||||
if (!input) return res.status(400).json({ error: 'input é obrigatório' });
|
||||
const clientId = req.headers['x-client-id'] || null;
|
||||
const options = {};
|
||||
if (workingDirectory) options.workingDirectory = workingDirectory;
|
||||
pipeline.executePipeline(req.params.id, input, (msg) => wsCallback(msg, clientId), options).catch(() => {});
|
||||
|
||||
if (repoName) {
|
||||
const syncResult = await gitIntegration.cloneOrPull(repoName, repoBranch || null);
|
||||
options.workingDirectory = syncResult.dir;
|
||||
options.repoName = repoName;
|
||||
options.repoBranch = repoBranch || null;
|
||||
} else if (workingDirectory) {
|
||||
options.workingDirectory = workingDirectory;
|
||||
}
|
||||
|
||||
const filesPrompt = buildContextFilesPrompt(contextFiles);
|
||||
const fullInput = input + filesPrompt;
|
||||
const result = pipeline.executePipeline(req.params.id, fullInput, (msg) => wsCallback(msg, clientId), options);
|
||||
result.catch(() => {});
|
||||
res.status(202).json({ pipelineId: req.params.id, status: 'started' });
|
||||
} catch (err) {
|
||||
const status = err.message.includes('não encontrado') ? 404 : 400;
|
||||
const status = err.message.includes('não encontrado') || err.message.includes('desativado') ? 400 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
@@ -349,6 +529,18 @@ router.post('/pipelines/:id/reject', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/pipelines/resume/:executionId', async (req, res) => {
|
||||
try {
|
||||
const clientId = req.headers['x-client-id'] || null;
|
||||
const result = pipeline.resumePipeline(req.params.executionId, (msg) => wsCallback(msg, clientId));
|
||||
result.catch(() => {});
|
||||
res.status(202).json({ status: 'resumed' });
|
||||
} catch (err) {
|
||||
const status = err.message.includes('não encontrad') ? 404 : 400;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/webhooks', (req, res) => {
|
||||
try {
|
||||
res.json(webhooksStore.getAll());
|
||||
@@ -388,11 +580,11 @@ router.put('/webhooks/:id', (req, res) => {
|
||||
try {
|
||||
const existing = webhooksStore.getById(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Webhook não encontrado' });
|
||||
|
||||
const allowed = ['name', 'targetType', 'targetId', 'active'];
|
||||
const updateData = {};
|
||||
if (req.body.name !== undefined) updateData.name = req.body.name;
|
||||
if (req.body.active !== undefined) updateData.active = !!req.body.active;
|
||||
|
||||
for (const key of allowed) {
|
||||
if (req.body[key] !== undefined) updateData[key] = req.body[key];
|
||||
}
|
||||
const updated = webhooksStore.update(req.params.id, updateData);
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
@@ -400,6 +592,29 @@ router.put('/webhooks/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/webhooks/:id/test', async (req, res) => {
|
||||
try {
|
||||
const wh = webhooksStore.getById(req.params.id);
|
||||
if (!wh) return res.status(404).json({ error: 'Webhook não encontrado' });
|
||||
|
||||
if (wh.targetType === 'agent') {
|
||||
const executionId = manager.executeTask(wh.targetId, 'Teste de webhook', '', (msg) => {
|
||||
if (wsbroadcast) wsbroadcast(msg);
|
||||
}, { source: 'webhook-test', webhookId: wh.id });
|
||||
res.status(202).json({ success: true, message: 'Webhook disparado com sucesso', executionId });
|
||||
} else if (wh.targetType === 'pipeline') {
|
||||
pipeline.executePipeline(wh.targetId, 'Teste de webhook', (msg) => {
|
||||
if (wsbroadcast) wsbroadcast(msg);
|
||||
}).catch(() => {});
|
||||
res.status(202).json({ success: true, message: 'Pipeline disparada com sucesso', pipelineId: wh.targetId });
|
||||
} else {
|
||||
return res.status(400).json({ error: `targetType inválido: ${wh.targetType}` });
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/webhooks/:id', (req, res) => {
|
||||
try {
|
||||
const deleted = webhooksStore.delete(req.params.id);
|
||||
@@ -434,12 +649,12 @@ hookRouter.post('/:token', (req, res) => {
|
||||
res.status(202).json({ executionId, status: 'started', webhook: webhook.name });
|
||||
} else if (webhook.targetType === 'pipeline') {
|
||||
const input = payload.input || payload.task || payload.message || 'Webhook trigger';
|
||||
const options = {};
|
||||
if (payload.workingDirectory) options.workingDirectory = payload.workingDirectory;
|
||||
pipeline.executePipeline(webhook.targetId, input, (msg) => {
|
||||
if (wsbroadcast) wsbroadcast(msg);
|
||||
}, options).catch(() => {});
|
||||
}).catch(() => {});
|
||||
res.status(202).json({ pipelineId: webhook.targetId, status: 'started', webhook: webhook.name });
|
||||
} else {
|
||||
return res.status(400).json({ error: `targetType inválido: ${webhook.targetType}` });
|
||||
}
|
||||
} catch (err) {
|
||||
const status = err.message.includes('não encontrado') ? 404 : 500;
|
||||
@@ -553,11 +768,16 @@ router.get('/system/status', (req, res) => {
|
||||
|
||||
let claudeVersionCache = null;
|
||||
|
||||
router.get('/system/info', (req, res) => {
|
||||
router.get('/system/info', async (req, res) => {
|
||||
try {
|
||||
if (claudeVersionCache === null) {
|
||||
try {
|
||||
claudeVersionCache = execSync(`${getBinPath()} --version`, { timeout: 5000 }).toString().trim();
|
||||
claudeVersionCache = await new Promise((resolve, reject) => {
|
||||
execFile(getBinPath(), ['--version'], { timeout: 5000 }, (err, stdout) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stdout.toString().trim());
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
claudeVersionCache = 'N/A';
|
||||
}
|
||||
@@ -641,6 +861,23 @@ router.get('/executions/active', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/executions/cancel-all', (req, res) => {
|
||||
try {
|
||||
const activePipelines = pipeline.getActivePipelines();
|
||||
for (const p of activePipelines) {
|
||||
pipeline.cancelPipeline(p.pipelineId);
|
||||
}
|
||||
cancelAllExecutions();
|
||||
const running = executionsStore.getAll().filter(e => e.status === 'running' || e.status === 'awaiting_approval');
|
||||
for (const e of running) {
|
||||
executionsStore.update(e.id, { status: 'canceled', endedAt: new Date().toISOString() });
|
||||
}
|
||||
res.json({ cancelled: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/executions/recent', (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
@@ -652,4 +889,453 @@ router.get('/executions/recent', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/executions/:id/retry', async (req, res) => {
|
||||
try {
|
||||
const execution = executionsStore.getById(req.params.id);
|
||||
if (!execution) return res.status(404).json({ error: 'Execução não encontrada' });
|
||||
if (!['error', 'canceled'].includes(execution.status)) {
|
||||
return res.status(400).json({ error: 'Apenas execuções com erro ou canceladas podem ser reexecutadas' });
|
||||
}
|
||||
const clientId = req.headers['x-client-id'] || null;
|
||||
if (execution.type === 'pipeline') {
|
||||
pipeline.executePipeline(execution.pipelineId, execution.input, (msg) => wsCallback(msg, clientId)).catch(() => {});
|
||||
return res.json({ success: true, message: 'Pipeline reexecutado' });
|
||||
}
|
||||
manager.executeTask(execution.agentId, execution.task, null, (msg) => wsCallback(msg, clientId));
|
||||
res.json({ success: true, message: 'Execução reiniciada' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/executions/export', async (req, res) => {
|
||||
try {
|
||||
const executions = executionsStore.getAll();
|
||||
const headers = ['ID', 'Tipo', 'Nome', 'Status', 'Início', 'Fim', 'Duração (ms)', 'Custo (USD)', 'Turnos'];
|
||||
const rows = executions.map(e => [
|
||||
e.id,
|
||||
e.type || 'agent',
|
||||
e.agentName || e.pipelineName || '',
|
||||
e.status,
|
||||
e.startedAt || '',
|
||||
e.endedAt || '',
|
||||
e.durationMs || '',
|
||||
e.costUsd || e.totalCostUsd || '',
|
||||
e.numTurns || '',
|
||||
]);
|
||||
const csv = [headers.join(','), ...rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n');
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=executions_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
res.send('\uFEFF' + csv);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stats/charts', async (req, res) => {
|
||||
try {
|
||||
const days = parseInt(req.query.days) || 7;
|
||||
const executions = executionsStore.getAll();
|
||||
const now = new Date();
|
||||
const labels = [];
|
||||
const executionCounts = [];
|
||||
const costData = [];
|
||||
const successCounts = [];
|
||||
const errorCounts = [];
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
labels.push(dateStr);
|
||||
const dayExecs = executions.filter(e => e.startedAt && e.startedAt.startsWith(dateStr));
|
||||
executionCounts.push(dayExecs.length);
|
||||
costData.push(+(dayExecs.reduce((sum, e) => sum + (e.costUsd || e.totalCostUsd || 0), 0)).toFixed(4));
|
||||
successCounts.push(dayExecs.filter(e => e.status === 'completed').length);
|
||||
errorCounts.push(dayExecs.filter(e => e.status === 'error').length);
|
||||
}
|
||||
|
||||
const agentCounts = {};
|
||||
executions.forEach(e => {
|
||||
if (e.agentName) agentCounts[e.agentName] = (agentCounts[e.agentName] || 0) + 1;
|
||||
});
|
||||
const topAgents = Object.entries(agentCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([name, count]) => ({ name, count }));
|
||||
|
||||
const statusDist = {};
|
||||
executions.forEach(e => { statusDist[e.status] = (statusDist[e.status] || 0) + 1; });
|
||||
|
||||
res.json({ labels, executionCounts, costData, successCounts, errorCounts, topAgents, statusDistribution: statusDist });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/notifications', async (req, res) => {
|
||||
try {
|
||||
const notifications = notificationsStore.getAll();
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
res.json({ notifications: notifications.slice(-50).reverse(), unreadCount });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/notifications/:id/read', (req, res) => {
|
||||
try {
|
||||
const updated = notificationsStore.update(req.params.id, { read: true });
|
||||
if (!updated) return res.status(404).json({ error: 'Notificação não encontrada' });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/notifications/read-all', (req, res) => {
|
||||
try {
|
||||
const notifications = notificationsStore.getAll();
|
||||
for (const n of notifications) {
|
||||
if (!n.read) notificationsStore.update(n.id, { read: true });
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/notifications', async (req, res) => {
|
||||
try {
|
||||
notificationsStore.save([]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/reports', (req, res) => {
|
||||
try {
|
||||
if (!existsSync(REPORTS_DIR)) return res.json([]);
|
||||
const files = readdirSync(REPORTS_DIR)
|
||||
.filter(f => f.endsWith('.md'))
|
||||
.sort((a, b) => b.localeCompare(a))
|
||||
.slice(0, 100);
|
||||
res.json(files);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/reports/:filename', (req, res) => {
|
||||
try {
|
||||
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
|
||||
if (!filename.endsWith('.md')) return res.status(400).json({ error: 'Formato inválido' });
|
||||
const filepath = join(REPORTS_DIR, filename);
|
||||
const resolved = pathResolve(filepath);
|
||||
if (!resolved.startsWith(pathResolve(REPORTS_DIR))) {
|
||||
return res.status(400).json({ error: 'Caminho inválido' });
|
||||
}
|
||||
if (!existsSync(filepath)) return res.status(404).json({ error: 'Relatório não encontrado' });
|
||||
const content = readFileSync(filepath, 'utf-8');
|
||||
res.json({ filename, content });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/reports/:filename', (req, res) => {
|
||||
try {
|
||||
const filename = req.params.filename.replace(/[^a-zA-Z0-9À-ÿ_.\-]/g, '');
|
||||
const filepath = join(REPORTS_DIR, filename);
|
||||
const resolved = pathResolve(filepath);
|
||||
if (!resolved.startsWith(pathResolve(REPORTS_DIR))) {
|
||||
return res.status(400).json({ error: 'Caminho inválido' });
|
||||
}
|
||||
if (!existsSync(filepath)) return res.status(404).json({ error: 'Relatório não encontrado' });
|
||||
unlinkSync(filepath);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
const PROJECTS_DIR = '/home/projetos';
|
||||
|
||||
function resolveProjectPath(requestedPath) {
|
||||
const decoded = decodeURIComponent(requestedPath || '');
|
||||
const resolved = pathResolve(PROJECTS_DIR, decoded);
|
||||
if (!resolved.startsWith(PROJECTS_DIR)) return null;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
router.get('/files', (req, res) => {
|
||||
try {
|
||||
const targetPath = resolveProjectPath(req.query.path || '');
|
||||
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
|
||||
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Diretório não encontrado' });
|
||||
|
||||
const stat = statSync(targetPath);
|
||||
if (!stat.isDirectory()) return res.status(400).json({ error: 'Caminho não é um diretório' });
|
||||
|
||||
const entries = readdirSync(targetPath, { withFileTypes: true })
|
||||
.filter(e => !e.name.startsWith('.'))
|
||||
.map(entry => {
|
||||
const fullPath = join(targetPath, entry.name);
|
||||
try {
|
||||
const s = statSync(fullPath);
|
||||
return {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : 'file',
|
||||
size: entry.isDirectory() ? null : s.size,
|
||||
modified: s.mtime.toISOString(),
|
||||
extension: entry.isDirectory() ? null : extname(entry.name).slice(1).toLowerCase(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const relativePath = relative(PROJECTS_DIR, targetPath) || '';
|
||||
|
||||
res.json({
|
||||
path: relativePath,
|
||||
parent: relativePath ? dirname(relativePath) : null,
|
||||
entries,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/files/download', (req, res) => {
|
||||
try {
|
||||
const targetPath = resolveProjectPath(req.query.path || '');
|
||||
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
|
||||
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Arquivo não encontrado' });
|
||||
|
||||
const stat = statSync(targetPath);
|
||||
if (!stat.isFile()) return res.status(400).json({ error: 'Caminho não é um arquivo' });
|
||||
|
||||
const filename = basename(targetPath);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
createReadStream(targetPath).pipe(res);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/files/download-folder', (req, res) => {
|
||||
try {
|
||||
const targetPath = resolveProjectPath(req.query.path || '');
|
||||
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
|
||||
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Pasta não encontrada' });
|
||||
|
||||
const stat = statSync(targetPath);
|
||||
if (!stat.isDirectory()) return res.status(400).json({ error: 'Caminho não é uma pasta' });
|
||||
|
||||
const folderName = basename(targetPath) || 'projetos';
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(folderName)}.tar.gz"`);
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
|
||||
const parentDir = dirname(targetPath);
|
||||
const dirName = basename(targetPath);
|
||||
const tar = spawnProcess('tar', ['-czf', '-', '-C', parentDir, dirName]);
|
||||
tar.stdout.pipe(res);
|
||||
tar.stderr.on('data', () => {});
|
||||
tar.on('error', (err) => {
|
||||
if (!res.headersSent) res.status(500).json({ error: err.message });
|
||||
});
|
||||
|
||||
req.on('close', () => { try { tar.kill(); } catch {} });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/files', (req, res) => {
|
||||
try {
|
||||
const targetPath = resolveProjectPath(req.query.path || '');
|
||||
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
|
||||
if (targetPath === PROJECTS_DIR) return res.status(400).json({ error: 'Não é permitido excluir o diretório raiz' });
|
||||
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Arquivo ou pasta não encontrado' });
|
||||
|
||||
const stat = statSync(targetPath);
|
||||
if (stat.isDirectory()) {
|
||||
rmSync(targetPath, { recursive: true, force: true });
|
||||
} else {
|
||||
unlinkSync(targetPath);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/repos', async (req, res) => {
|
||||
try {
|
||||
const repos = await gitIntegration.listRepos();
|
||||
res.json(repos);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/repos/:name/branches', async (req, res) => {
|
||||
try {
|
||||
const branches = await gitIntegration.listBranches(req.params.name);
|
||||
res.json(branches);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/files/publish', async (req, res) => {
|
||||
const { path: projectPath } = req.body;
|
||||
if (!projectPath) return res.status(400).json({ error: 'path é obrigatório' });
|
||||
|
||||
const targetPath = resolveProjectPath(projectPath);
|
||||
if (!targetPath) return res.status(400).json({ error: 'Caminho inválido' });
|
||||
if (!existsSync(targetPath)) return res.status(404).json({ error: 'Projeto não encontrado' });
|
||||
if (!statSync(targetPath).isDirectory()) return res.status(400).json({ error: 'Caminho não é uma pasta' });
|
||||
|
||||
const projectName = basename(targetPath).toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
const GITEA_URL = process.env.GITEA_URL || 'http://gitea:3000';
|
||||
const GITEA_USER = process.env.GITEA_USER || 'fred';
|
||||
const GITEA_PASS = process.env.GITEA_PASS || '';
|
||||
const DOMAIN = process.env.DOMAIN || 'nitro-cloud.duckdns.org';
|
||||
const VPS_COMPOSE_DIR = process.env.VPS_COMPOSE_DIR || '/vps';
|
||||
|
||||
if (!GITEA_PASS) return res.status(500).json({ error: 'GITEA_PASS não configurado no servidor' });
|
||||
|
||||
const exec = (cmd, opts = {}) => new Promise((resolve, reject) => {
|
||||
const proc = spawnProcess('sh', ['-c', cmd], { cwd: opts.cwd || targetPath, env: { ...process.env, HOME: '/tmp', GIT_TERMINAL_PROMPT: '0' } });
|
||||
let stdout = '', stderr = '';
|
||||
proc.stdout.on('data', d => stdout += d);
|
||||
proc.stderr.on('data', d => stderr += d);
|
||||
proc.on('close', code => code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `exit ${code}`)));
|
||||
});
|
||||
|
||||
const steps = [];
|
||||
|
||||
try {
|
||||
const authUrl = `${GITEA_URL.replace('://', `://${GITEA_USER}:${GITEA_PASS}@`)}`;
|
||||
const repoApiUrl = `${GITEA_URL}/api/v1/repos/${GITEA_USER}/${projectName}`;
|
||||
const createUrl = `${GITEA_URL}/api/v1/user/repos`;
|
||||
const authHeader = 'Basic ' + Buffer.from(`${GITEA_USER}:${GITEA_PASS}`).toString('base64');
|
||||
|
||||
let repoExists = false;
|
||||
try {
|
||||
const check = await fetch(repoApiUrl, { headers: { Authorization: authHeader } });
|
||||
repoExists = check.ok;
|
||||
} catch {}
|
||||
|
||||
if (!repoExists) {
|
||||
const createRes = await fetch(createUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
||||
body: JSON.stringify({ name: projectName, auto_init: false, private: false }),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const err = await createRes.json().catch(() => ({}));
|
||||
throw new Error(`Erro ao criar repositório: ${err.message || createRes.statusText}`);
|
||||
}
|
||||
steps.push('Repositório criado no Gitea');
|
||||
} else {
|
||||
steps.push('Repositório já existe no Gitea');
|
||||
}
|
||||
|
||||
const repoUrl = `${authUrl}/${GITEA_USER}/${projectName}.git`;
|
||||
const gitDir = `${targetPath}/.git`;
|
||||
|
||||
if (!existsSync(gitDir)) {
|
||||
await exec('git init');
|
||||
await exec(`git remote add origin "${repoUrl}"`);
|
||||
steps.push('Git inicializado');
|
||||
} else {
|
||||
try {
|
||||
await exec('git remote get-url origin');
|
||||
await exec(`git remote set-url origin "${repoUrl}"`);
|
||||
} catch {
|
||||
await exec(`git remote add origin "${repoUrl}"`);
|
||||
}
|
||||
steps.push('Remote atualizado');
|
||||
}
|
||||
|
||||
await exec('git add -A');
|
||||
try {
|
||||
await exec('git -c user.name="Agents Orchestrator" -c user.email="agents@nitro-cloud" commit -m "Publicação automática"');
|
||||
steps.push('Commit criado');
|
||||
} catch {
|
||||
steps.push('Sem alterações para commit');
|
||||
}
|
||||
|
||||
await exec('git push -u origin HEAD:main --force');
|
||||
steps.push('Push realizado');
|
||||
|
||||
const caddyFile = `${VPS_COMPOSE_DIR}/caddy/Caddyfile`;
|
||||
if (existsSync(caddyFile)) {
|
||||
const caddyContent = readFileSync(caddyFile, 'utf-8');
|
||||
const marker = `@${projectName} host ${projectName}.${DOMAIN}`;
|
||||
|
||||
if (!caddyContent.includes(marker)) {
|
||||
const block = `\n @${projectName} host ${projectName}.${DOMAIN}\n handle @${projectName} {\n root * /srv/${projectName}\n file_server\n try_files {path} /index.html\n }\n`;
|
||||
const updated = caddyContent.replace(
|
||||
/(\n? {4}handle \{[\s\S]*?respond.*?200[\s\S]*?\})/,
|
||||
block + '$1'
|
||||
);
|
||||
writeFileSync(caddyFile, updated);
|
||||
steps.push('Caddyfile atualizado');
|
||||
} else {
|
||||
steps.push('Caddyfile já configurado');
|
||||
}
|
||||
}
|
||||
|
||||
const composePath = `${VPS_COMPOSE_DIR}/docker-compose.yml`;
|
||||
if (existsSync(composePath)) {
|
||||
const composeContent = readFileSync(composePath, 'utf-8');
|
||||
const volumeLine = `/home/projetos/${basename(targetPath)}:/srv/${projectName}:ro`;
|
||||
if (!composeContent.includes(volumeLine)) {
|
||||
const updated = composeContent.replace(
|
||||
/(- .\/caddy\/config:\/config)/,
|
||||
`$1\n - ${volumeLine}`
|
||||
);
|
||||
writeFileSync(composePath, updated);
|
||||
steps.push('Volume adicionado ao docker-compose');
|
||||
} else {
|
||||
steps.push('Volume já configurado');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await exec(`docker compose -f ${VPS_COMPOSE_DIR}/docker-compose.yml up -d --force-recreate --no-deps caddy`, { cwd: VPS_COMPOSE_DIR });
|
||||
steps.push('Caddy reiniciado');
|
||||
} catch (e) {
|
||||
steps.push(`Caddy: reinício manual necessário (${e.message})`);
|
||||
}
|
||||
|
||||
const siteUrl = `https://${projectName}.${DOMAIN}`;
|
||||
const repoWebUrl = `https://git.${DOMAIN}/${GITEA_USER}/${projectName}`;
|
||||
|
||||
res.json({
|
||||
status: 'Publicado',
|
||||
siteUrl,
|
||||
repoUrl: repoWebUrl,
|
||||
projectName,
|
||||
steps,
|
||||
message: `Acesse ${siteUrl} em alguns segundos`,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message, steps });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'fs';
|
||||
import { writeFile, rename } from 'fs/promises';
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -30,7 +31,16 @@ function readJson(path, fallback) {
|
||||
|
||||
function writeJson(path, data) {
|
||||
ensureDir();
|
||||
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8');
|
||||
const tmpPath = path + '.tmp';
|
||||
writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8');
|
||||
renameSync(tmpPath, path);
|
||||
}
|
||||
|
||||
async function writeJsonAsync(path, data) {
|
||||
ensureDir();
|
||||
const tmpPath = path + '.tmp';
|
||||
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf8');
|
||||
await rename(tmpPath, path);
|
||||
}
|
||||
|
||||
function clone(v) {
|
||||
@@ -41,6 +51,7 @@ function createStore(filePath) {
|
||||
let mem = null;
|
||||
let dirty = false;
|
||||
let timer = null;
|
||||
let maxSize = Infinity;
|
||||
|
||||
function boot() {
|
||||
if (mem !== null) return;
|
||||
@@ -54,7 +65,7 @@ function createStore(filePath) {
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
if (dirty) {
|
||||
writeJson(filePath, mem);
|
||||
writeJsonAsync(filePath, mem).catch((e) => console.error(`[db] Erro ao salvar ${filePath}:`, e.message));
|
||||
dirty = false;
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
@@ -72,6 +83,20 @@ function createStore(filePath) {
|
||||
return item ? clone(item) : null;
|
||||
},
|
||||
|
||||
findById(id) {
|
||||
return store.getById(id);
|
||||
},
|
||||
|
||||
count() {
|
||||
boot();
|
||||
return mem.length;
|
||||
},
|
||||
|
||||
filter(predicate) {
|
||||
boot();
|
||||
return mem.filter(predicate).map((item) => clone(item));
|
||||
},
|
||||
|
||||
create(data) {
|
||||
boot();
|
||||
const item = {
|
||||
@@ -81,6 +106,9 @@ function createStore(filePath) {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
mem.push(item);
|
||||
if (maxSize !== Infinity && mem.length > maxSize) {
|
||||
mem.splice(0, mem.length - maxSize);
|
||||
}
|
||||
touch();
|
||||
return clone(item);
|
||||
},
|
||||
@@ -104,7 +132,8 @@ function createStore(filePath) {
|
||||
},
|
||||
|
||||
save(items) {
|
||||
mem = Array.isArray(items) ? items : mem;
|
||||
if (!Array.isArray(items)) return;
|
||||
mem = items;
|
||||
touch();
|
||||
},
|
||||
|
||||
@@ -118,6 +147,10 @@ function createStore(filePath) {
|
||||
dirty = false;
|
||||
}
|
||||
},
|
||||
|
||||
setMaxSize(n) {
|
||||
maxSize = n;
|
||||
},
|
||||
};
|
||||
|
||||
allStores.push(store);
|
||||
@@ -176,6 +209,21 @@ function createSettingsStore(filePath) {
|
||||
return store;
|
||||
}
|
||||
|
||||
const locks = new Map();
|
||||
|
||||
export async function withLock(key, fn) {
|
||||
while (locks.has(key)) await locks.get(key);
|
||||
let resolve;
|
||||
const promise = new Promise((r) => { resolve = r; });
|
||||
locks.set(key, promise);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
locks.delete(key);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export function flushAllStores() {
|
||||
for (const s of allStores) s.flush();
|
||||
}
|
||||
@@ -185,5 +233,10 @@ export const tasksStore = createStore(`${DATA_DIR}/tasks.json`);
|
||||
export const pipelinesStore = createStore(`${DATA_DIR}/pipelines.json`);
|
||||
export const schedulesStore = createStore(`${DATA_DIR}/schedules.json`);
|
||||
export const executionsStore = createStore(`${DATA_DIR}/executions.json`);
|
||||
executionsStore.setMaxSize(5000);
|
||||
export const webhooksStore = createStore(`${DATA_DIR}/webhooks.json`);
|
||||
export const settingsStore = createSettingsStore(`${DATA_DIR}/settings.json`);
|
||||
export const secretsStore = createStore(`${DATA_DIR}/secrets.json`);
|
||||
export const notificationsStore = createStore(`${DATA_DIR}/notifications.json`);
|
||||
notificationsStore.setMaxSize(200);
|
||||
export const agentVersionsStore = createStore(`${DATA_DIR}/agent_versions.json`);
|
||||
|
||||
Reference in New Issue
Block a user