Compare commits
1 Commits
main
...
7a72a028f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a72a028f7 |
@@ -1,12 +1,10 @@
|
||||
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
|
||||
|
||||
554
README.md
554
README.md
@@ -1,373 +1,339 @@
|
||||
<p align="center">
|
||||
<img src="docs/logo.svg" alt="Agents Orchestrator" width="80" />
|
||||
</p>
|
||||
# Agents Orchestrator
|
||||
|
||||
<h1 align="center">Agents Orchestrator</h1>
|
||||
Painel administrativo web para orquestração de agentes [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Crie, configure e execute múltiplos agentes com diferentes personalidades, modelos e diretórios de trabalho — tudo a partir de uma interface visual profissional.
|
||||
|
||||
<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>
|
||||
## Acesso
|
||||
|
||||
<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 |
|
||||
|
||||
---
|
||||
| Recurso | URL |
|
||||
|---------|-----|
|
||||
| **Aplicação** | https://agents.nitro-cloud.duckdns.org |
|
||||
| **Repositório** | https://git.nitro-cloud.duckdns.org/fred/agents-orchestrator |
|
||||
| **Portal Nitro Cloud** | https://nitro-cloud.duckdns.org |
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
### Agentes
|
||||
### Gerenciamento de Agentes
|
||||
- Crie agentes com nome, system prompt, modelo (Sonnet/Opus/Haiku), diretório de trabalho, ferramentas permitidas, modo de permissão e tags
|
||||
- Ative, desative, edite, **duplique** ou exclua a qualquer momento
|
||||
- Exporte/importe configurações completas em JSON
|
||||
|
||||
- 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
|
||||
### Execução de Tarefas
|
||||
- Execute tarefas sob demanda em qualquer agente ativo
|
||||
- Templates rápidos incluídos (detecção de bugs, revisão OWASP, refatoração, testes, documentação, performance)
|
||||
- **Reexecute** tarefas que falharam ou foram canceladas com um clique
|
||||
- Continuação de conversa (resume session) no terminal
|
||||
|
||||
### Execucao
|
||||
### Terminal em Tempo Real
|
||||
- Streaming chunk-a-chunk via WebSocket com indicador de conexão
|
||||
- **Busca** no output do terminal com navegação entre ocorrências
|
||||
- **Download** da saída completa como `.txt`
|
||||
- **Copiar** saída para a área de transferência
|
||||
- **Toggle de auto-scroll** para controle manual da rolagem
|
||||
- Filtro por execução
|
||||
|
||||
- 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
|
||||
### Dashboard com Gráficos
|
||||
- Métricas em tempo real (agentes, execuções, agendamentos, custo, webhooks)
|
||||
- **Gráfico de execuções** por dia (barras empilhadas sucesso/erro)
|
||||
- **Gráfico de custo** por dia (linha com área preenchida)
|
||||
- **Distribuição de status** (doughnut chart)
|
||||
- **Top 5 agentes** mais executados (barras horizontais)
|
||||
- **Taxa de sucesso** geral (gauge com percentual)
|
||||
- Seletor de período: 7, 14 ou 30 dias
|
||||
|
||||
### Agendamento Cron
|
||||
- Agende tarefas recorrentes com expressões cron
|
||||
- Presets incluídos (horário, diário, semanal, mensal)
|
||||
- Histórico de execuções por agendamento com duração e custo
|
||||
|
||||
- Expressoes cron com presets (horario, diario, semanal, mensal)
|
||||
- Historico de execucoes por agendamento
|
||||
- Retry automatico em caso de limite de slots
|
||||
### Pipelines
|
||||
- Encadeie múltiplos agentes em fluxos sequenciais
|
||||
- Saída de cada passo alimenta o próximo via template `{{input}}`
|
||||
- Portões de aprovação humana entre passos (human-in-the-loop)
|
||||
- Ideal para fluxos como "analisar → corrigir → testar"
|
||||
|
||||
### Webhooks
|
||||
- Dispare execuções de agentes ou pipelines via HTTP externo
|
||||
- **Edite** webhooks existentes (nome, alvo, status)
|
||||
- **Teste** webhooks com um clique para verificar configuração
|
||||
- Snippet cURL pronto para copiar
|
||||
- Assinatura HMAC-SHA256 para validação de origem
|
||||
|
||||
- Disparo de execucoes via HTTP externo
|
||||
- Edicao, teste com 1 clique e snippet cURL
|
||||
- Assinatura HMAC-SHA256
|
||||
### Notificações
|
||||
- **Centro de notificações** no header com badge de contagem
|
||||
- Notificações automáticas para execuções concluídas e com erro
|
||||
- **Notificações nativas do navegador** (Browser Notification API)
|
||||
- Marcar como lidas / limpar todas
|
||||
- Polling automático a cada 15 segundos
|
||||
|
||||
### Notificacoes
|
||||
### Tema Claro/Escuro
|
||||
- Toggle de tema no header com transições suaves
|
||||
- Persistência da preferência em localStorage
|
||||
- Terminal mantém fundo escuro em ambos os temas
|
||||
|
||||
- Centro de notificacoes com badge de contagem
|
||||
- Notificacoes nativas do navegador
|
||||
- Polling automatico a cada 15 segundos
|
||||
### Exportação de Dados
|
||||
- **Exportar histórico** de execuções como CSV (UTF-8 com BOM)
|
||||
- Exportar configuração de agentes em JSON
|
||||
|
||||
### Tema e UX
|
||||
### Atalhos de Teclado
|
||||
| Tecla | Ação |
|
||||
|-------|------|
|
||||
| `1`–`9` | Navegar entre seções |
|
||||
| `N` | Novo agente |
|
||||
| `Esc` | Fechar modal |
|
||||
|
||||
- Tema claro/escuro com transicao suave
|
||||
- Atalhos de teclado (`1`-`9` navegacao, `N` novo agente, `Esc` fechar modal)
|
||||
- Exportacao de historico como CSV
|
||||
## Deploy
|
||||
|
||||
---
|
||||
A aplicação roda em container Docker na infraestrutura Nitro Cloud com HTTPS automático via Caddy + Let's Encrypt.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Requisitos
|
||||
|
||||
- Node.js >= 22
|
||||
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) instalado e autenticado
|
||||
|
||||
### Execucao local
|
||||
### Atualizar o sistema em produção
|
||||
|
||||
```bash
|
||||
git clone https://github.com/fredac100/agents-orchestrator.git
|
||||
cd agents-orchestrator
|
||||
npm install
|
||||
npm start
|
||||
# 1. Push das alterações para o Gitea
|
||||
git push nitro main
|
||||
|
||||
# 2. Conectar no servidor
|
||||
ssh -p 2222 fred@192.168.1.151
|
||||
|
||||
# 3. Atualizar código e rebuild do container
|
||||
cd ~/vps/apps/agents-orchestrator && git pull
|
||||
cd ~/vps && docker compose up -d --build agents-orchestrator
|
||||
```
|
||||
|
||||
Acesse `http://localhost:3000`.
|
||||
|
||||
### Com Docker
|
||||
### Verificar status
|
||||
|
||||
```bash
|
||||
docker build -t agents-orchestrator .
|
||||
docker run -p 3000:3000 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
-v ~/.claude:/home/node/.claude \
|
||||
agents-orchestrator
|
||||
ssh -p 2222 fred@192.168.1.151 "docker logs agents-orchestrator --tail 20"
|
||||
```
|
||||
|
||||
---
|
||||
### Reiniciar
|
||||
|
||||
## Arquitetura
|
||||
|
||||
```
|
||||
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)
|
||||
```bash
|
||||
ssh -p 2222 fred@192.168.1.151 "cd ~/vps && docker compose restart agents-orchestrator"
|
||||
```
|
||||
|
||||
### Estrutura do Projeto
|
||||
## Variáveis de Ambiente
|
||||
|
||||
| Variável | Descrição | Padrão |
|
||||
|----------|-----------|--------|
|
||||
| `PORT` | Porta do servidor | `3000` |
|
||||
| `HOST` | Endereço de bind | `0.0.0.0` |
|
||||
| `AUTH_TOKEN` | Token Bearer para autenticação da API | _(desabilitado)_ |
|
||||
| `ALLOWED_ORIGIN` | Origin permitida para CORS | `https://agents.nitro-cloud.duckdns.org` |
|
||||
| `WEBHOOK_SECRET` | Segredo HMAC para assinatura de webhooks | _(desabilitado)_ |
|
||||
| `CLAUDE_BIN` | Caminho para o binário do Claude CLI | _(auto-detectado)_ |
|
||||
| `REDIS_URL` | URL do Redis para cache L2 (opcional) | _(somente memória)_ |
|
||||
|
||||
## Como Funciona
|
||||
|
||||
### Criando um agente
|
||||
|
||||
1. Acesse https://agents.nitro-cloud.duckdns.org
|
||||
2. Clique em **Novo Agente** no header ou na seção Agentes
|
||||
3. Configure nome, system prompt, modelo e diretório de trabalho
|
||||
4. 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. Marque passos que requerem aprovação humana antes de prosseguir
|
||||
5. 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
|
||||
|
||||
## Infraestrutura
|
||||
|
||||
```
|
||||
server.js HTTP + WebSocket + rate limiting + auth
|
||||
Cliente (navegador)
|
||||
│
|
||||
▼ HTTPS (porta 443)
|
||||
Caddy (reverse proxy + SSL automático)
|
||||
│
|
||||
▼ agents.nitro-cloud.duckdns.org
|
||||
Container Docker (agents-orchestrator)
|
||||
│
|
||||
├── Express (HTTP API + arquivos estáticos)
|
||||
└── WebSocket (streaming em tempo real)
|
||||
```
|
||||
|
||||
### Arquitetura Interna
|
||||
|
||||
```
|
||||
server.js Express + WebSocket + rate limiting + auth
|
||||
src/
|
||||
routes/api.js API REST — 40+ endpoints
|
||||
routes/api.js API REST (/api/*) — 30+ 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
|
||||
manager.js CRUD + orquestração + notificações
|
||||
executor.js Spawna o CLI claude como child_process
|
||||
scheduler.js Agendamento cron (in-memory + persistido)
|
||||
pipeline.js Execução sequencial com aprovação humana
|
||||
store/db.js Persistência em JSON com escrita atômica
|
||||
cache/index.js Cache em 2 níveis (memória + Redis opcional)
|
||||
public/
|
||||
app.html SPA com hash routing
|
||||
css/styles.css Design system (dark/light)
|
||||
index.html SPA single-page com hash routing
|
||||
css/styles.css Design system (dark/light themes)
|
||||
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)
|
||||
app.js Controlador principal + WebSocket + tema + routing
|
||||
api.js Client HTTP para a API
|
||||
components/ UI por seção (15 módulos)
|
||||
data/
|
||||
agents.json Agentes cadastrados
|
||||
tasks.json Templates de tarefas
|
||||
pipelines.json Pipelines configurados
|
||||
schedules.json Agendamentos persistidos
|
||||
executions.json Histórico de execuções (max 5000)
|
||||
webhooks.json Configuração de webhooks
|
||||
notifications.json Notificações do sistema
|
||||
settings.json Configurações globais
|
||||
```
|
||||
|
||||
---
|
||||
O executor invoca o binário `claude` com `--output-format stream-json`, parseia o stdout linha a linha e transmite os chunks via WebSocket para o frontend em tempo real.
|
||||
|
||||
## API
|
||||
## API REST
|
||||
|
||||
### Agentes
|
||||
|
||||
| Metodo | Endpoint | Descricao |
|
||||
| Método | Endpoint | Descrição |
|
||||
|--------|----------|-----------|
|
||||
| `GET` | `/api/agents` | Listar agentes |
|
||||
| `POST` | `/api/agents` | Criar agente |
|
||||
| `GET` | `/api/agents/:id` | Obter agente |
|
||||
| `PUT` | `/api/agents/:id` | Atualizar agente |
|
||||
| `DELETE` | `/api/agents/:id` | Excluir agente |
|
||||
| `POST` | `/api/agents/:id/execute` | Executar tarefa (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/execute` | Executar tarefa no agente |
|
||||
| `POST` | `/api/agents/:id/continue` | Continuar conversa (resume) |
|
||||
| `POST` | `/api/agents/:id/cancel/:execId` | Cancelar execução |
|
||||
| `GET` | `/api/agents/:id/export` | Exportar agente (JSON) |
|
||||
| `POST` | `/api/agents/:id/duplicate` | Duplicar agente |
|
||||
|
||||
### Tarefas
|
||||
|
||||
| Método | Endpoint | Descrição |
|
||||
|--------|----------|-----------|
|
||||
| `GET` | `/api/tasks` | Listar tarefas |
|
||||
| `POST` | `/api/tasks` | Criar tarefa |
|
||||
| `PUT` | `/api/tasks/:id` | Atualizar tarefa |
|
||||
| `DELETE` | `/api/tasks/:id` | Excluir tarefa |
|
||||
|
||||
### Agendamentos
|
||||
|
||||
| Método | Endpoint | Descrição |
|
||||
|--------|----------|-----------|
|
||||
| `GET` | `/api/schedules` | Listar agendamentos |
|
||||
| `POST` | `/api/schedules` | Criar agendamento |
|
||||
| `PUT` | `/api/schedules/:taskId` | Atualizar agendamento |
|
||||
| `DELETE` | `/api/schedules/:taskId` | Remover agendamento |
|
||||
| `GET` | `/api/schedules/history` | Histórico de execuções agendadas |
|
||||
|
||||
### Pipelines
|
||||
|
||||
| Metodo | Endpoint | Descricao |
|
||||
| Método | Endpoint | Descrição |
|
||||
|--------|----------|-----------|
|
||||
| `GET` | `/api/pipelines` | Listar pipelines |
|
||||
| `POST` | `/api/pipelines` | Criar pipeline |
|
||||
| `POST` | `/api/pipelines/:id/execute` | Executar (aceita `repoName` e `repoBranch`) |
|
||||
| `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 |
|
||||
| `POST` | `/api/pipelines/:id/approve` | Aprovar passo pendente |
|
||||
| `POST` | `/api/pipelines/:id/reject` | Rejeitar passo |
|
||||
| `POST` | `/api/pipelines/resume/:execId` | Retomar pipeline falho |
|
||||
| `POST` | `/api/pipelines/:id/reject` | Rejeitar passo pendente |
|
||||
|
||||
### Repositorios
|
||||
### Webhooks
|
||||
|
||||
| Metodo | Endpoint | Descricao |
|
||||
| Método | Endpoint | Descrição |
|
||||
|--------|----------|-----------|
|
||||
| `GET` | `/api/repos` | Listar repositorios do Gitea |
|
||||
| `GET` | `/api/repos/:name/branches` | Listar branches de um repo |
|
||||
| `GET` | `/api/webhooks` | Listar webhooks |
|
||||
| `POST` | `/api/webhooks` | Criar webhook |
|
||||
| `PUT` | `/api/webhooks/:id` | Atualizar webhook |
|
||||
| `DELETE` | `/api/webhooks/:id` | Excluir webhook |
|
||||
| `POST` | `/api/webhooks/:id/test` | Testar webhook |
|
||||
|
||||
### Arquivos e Publicacao
|
||||
### Execuções e Histórico
|
||||
|
||||
| Metodo | Endpoint | Descricao |
|
||||
| Método | Endpoint | Descrição |
|
||||
|--------|----------|-----------|
|
||||
| `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) |
|
||||
| `GET` | `/api/executions/active` | Execuções em andamento |
|
||||
| `GET` | `/api/executions/history` | Histórico paginado com filtros |
|
||||
| `GET` | `/api/executions/recent` | Execuções recentes |
|
||||
| `GET` | `/api/executions/export` | Exportar histórico como CSV |
|
||||
| `GET` | `/api/executions/:id` | Detalhes de uma execução |
|
||||
| `DELETE` | `/api/executions/:id` | Excluir execução do histórico |
|
||||
| `POST` | `/api/executions/:id/retry` | Reexecutar execução falha |
|
||||
| `DELETE` | `/api/executions` | Limpar histórico |
|
||||
|
||||
### Notificações
|
||||
|
||||
| Método | Endpoint | Descrição |
|
||||
|--------|----------|-----------|
|
||||
| `GET` | `/api/notifications` | Listar notificações |
|
||||
| `POST` | `/api/notifications/:id/read` | Marcar como lida |
|
||||
| `POST` | `/api/notifications/read-all` | Marcar todas como lidas |
|
||||
| `DELETE` | `/api/notifications` | Limpar notificações |
|
||||
|
||||
### Sistema
|
||||
|
||||
| Metodo | Endpoint | Descricao |
|
||||
| Método | Endpoint | Descrição |
|
||||
|--------|----------|-----------|
|
||||
| `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
|
||||
|
||||
---
|
||||
| `GET` | `/api/health` | Health check (sem auth) |
|
||||
| `GET` | `/api/system/status` | Status geral do sistema |
|
||||
| `GET` | `/api/system/info` | Informações do servidor |
|
||||
| `GET` | `/api/stats/costs` | Estatísticas de custo |
|
||||
| `GET` | `/api/stats/charts` | Dados para gráficos do dashboard |
|
||||
| `GET/PUT` | `/api/settings` | Configurações globais |
|
||||
|
||||
## Eventos WebSocket
|
||||
|
||||
| Evento | Descricao |
|
||||
|--------|-----------|
|
||||
| `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 no pipeline |
|
||||
| `pipeline_approval_required` | Aguardando aprovacao humana |
|
||||
| `report_generated` | Relatorio gerado |
|
||||
O servidor envia eventos tipados via WebSocket que o frontend renderiza no terminal:
|
||||
|
||||
---
|
||||
| Evento | Descrição |
|
||||
|--------|-----------|
|
||||
| `execution_output` | Chunk de texto da saída do agente |
|
||||
| `execution_complete` | Execução finalizada com resultado |
|
||||
| `execution_error` | Erro durante execução |
|
||||
| `pipeline_step_start` | Início de um passo do pipeline |
|
||||
| `pipeline_step_complete` | Passo do pipeline concluído |
|
||||
| `pipeline_complete` | Pipeline finalizado |
|
||||
| `pipeline_error` | Erro em um passo do pipeline |
|
||||
| `pipeline_approval_required` | Passo aguardando aprovação humana |
|
||||
|
||||
## Segurança
|
||||
|
||||
- **HTTPS** via Caddy com certificado Let's Encrypt automático
|
||||
- **Autenticação** via Bearer token (variável `AUTH_TOKEN`)
|
||||
- **Rate limiting** — 100 requisições por minuto por IP
|
||||
- **CORS** restrito à origin configurada
|
||||
- **Timing-safe comparison** para tokens de autenticação e webhooks
|
||||
- **Correlation IDs** em todas as requisições para rastreabilidade
|
||||
- **Escrita atômica** em disco (temp + rename) para integridade de dados
|
||||
- **Sanitização** de prompts (NUL, caracteres de controle, limite de 50.000 chars)
|
||||
- **Assinatura HMAC-SHA256** para webhooks recebidos
|
||||
|
||||
## Stack
|
||||
|
||||
| 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) |
|
||||
- **Backend**: Node.js, Express, WebSocket (ws), node-cron, uuid, express-rate-limit
|
||||
- **Frontend**: HTML, CSS, JavaScript vanilla (sem framework, sem bundler)
|
||||
- **Gráficos**: Chart.js 4.x
|
||||
- **Ícones**: Lucide
|
||||
- **Fontes**: Inter (UI), JetBrains Mono (código/terminal)
|
||||
- **Persistência**: Arquivos JSON em disco com escrita atômica
|
||||
- **Cache**: In-memory com suporte opcional a Redis (ioredis)
|
||||
- **Infraestrutura**: Docker + Caddy + DuckDNS + Let's Encrypt
|
||||
|
||||
---
|
||||
|
||||
## Licenca
|
||||
## Licença
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>Desenvolvido por <a href="https://nitro-cloud.duckdns.org">Nitro Cloud</a></sub>
|
||||
</p>
|
||||
|
||||
@@ -72,12 +72,6 @@
|
||||
<span>Histórico</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#" class="sidebar-nav-link" data-section="files">
|
||||
<i data-lucide="folder-open"></i>
|
||||
<span>Projetos</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="sidebar-nav-item">
|
||||
<a href="#" class="sidebar-nav-link" data-section="settings">
|
||||
<i data-lucide="settings"></i>
|
||||
@@ -584,10 +578,6 @@
|
||||
<div id="history-pagination"></div>
|
||||
</section>
|
||||
|
||||
<section id="files" class="section" aria-label="Projetos" hidden>
|
||||
<div id="files-container"></div>
|
||||
</section>
|
||||
|
||||
<section id="settings" class="section" aria-label="Configurações" hidden>
|
||||
<div class="settings-grid">
|
||||
<div class="card">
|
||||
@@ -808,7 +798,7 @@
|
||||
class="input"
|
||||
id="agent-workdir"
|
||||
name="workdir"
|
||||
value="/home/projetos/"
|
||||
placeholder="/home/fred/projetos"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
@@ -852,16 +842,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="agent-delegate-to">Delegar para (auto)</label>
|
||||
<select class="select" id="agent-delegate-to" name="delegateTo">
|
||||
<option value="">Nenhum</option>
|
||||
</select>
|
||||
<p class="form-hint">Ao concluir, delega automaticamente o resultado para este agente.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Retry em caso de falha</label>
|
||||
@@ -998,30 +978,6 @@
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="execute-repo">Repositório Git</label>
|
||||
<div class="repo-selector">
|
||||
<select class="select" id="execute-repo">
|
||||
<option value="">Nenhum (usar diretório manual)</option>
|
||||
</select>
|
||||
<select class="select" id="execute-repo-branch" style="display:none">
|
||||
<option value="">Branch padrão</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="form-hint">Se selecionado, o agente trabalha no repositório e faz commit/push automático ao finalizar.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="execute-workdir-group">
|
||||
<label class="form-label" for="execute-workdir">Diretório de Trabalho</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
id="execute-workdir"
|
||||
value="/home/projetos/"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Arquivos de Contexto</label>
|
||||
<div class="dropzone" id="execute-dropzone">
|
||||
@@ -1245,19 +1201,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="pipeline-execute-repo">Repositório Git</label>
|
||||
<div class="repo-selector">
|
||||
<select class="select" id="pipeline-execute-repo">
|
||||
<option value="">Nenhum (usar diretório manual)</option>
|
||||
</select>
|
||||
<select class="select" id="pipeline-execute-repo-branch" style="display:none">
|
||||
<option value="">Branch padrão</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="form-hint">Se selecionado, todos os agentes trabalham no repositório e o commit/push é automático ao final.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="pipeline-execute-workdir-group">
|
||||
<label class="form-label" for="pipeline-execute-workdir">Diretório de Trabalho (opcional)</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -1434,7 +1377,6 @@
|
||||
<script src="js/components/history.js"></script>
|
||||
<script src="js/components/webhooks.js"></script>
|
||||
<script src="js/components/notifications.js"></script>
|
||||
<script src="js/components/files.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
<script>
|
||||
Utils.refreshIcons();
|
||||
|
||||
@@ -966,22 +966,15 @@ textarea {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
.modal-sm,
|
||||
.modal--sm {
|
||||
.modal-sm {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal--md {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.modal-lg,
|
||||
.modal--lg {
|
||||
.modal-lg {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.modal-xl,
|
||||
.modal--xl {
|
||||
.modal-xl {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
@@ -1095,32 +1088,29 @@ textarea {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.terminal-line {
|
||||
padding: 2px 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 12px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.terminal-line .timestamp {
|
||||
color: var(--text-muted);
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
padding-top: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-line .content {
|
||||
color: #c8c8d8;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.terminal-line.error .content {
|
||||
@@ -3374,6 +3364,9 @@ tbody tr:hover td {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.task-card-description {
|
||||
@@ -3381,13 +3374,13 @@ tbody tr:hover td {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
max-height: 3em;
|
||||
}
|
||||
|
||||
.task-card-footer {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -5176,185 +5169,3 @@ body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metr
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
/* File Explorer */
|
||||
#files-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.files-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.files-breadcrumb-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.files-breadcrumb-link:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.files-breadcrumb-link:last-child {
|
||||
color: var(--text-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.files-breadcrumb-sep {
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.files-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.files-count {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.files-table-wrapper {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.files-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.files-table thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.files-table th {
|
||||
text-align: left;
|
||||
padding: 10px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.files-table td {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.files-row:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.files-row:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.files-td-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.files-th-size,
|
||||
.files-td-size {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.files-th-date,
|
||||
.files-td-date {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.files-th-actions,
|
||||
.files-td-actions {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.files-td-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.repo-selector { display: flex; gap: 8px; }
|
||||
.repo-selector .select { flex: 1; }
|
||||
|
||||
.btn-publish { color: var(--success); }
|
||||
.btn-publish:hover { background: rgba(16, 185, 129, 0.1); }
|
||||
|
||||
.publish-result { display: flex; flex-direction: column; gap: 12px; padding: 8px 0; }
|
||||
.publish-result-item { font-size: 14px; }
|
||||
.publish-result-item a { color: var(--accent); text-decoration: underline; }
|
||||
|
||||
.files-entry-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.files-entry-dir {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.files-entry-dir:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.files-entry-file {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.files-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 80px 20px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.files-th-date,
|
||||
.files-td-date {
|
||||
display: none;
|
||||
}
|
||||
.files-th-size,
|
||||
.files-td-size {
|
||||
width: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,10 +38,8 @@ 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, contextFiles, workingDirectory, repoName, repoBranch) {
|
||||
execute(id, task, instructions, contextFiles) {
|
||||
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);
|
||||
},
|
||||
@@ -84,10 +82,9 @@ 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, contextFiles, repoName, repoBranch) {
|
||||
execute(id, input, workingDirectory, contextFiles) {
|
||||
const body = { input };
|
||||
if (repoName) { body.repoName = repoName; if (repoBranch) body.repoBranch = repoBranch; }
|
||||
else if (workingDirectory) body.workingDirectory = workingDirectory;
|
||||
if (workingDirectory) body.workingDirectory = workingDirectory;
|
||||
if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
|
||||
return API.request('POST', `/pipelines/${id}/execute`, body);
|
||||
},
|
||||
@@ -144,17 +141,6 @@ const API = {
|
||||
},
|
||||
},
|
||||
|
||||
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)}`); },
|
||||
|
||||
@@ -17,11 +17,10 @@ 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'],
|
||||
sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'settings'],
|
||||
|
||||
init() {
|
||||
if (App._initialized) return;
|
||||
@@ -37,7 +36,6 @@ const App = {
|
||||
|
||||
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');
|
||||
@@ -115,7 +113,6 @@ 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) {
|
||||
@@ -766,20 +763,6 @@ const App = {
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('pipeline-steps-container')?.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-step-action]');
|
||||
if (!btn) return;
|
||||
@@ -861,61 +844,6 @@ 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;
|
||||
@@ -932,9 +860,6 @@ 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');
|
||||
@@ -951,7 +876,7 @@ const App = {
|
||||
Terminal.disableChat();
|
||||
App._lastAgentName = agentName;
|
||||
|
||||
await API.agents.execute(agentId, task, instructions, contextFiles, workingDirectory, repoName, repoBranch);
|
||||
await API.agents.execute(agentId, task, instructions, contextFiles);
|
||||
|
||||
if (dropzone) dropzone.reset();
|
||||
Modal.close('execute-modal-overlay');
|
||||
|
||||
@@ -180,9 +180,6 @@ 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 = '';
|
||||
|
||||
@@ -195,8 +192,6 @@ const AgentsUI = {
|
||||
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;
|
||||
|
||||
@@ -255,8 +250,6 @@ const AgentsUI = {
|
||||
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;
|
||||
|
||||
@@ -303,7 +296,6 @@ const AgentsUI = {
|
||||
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 || '',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -366,19 +358,8 @@ const AgentsUI = {
|
||||
|
||||
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}`);
|
||||
@@ -487,14 +468,6 @@ const AgentsUI = {
|
||||
});
|
||||
},
|
||||
|
||||
_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');
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
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;
|
||||
@@ -476,11 +476,6 @@ const PipelinesUI = {
|
||||
|
||||
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');
|
||||
},
|
||||
|
||||
@@ -508,9 +503,7 @@ const PipelinesUI = {
|
||||
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);
|
||||
await API.pipelines.execute(pipelineId, input, workingDirectory, contextFiles);
|
||||
if (dropzone) dropzone.reset();
|
||||
Modal.close('pipeline-execute-modal-overlay');
|
||||
App.navigateTo('terminal');
|
||||
|
||||
@@ -68,7 +68,7 @@ const TasksUI = {
|
||||
<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" title="${Utils.escapeHtml(task.description)}">${Utils.escapeHtml(task.description.length > 240 ? task.description.slice(0, 240) + '…' : task.description)}</p>` : ''}
|
||||
${task.description ? `<p class="task-card-description" title="${Utils.escapeHtml(task.description)}">${Utils.escapeHtml(task.description.length > 120 ? task.description.slice(0, 120) + '…' : task.description)}</p>` : ''}
|
||||
<div class="task-card-footer">
|
||||
<span class="task-card-date">
|
||||
<i data-lucide="calendar"></i>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/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!"
|
||||
30
server.js
30
server.js
@@ -12,7 +12,6 @@ import apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/r
|
||||
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));
|
||||
@@ -115,9 +114,13 @@ 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');
|
||||
if (filePath.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
} else {
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
}
|
||||
},
|
||||
}));
|
||||
app.use('/api', apiRouter);
|
||||
@@ -174,9 +177,6 @@ setGlobalBroadcast(broadcast);
|
||||
function gracefulShutdown(signal) {
|
||||
console.log(`\nSinal ${signal} recebido. Encerrando servidor...`);
|
||||
|
||||
stopAllSchedules();
|
||||
console.log('Agendamentos parados.');
|
||||
|
||||
cancelAllExecutions();
|
||||
console.log('Execuções ativas canceladas.');
|
||||
|
||||
@@ -185,23 +185,15 @@ function gracefulShutdown(signal) {
|
||||
|
||||
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);
|
||||
});
|
||||
httpServer.close(() => {
|
||||
console.log('Servidor HTTP encerrado.');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
console.error('Forçando encerramento após timeout.');
|
||||
process.exit(1);
|
||||
}, 10000).unref();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { agentsStore, schedulesStore, executionsStore, notificationsStore, secre
|
||||
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',
|
||||
@@ -173,26 +172,9 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
|
||||
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(
|
||||
effectiveConfig,
|
||||
{ description: task, instructions: effectiveInstructions },
|
||||
agent.config,
|
||||
{ description: task, instructions },
|
||||
{
|
||||
onData: (parsed, execId) => {
|
||||
if (cb) cb({ type: 'execution_output', executionId: execId, agentId, data: parsed });
|
||||
@@ -251,50 +233,7 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
||||
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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -294,19 +293,6 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
||||
});
|
||||
|
||||
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) {
|
||||
|
||||
@@ -160,13 +160,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import { execFile, spawn as spawnProcess } from 'child_process';
|
||||
import { execFile } from 'child_process';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import crypto from 'crypto';
|
||||
import os from 'os';
|
||||
@@ -8,14 +8,11 @@ import * as manager from '../agents/manager.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 * 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 { readdirSync, readFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join, dirname, resolve as pathResolve, extname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __apiDirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -166,25 +163,14 @@ function buildContextFilesPrompt(contextFiles) {
|
||||
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) => {
|
||||
router.post('/agents/:id/execute', (req, res) => {
|
||||
try {
|
||||
const { task, instructions, contextFiles, workingDirectory, repoName, repoBranch } = req.body;
|
||||
const { task, instructions, contextFiles } = req.body;
|
||||
if (!task) return res.status(400).json({ error: 'task é obrigatório' });
|
||||
const clientId = req.headers['x-client-id'] || null;
|
||||
const 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);
|
||||
const executionId = manager.executeTask(req.params.id, fullTask, instructions, (msg) => wsCallback(msg, clientId));
|
||||
res.status(202).json({ executionId, status: 'started' });
|
||||
} catch (err) {
|
||||
const status = err.message.includes('não encontrado') ? 404 : 400;
|
||||
@@ -474,20 +460,11 @@ router.delete('/pipelines/:id', (req, res) => {
|
||||
|
||||
router.post('/pipelines/:id/execute', async (req, res) => {
|
||||
try {
|
||||
const { input, workingDirectory, contextFiles, repoName, repoBranch } = req.body;
|
||||
const { input, workingDirectory, contextFiles } = req.body;
|
||||
if (!input) return res.status(400).json({ error: 'input é obrigatório' });
|
||||
const clientId = req.headers['x-client-id'] || null;
|
||||
const options = {};
|
||||
|
||||
if (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;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -1060,282 +1037,4 @@ router.delete('/reports/:filename', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user