Compare commits
18 Commits
7a72a028f7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1606efa09f | ||
|
|
633b19f80d | ||
|
|
2fae816162 | ||
|
|
2201ac8699 | ||
|
|
a6bbe33e4b | ||
|
|
4c197eef91 | ||
|
|
e9f65c2845 | ||
|
|
2fccaaac40 | ||
|
|
3178366e0e | ||
|
|
fa47538a8f | ||
|
|
7a4ab2279d | ||
|
|
7cbfcb2d0d | ||
|
|
46a6ebc9dd | ||
|
|
f6bf7ce0ed | ||
|
|
96733b55cd | ||
|
|
3ed285c9d1 | ||
|
|
6a21a4d711 | ||
|
|
bbfb9864bd |
@@ -1,10 +1,12 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
RUN apk add --no-cache git docker-cli
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
RUN npm install -g @anthropic-ai/claude-code
|
RUN npm install -g @anthropic-ai/claude-code
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN mkdir -p data && chown -R node:node /app
|
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
|
USER node
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
548
README.md
548
README.md
@@ -1,339 +1,373 @@
|
|||||||
# Agents Orchestrator
|
<p align="center">
|
||||||
|
<img src="docs/logo.svg" alt="Agents Orchestrator" width="80" />
|
||||||
|
</p>
|
||||||
|
|
||||||
Painel administrativo web para orquestração de agentes [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Crie, configure e execute múltiplos agentes com diferentes personalidades, modelos e diretórios de trabalho — tudo a partir de uma interface visual profissional.
|
<h1 align="center">Agents Orchestrator</h1>
|
||||||
|
|
||||||

|
<p align="center">
|
||||||
|
<strong>Plataforma de orquestração de agentes IA com interface visual, pipelines automatizados e integração Git nativa.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
## Acesso
|
<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>
|
||||||
|
|
||||||
| Recurso | URL |
|
<p align="center">
|
||||||
|---------|-----|
|
<a href="#visao-geral">Visao Geral</a> •
|
||||||
| **Aplicação** | https://agents.nitro-cloud.duckdns.org |
|
<a href="#funcionalidades">Funcionalidades</a> •
|
||||||
| **Repositório** | https://git.nitro-cloud.duckdns.org/fred/agents-orchestrator |
|
<a href="#quick-start">Quick Start</a> •
|
||||||
| **Portal Nitro Cloud** | https://nitro-cloud.duckdns.org |
|
<a href="#arquitetura">Arquitetura</a> •
|
||||||
|
<a href="#api">API</a> •
|
||||||
|
<a href="#deploy">Deploy</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visao Geral
|
||||||
|
|
||||||
|
Agents Orchestrator e uma plataforma web para criar, configurar e executar agentes [Claude Code](https://docs.anthropic.com/en/docs/claude-code) de forma visual. Projetada para equipes de desenvolvimento e profissionais que precisam orquestrar multiplos agentes IA com diferentes especialidades, executar pipelines de trabalho automatizados e integrar com repositorios Git — tudo a partir de um painel administrativo elegante.
|
||||||
|
|
||||||
|
### Por que usar?
|
||||||
|
|
||||||
|
| Problema | Solucao |
|
||||||
|
|----------|---------|
|
||||||
|
| Gerenciar multiplos agentes via CLI e tedioso | Interface visual com cards, filtros e execucao com 1 clique |
|
||||||
|
| Saida do agente nao e visivel em tempo real | Terminal com streaming WebSocket chunk-a-chunk |
|
||||||
|
| Automatizar fluxos sequenciais e complexo | Pipelines visuais com aprovacao humana entre passos |
|
||||||
|
| Agentes nao tem acesso a repositorios remotos | Integracao Git nativa com clone, commit e push automatico |
|
||||||
|
| Deploy manual e propenso a erros | `git deploy` — um comando faz tudo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Funcionalidades
|
## Funcionalidades
|
||||||
|
|
||||||
### Gerenciamento de Agentes
|
### Agentes
|
||||||
- Crie agentes com nome, system prompt, modelo (Sonnet/Opus/Haiku), diretório de trabalho, ferramentas permitidas, modo de permissão e tags
|
|
||||||
- Ative, desative, edite, **duplique** ou exclua a qualquer momento
|
|
||||||
- Exporte/importe configurações completas em JSON
|
|
||||||
|
|
||||||
### Execução de Tarefas
|
- Criacao com system prompt, modelo (Sonnet/Opus/Haiku), diretorio de trabalho, ferramentas permitidas e modo de permissao
|
||||||
- Execute tarefas sob demanda em qualquer agente ativo
|
- Tags para organizacao e filtragem
|
||||||
- Templates rápidos incluídos (detecção de bugs, revisão OWASP, refatoração, testes, documentação, performance)
|
- Duplicacao, importacao/exportacao JSON
|
||||||
- **Reexecute** tarefas que falharam ou foram canceladas com um clique
|
- Delegacao automatica entre agentes (Tech Lead → PO)
|
||||||
- Continuação de conversa (resume session) no terminal
|
- Agentes coordenadores recebem lista de agentes disponiveis injetada no prompt
|
||||||
|
|
||||||
### Terminal em Tempo Real
|
### Execucao
|
||||||
- Streaming chunk-a-chunk via WebSocket com indicador de conexão
|
|
||||||
- **Busca** no output do terminal com navegação entre ocorrências
|
|
||||||
- **Download** da saída completa como `.txt`
|
|
||||||
- **Copiar** saída para a área de transferência
|
|
||||||
- **Toggle de auto-scroll** para controle manual da rolagem
|
|
||||||
- Filtro por execução
|
|
||||||
|
|
||||||
### Dashboard com Gráficos
|
- Modal de execucao com seletor de agente, tarefa, instrucoes adicionais e arquivos de contexto
|
||||||
- Métricas em tempo real (agentes, execuções, agendamentos, custo, webhooks)
|
- **Seletor de repositorio Git** — escolha um repo do Gitea e o branch; o sistema clona/atualiza, executa e faz commit/push automatico
|
||||||
- **Gráfico de execuções** por dia (barras empilhadas sucesso/erro)
|
- Templates rapidos: deteccao de bugs, revisao OWASP, refatoracao, testes, documentacao, performance
|
||||||
- **Gráfico de custo** por dia (linha com área preenchida)
|
- Retry automatico configuravel por agente
|
||||||
- **Distribuição de status** (doughnut chart)
|
- Continuacao de conversa (resume session)
|
||||||
- **Top 5 agentes** mais executados (barras horizontais)
|
- Cancelamento individual ou em massa
|
||||||
- **Taxa de sucesso** geral (gauge com percentual)
|
|
||||||
- Seletor de período: 7, 14 ou 30 dias
|
|
||||||
|
|
||||||
### Agendamento Cron
|
|
||||||
- Agende tarefas recorrentes com expressões cron
|
|
||||||
- Presets incluídos (horário, diário, semanal, mensal)
|
|
||||||
- Histórico de execuções por agendamento com duração e custo
|
|
||||||
|
|
||||||
### Pipelines
|
### Pipelines
|
||||||
- Encadeie múltiplos agentes em fluxos sequenciais
|
|
||||||
- Saída de cada passo alimenta o próximo via template `{{input}}`
|
- Encadeamento de multiplos agentes em fluxos sequenciais
|
||||||
- Portões de aprovação humana entre passos (human-in-the-loop)
|
- Saida de cada passo alimenta o proximo via `{{input}}`
|
||||||
- Ideal para fluxos como "analisar → corrigir → testar"
|
- **Seletor de repositorio** — todos os passos trabalham no mesmo repo com commit automatico ao final
|
||||||
|
- Portoes de aprovacao humana (human-in-the-loop)
|
||||||
|
- Retomada de pipelines falhos a partir do passo onde pararam
|
||||||
|
- Editor de fluxo visual com drag para reordenar passos
|
||||||
|
|
||||||
|
### Terminal
|
||||||
|
|
||||||
|
- Streaming em tempo real via WebSocket
|
||||||
|
- Botao Interromper para cancelar execucoes ativas
|
||||||
|
- Busca no output com navegacao entre ocorrencias
|
||||||
|
- Download como `.txt` e copia para clipboard
|
||||||
|
- Auto-scroll toggleavel
|
||||||
|
|
||||||
|
### Integração Git
|
||||||
|
|
||||||
|
- Listagem automatica de repositorios do Gitea
|
||||||
|
- Seletor de branch dinamico
|
||||||
|
- Clone/pull automatico antes da execucao
|
||||||
|
- **Commit e push automatico** ao final com mensagem descritiva
|
||||||
|
- Instrucao injetada para agentes nao fazerem operacoes git
|
||||||
|
- Publicacao de projetos: cria repo, configura subdominio, deploy com 1 clique
|
||||||
|
|
||||||
|
### Explorador de Arquivos
|
||||||
|
|
||||||
|
- Navegacao em `/home/projetos/` com breadcrumb
|
||||||
|
- Download de arquivos individuais ou pastas completas (.tar.gz)
|
||||||
|
- Exclusao com confirmacao
|
||||||
|
- Botao publicar em projetos — cria repo no Gitea, configura Caddy e faz deploy automatico em `projeto.nitro-cloud.duckdns.org`
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
- Metricas em tempo real: agentes, execucoes, agendamentos, custo, webhooks
|
||||||
|
- Graficos: execucoes por dia, custo diario, distribuicao de status, top 5 agentes, taxa de sucesso
|
||||||
|
- Seletor de periodo: 7, 14 ou 30 dias
|
||||||
|
|
||||||
|
### Catalogo de Tarefas
|
||||||
|
|
||||||
|
- Tarefas reutilizaveis com nome, categoria e descricao
|
||||||
|
- Categorias: Code Review, Seguranca, Refatoracao, Testes, Documentacao, Performance
|
||||||
|
- Filtro por texto e categoria
|
||||||
|
- Execucao direta a partir do catalogo
|
||||||
|
|
||||||
|
### Agendamento Cron
|
||||||
|
|
||||||
|
- Expressoes cron com presets (horario, diario, semanal, mensal)
|
||||||
|
- Historico de execucoes por agendamento
|
||||||
|
- Retry automatico em caso de limite de slots
|
||||||
|
|
||||||
### Webhooks
|
### Webhooks
|
||||||
- Dispare execuções de agentes ou pipelines via HTTP externo
|
|
||||||
- **Edite** webhooks existentes (nome, alvo, status)
|
|
||||||
- **Teste** webhooks com um clique para verificar configuração
|
|
||||||
- Snippet cURL pronto para copiar
|
|
||||||
- Assinatura HMAC-SHA256 para validação de origem
|
|
||||||
|
|
||||||
### Notificações
|
- Disparo de execucoes via HTTP externo
|
||||||
- **Centro de notificações** no header com badge de contagem
|
- Edicao, teste com 1 clique e snippet cURL
|
||||||
- Notificações automáticas para execuções concluídas e com erro
|
- Assinatura HMAC-SHA256
|
||||||
- **Notificações nativas do navegador** (Browser Notification API)
|
|
||||||
- Marcar como lidas / limpar todas
|
|
||||||
- Polling automático a cada 15 segundos
|
|
||||||
|
|
||||||
### Tema Claro/Escuro
|
### Notificacoes
|
||||||
- Toggle de tema no header com transições suaves
|
|
||||||
- Persistência da preferência em localStorage
|
|
||||||
- Terminal mantém fundo escuro em ambos os temas
|
|
||||||
|
|
||||||
### Exportação de Dados
|
- Centro de notificacoes com badge de contagem
|
||||||
- **Exportar histórico** de execuções como CSV (UTF-8 com BOM)
|
- Notificacoes nativas do navegador
|
||||||
- Exportar configuração de agentes em JSON
|
- Polling automatico a cada 15 segundos
|
||||||
|
|
||||||
### Atalhos de Teclado
|
### Tema e UX
|
||||||
| Tecla | Ação |
|
|
||||||
|-------|------|
|
|
||||||
| `1`–`9` | Navegar entre seções |
|
|
||||||
| `N` | Novo agente |
|
|
||||||
| `Esc` | Fechar modal |
|
|
||||||
|
|
||||||
## Deploy
|
- Tema claro/escuro com transicao suave
|
||||||
|
- Atalhos de teclado (`1`-`9` navegacao, `N` novo agente, `Esc` fechar modal)
|
||||||
|
- Exportacao de historico como CSV
|
||||||
|
|
||||||
A aplicação roda em container Docker na infraestrutura Nitro Cloud com HTTPS automático via Caddy + Let's Encrypt.
|
---
|
||||||
|
|
||||||
### Atualizar o sistema em produção
|
## Quick Start
|
||||||
|
|
||||||
|
### Requisitos
|
||||||
|
|
||||||
|
- Node.js >= 22
|
||||||
|
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) instalado e autenticado
|
||||||
|
|
||||||
|
### Execucao local
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Push das alterações para o Gitea
|
git clone https://github.com/fredac100/agents-orchestrator.git
|
||||||
git push nitro main
|
cd agents-orchestrator
|
||||||
|
npm install
|
||||||
# 2. Conectar no servidor
|
npm start
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verificar status
|
Acesse `http://localhost:3000`.
|
||||||
|
|
||||||
|
### Com Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -p 2222 fred@192.168.1.151 "docker logs agents-orchestrator --tail 20"
|
docker build -t agents-orchestrator .
|
||||||
|
docker run -p 3000:3000 \
|
||||||
|
-v $(pwd)/data:/app/data \
|
||||||
|
-v ~/.claude:/home/node/.claude \
|
||||||
|
agents-orchestrator
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reiniciar
|
---
|
||||||
|
|
||||||
```bash
|
## Arquitetura
|
||||||
ssh -p 2222 fred@192.168.1.151 "cd ~/vps && docker compose restart agents-orchestrator"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Cliente (navegador)
|
HTTPS (443)
|
||||||
│
|
|
|
||||||
▼ HTTPS (porta 443)
|
[Caddy] ─── SSL automatico via DuckDNS
|
||||||
Caddy (reverse proxy + SSL automático)
|
|
|
||||||
│
|
*.nitro-cloud.duckdns.org
|
||||||
▼ agents.nitro-cloud.duckdns.org
|
|
|
||||||
Container Docker (agents-orchestrator)
|
┌──────────────┼──────────────┐
|
||||||
│
|
| | |
|
||||||
├── Express (HTTP API + arquivos estáticos)
|
[agents.*] [git.*] [projeto.*]
|
||||||
└── WebSocket (streaming em tempo real)
|
| | |
|
||||||
|
┌──────┴──────┐ [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)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Arquitetura Interna
|
### Estrutura do Projeto
|
||||||
|
|
||||||
```
|
```
|
||||||
server.js Express + WebSocket + rate limiting + auth
|
server.js HTTP + WebSocket + rate limiting + auth
|
||||||
src/
|
src/
|
||||||
routes/api.js API REST (/api/*) — 30+ endpoints
|
routes/api.js API REST — 40+ endpoints
|
||||||
agents/
|
agents/
|
||||||
manager.js CRUD + orquestração + notificações
|
manager.js CRUD + orquestracao + delegacao
|
||||||
executor.js Spawna o CLI claude como child_process
|
executor.js Spawna o CLI claude como child_process
|
||||||
scheduler.js Agendamento cron (in-memory + persistido)
|
scheduler.js Agendamento cron
|
||||||
pipeline.js Execução sequencial com aprovação humana
|
pipeline.js Execucao sequencial + aprovacao humana
|
||||||
store/db.js Persistência em JSON com escrita atômica
|
git-integration.js Clone, pull, commit, push automatico
|
||||||
cache/index.js Cache em 2 níveis (memória + Redis opcional)
|
store/db.js Persistencia JSON com escrita atomica
|
||||||
|
cache/index.js Cache L1 (memoria) + L2 (Redis opcional)
|
||||||
|
reports/generator.js Geracao de relatorios de execucao
|
||||||
public/
|
public/
|
||||||
index.html SPA single-page com hash routing
|
app.html SPA com hash routing
|
||||||
css/styles.css Design system (dark/light themes)
|
css/styles.css Design system (dark/light)
|
||||||
js/
|
js/
|
||||||
app.js Controlador principal + WebSocket + tema + routing
|
app.js Controlador principal + WebSocket
|
||||||
api.js Client HTTP para a API
|
api.js Client HTTP para a API
|
||||||
components/ UI por seção (15 módulos)
|
components/ 16 modulos UI independentes
|
||||||
data/
|
scripts/
|
||||||
agents.json Agentes cadastrados
|
deploy.sh Deploy automatizado via rsync + Docker
|
||||||
tasks.json Templates de tarefas
|
data/ Persistencia em JSON (8 stores)
|
||||||
pipelines.json Pipelines configurados
|
|
||||||
schedules.json Agendamentos persistidos
|
|
||||||
executions.json Histórico de execuções (max 5000)
|
|
||||||
webhooks.json Configuração de webhooks
|
|
||||||
notifications.json Notificações do sistema
|
|
||||||
settings.json Configurações globais
|
|
||||||
```
|
```
|
||||||
|
|
||||||
O executor invoca o binário `claude` com `--output-format stream-json`, parseia o stdout linha a linha e transmite os chunks via WebSocket para o frontend em tempo real.
|
---
|
||||||
|
|
||||||
## API REST
|
## API
|
||||||
|
|
||||||
### Agentes
|
### Agentes
|
||||||
|
|
||||||
| Método | Endpoint | Descrição |
|
| Metodo | Endpoint | Descricao |
|
||||||
|--------|----------|-----------|
|
|--------|----------|-----------|
|
||||||
| `GET` | `/api/agents` | Listar agentes |
|
| `GET` | `/api/agents` | Listar agentes |
|
||||||
| `POST` | `/api/agents` | Criar agente |
|
| `POST` | `/api/agents` | Criar agente |
|
||||||
| `GET` | `/api/agents/:id` | Obter agente |
|
| `GET` | `/api/agents/:id` | Obter agente |
|
||||||
| `PUT` | `/api/agents/:id` | Atualizar agente |
|
| `PUT` | `/api/agents/:id` | Atualizar agente |
|
||||||
| `DELETE` | `/api/agents/:id` | Excluir agente |
|
| `DELETE` | `/api/agents/:id` | Excluir agente |
|
||||||
| `POST` | `/api/agents/:id/execute` | Executar tarefa no agente |
|
| `POST` | `/api/agents/:id/execute` | Executar tarefa (aceita `repoName` e `repoBranch`) |
|
||||||
| `POST` | `/api/agents/:id/continue` | Continuar conversa (resume) |
|
| `POST` | `/api/agents/:id/continue` | Continuar conversa |
|
||||||
| `POST` | `/api/agents/:id/cancel/:execId` | Cancelar execução |
|
| `POST` | `/api/agents/:id/cancel/:execId` | Cancelar execucao |
|
||||||
| `GET` | `/api/agents/:id/export` | Exportar agente (JSON) |
|
| `GET` | `/api/agents/:id/export` | Exportar agente |
|
||||||
| `POST` | `/api/agents/:id/duplicate` | Duplicar agente |
|
| `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
|
### Pipelines
|
||||||
|
|
||||||
| Método | Endpoint | Descrição |
|
| Metodo | Endpoint | Descricao |
|
||||||
|--------|----------|-----------|
|
|--------|----------|-----------|
|
||||||
| `GET` | `/api/pipelines` | Listar pipelines |
|
| `GET` | `/api/pipelines` | Listar pipelines |
|
||||||
| `POST` | `/api/pipelines` | Criar pipeline |
|
| `POST` | `/api/pipelines` | Criar pipeline |
|
||||||
| `GET` | `/api/pipelines/:id` | Obter pipeline |
|
| `POST` | `/api/pipelines/:id/execute` | Executar (aceita `repoName` e `repoBranch`) |
|
||||||
| `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/approve` | Aprovar passo pendente |
|
||||||
| `POST` | `/api/pipelines/:id/reject` | Rejeitar passo pendente |
|
| `POST` | `/api/pipelines/:id/reject` | Rejeitar passo |
|
||||||
|
| `POST` | `/api/pipelines/resume/:execId` | Retomar pipeline falho |
|
||||||
|
|
||||||
### Webhooks
|
### Repositorios
|
||||||
|
|
||||||
| Método | Endpoint | Descrição |
|
| Metodo | Endpoint | Descricao |
|
||||||
|--------|----------|-----------|
|
|--------|----------|-----------|
|
||||||
| `GET` | `/api/webhooks` | Listar webhooks |
|
| `GET` | `/api/repos` | Listar repositorios do Gitea |
|
||||||
| `POST` | `/api/webhooks` | Criar webhook |
|
| `GET` | `/api/repos/:name/branches` | Listar branches de um repo |
|
||||||
| `PUT` | `/api/webhooks/:id` | Atualizar webhook |
|
|
||||||
| `DELETE` | `/api/webhooks/:id` | Excluir webhook |
|
|
||||||
| `POST` | `/api/webhooks/:id/test` | Testar webhook |
|
|
||||||
|
|
||||||
### Execuções e Histórico
|
### Arquivos e Publicacao
|
||||||
|
|
||||||
| Método | Endpoint | Descrição |
|
| Metodo | Endpoint | Descricao |
|
||||||
|--------|----------|-----------|
|
|--------|----------|-----------|
|
||||||
| `GET` | `/api/executions/active` | Execuções em andamento |
|
| `GET` | `/api/files` | Listar diretorio |
|
||||||
| `GET` | `/api/executions/history` | Histórico paginado com filtros |
|
| `GET` | `/api/files/download` | Download de arquivo |
|
||||||
| `GET` | `/api/executions/recent` | Execuções recentes |
|
| `GET` | `/api/files/download-folder` | Download de pasta (.tar.gz) |
|
||||||
| `GET` | `/api/executions/export` | Exportar histórico como CSV |
|
| `DELETE` | `/api/files` | Excluir arquivo ou pasta |
|
||||||
| `GET` | `/api/executions/:id` | Detalhes de uma execução |
|
| `POST` | `/api/files/publish` | Publicar projeto (repo + deploy + subdominio) |
|
||||||
| `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
|
### Sistema
|
||||||
|
|
||||||
| Método | Endpoint | Descrição |
|
| Metodo | Endpoint | Descricao |
|
||||||
|--------|----------|-----------|
|
|--------|----------|-----------|
|
||||||
| `GET` | `/api/health` | Health check (sem auth) |
|
| `GET` | `/api/health` | Health check |
|
||||||
| `GET` | `/api/system/status` | Status geral do sistema |
|
| `GET` | `/api/system/status` | Status geral |
|
||||||
| `GET` | `/api/system/info` | Informações do servidor |
|
| `GET` | `/api/stats/costs` | Estatisticas de custo |
|
||||||
| `GET` | `/api/stats/costs` | Estatísticas de custo |
|
| `GET` | `/api/stats/charts` | Dados para graficos |
|
||||||
| `GET` | `/api/stats/charts` | Dados para gráficos do dashboard |
|
|
||||||
| `GET/PUT` | `/api/settings` | Configurações globais |
|
---
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
### Deploy automatico
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
O alias executa `scripts/deploy.sh` que automaticamente:
|
||||||
|
|
||||||
|
1. Push para GitHub e Gitea
|
||||||
|
2. Backup dos dados no VPS
|
||||||
|
3. Sincronizacao via rsync
|
||||||
|
4. Correcao de permissoes
|
||||||
|
5. Rebuild do container Docker
|
||||||
|
6. Verificacao de integridade
|
||||||
|
7. Limpeza de backups antigos (mantem 3)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apenas deploy sem push
|
||||||
|
bash scripts/deploy.sh --skip-push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variaveis de Ambiente
|
||||||
|
|
||||||
|
| Variavel | Descricao | Padrao |
|
||||||
|
|----------|-----------|--------|
|
||||||
|
| `PORT` | Porta do servidor | `3000` |
|
||||||
|
| `HOST` | Endereco de bind | `0.0.0.0` |
|
||||||
|
| `AUTH_TOKEN` | Bearer token para auth da API | _(desabilitado)_ |
|
||||||
|
| `ALLOWED_ORIGIN` | Origin para CORS | `http://localhost:3000` |
|
||||||
|
| `WEBHOOK_SECRET` | Segredo HMAC para webhooks | _(desabilitado)_ |
|
||||||
|
| `GITEA_URL` | URL interna do Gitea | `http://gitea:3000` |
|
||||||
|
| `GITEA_USER` | Usuario do Gitea | `fred` |
|
||||||
|
| `GITEA_PASS` | Senha do Gitea | _(obrigatorio para Git)_ |
|
||||||
|
| `DOMAIN` | Dominio base para subdominios | `nitro-cloud.duckdns.org` |
|
||||||
|
| `CLAUDE_BIN` | Caminho do CLI Claude | _(auto-detectado)_ |
|
||||||
|
| `REDIS_URL` | Redis para cache L2 | _(somente memoria)_ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seguranca
|
||||||
|
|
||||||
|
- HTTPS via Caddy com certificado wildcard Let's Encrypt
|
||||||
|
- Autenticacao Bearer token com timing-safe comparison
|
||||||
|
- Rate limiting: 100 req/min (API), 30 req/min (webhooks)
|
||||||
|
- CORS restrito a origin configurada
|
||||||
|
- Correlation IDs em todas as requisicoes
|
||||||
|
- Escrita atomica em disco (temp + rename)
|
||||||
|
- Sanitizacao de prompts (NUL, controle, limite 50K chars)
|
||||||
|
- HMAC-SHA256 para webhooks recebidos
|
||||||
|
- Protecao contra path traversal no file explorer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Eventos WebSocket
|
## Eventos WebSocket
|
||||||
|
|
||||||
O servidor envia eventos tipados via WebSocket que o frontend renderiza no terminal:
|
| Evento | Descricao |
|
||||||
|
|
||||||
| Evento | Descrição |
|
|
||||||
|--------|-----------|
|
|--------|-----------|
|
||||||
| `execution_output` | Chunk de texto da saída do agente |
|
| `execution_output` | Chunk de saida do agente |
|
||||||
| `execution_complete` | Execução finalizada com resultado |
|
| `execution_complete` | Execucao finalizada |
|
||||||
| `execution_error` | Erro durante execução |
|
| `execution_error` | Erro durante execucao |
|
||||||
| `pipeline_step_start` | Início de um passo do pipeline |
|
| `execution_retry` | Tentativa de retry |
|
||||||
| `pipeline_step_complete` | Passo do pipeline concluído |
|
| `pipeline_step_start` | Inicio de passo |
|
||||||
|
| `pipeline_step_complete` | Passo concluido |
|
||||||
| `pipeline_complete` | Pipeline finalizado |
|
| `pipeline_complete` | Pipeline finalizado |
|
||||||
| `pipeline_error` | Erro em um passo do pipeline |
|
| `pipeline_error` | Erro no pipeline |
|
||||||
| `pipeline_approval_required` | Passo aguardando aprovação humana |
|
| `pipeline_approval_required` | Aguardando aprovacao humana |
|
||||||
|
| `report_generated` | Relatorio gerado |
|
||||||
|
|
||||||
## 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
|
## Stack
|
||||||
|
|
||||||
- **Backend**: Node.js, Express, WebSocket (ws), node-cron, uuid, express-rate-limit
|
| Camada | Tecnologias |
|
||||||
- **Frontend**: HTML, CSS, JavaScript vanilla (sem framework, sem bundler)
|
|--------|-------------|
|
||||||
- **Gráficos**: Chart.js 4.x
|
| **Backend** | Node.js 22, Express, WebSocket (ws), node-cron, uuid |
|
||||||
- **Ícones**: Lucide
|
| **Frontend** | HTML, CSS, JavaScript vanilla — sem framework, sem bundler |
|
||||||
- **Fontes**: Inter (UI), JetBrains Mono (código/terminal)
|
| **Graficos** | Chart.js 4.x |
|
||||||
- **Persistência**: Arquivos JSON em disco com escrita atômica
|
| **Icones** | Lucide |
|
||||||
- **Cache**: In-memory com suporte opcional a Redis (ioredis)
|
| **Fontes** | Inter (UI), JetBrains Mono (terminal) |
|
||||||
- **Infraestrutura**: Docker + Caddy + DuckDNS + Let's Encrypt
|
| **Persistencia** | JSON em disco com escrita atomica |
|
||||||
|
| **Cache** | In-memory + Redis opcional (ioredis) |
|
||||||
|
| **Infra** | Docker, Caddy, DuckDNS, Let's Encrypt |
|
||||||
|
| **Git** | Gitea (self-hosted) |
|
||||||
|
|
||||||
## Licença
|
---
|
||||||
|
|
||||||
|
## Licenca
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub>Desenvolvido por <a href="https://nitro-cloud.duckdns.org">Nitro Cloud</a></sub>
|
||||||
|
</p>
|
||||||
|
|||||||
@@ -72,6 +72,12 @@
|
|||||||
<span>Histórico</span>
|
<span>Histórico</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="sidebar-nav-item">
|
||||||
<a href="#" class="sidebar-nav-link" data-section="settings">
|
<a href="#" class="sidebar-nav-link" data-section="settings">
|
||||||
<i data-lucide="settings"></i>
|
<i data-lucide="settings"></i>
|
||||||
@@ -578,6 +584,10 @@
|
|||||||
<div id="history-pagination"></div>
|
<div id="history-pagination"></div>
|
||||||
</section>
|
</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>
|
<section id="settings" class="section" aria-label="Configurações" hidden>
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -798,7 +808,7 @@
|
|||||||
class="input"
|
class="input"
|
||||||
id="agent-workdir"
|
id="agent-workdir"
|
||||||
name="workdir"
|
name="workdir"
|
||||||
placeholder="/home/fred/projetos"
|
value="/home/projetos/"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -842,6 +852,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Retry em caso de falha</label>
|
<label class="form-label">Retry em caso de falha</label>
|
||||||
@@ -978,6 +998,30 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label class="form-label">Arquivos de Contexto</label>
|
<label class="form-label">Arquivos de Contexto</label>
|
||||||
<div class="dropzone" id="execute-dropzone">
|
<div class="dropzone" id="execute-dropzone">
|
||||||
@@ -1201,6 +1245,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<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>
|
<label class="form-label" for="pipeline-execute-workdir">Diretório de Trabalho (opcional)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1377,6 +1434,7 @@
|
|||||||
<script src="js/components/history.js"></script>
|
<script src="js/components/history.js"></script>
|
||||||
<script src="js/components/webhooks.js"></script>
|
<script src="js/components/webhooks.js"></script>
|
||||||
<script src="js/components/notifications.js"></script>
|
<script src="js/components/notifications.js"></script>
|
||||||
|
<script src="js/components/files.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
<script>
|
<script>
|
||||||
Utils.refreshIcons();
|
Utils.refreshIcons();
|
||||||
|
|||||||
@@ -966,15 +966,22 @@ textarea {
|
|||||||
transform: scale(1) translateY(0);
|
transform: scale(1) translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-sm {
|
.modal-sm,
|
||||||
|
.modal--sm {
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-lg {
|
.modal--md {
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-lg,
|
||||||
|
.modal--lg {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-xl {
|
.modal-xl,
|
||||||
|
.modal--xl {
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1088,29 +1095,32 @@ textarea {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-line {
|
.terminal-line {
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: flex-start;
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-line .timestamp {
|
.terminal-line .timestamp {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-right: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding-top: 1px;
|
padding-top: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-line .content {
|
.terminal-line .content {
|
||||||
color: #c8c8d8;
|
color: #c8c8d8;
|
||||||
word-break: break-all;
|
word-break: break-word;
|
||||||
flex: 1;
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-line.error .content {
|
.terminal-line.error .content {
|
||||||
@@ -3364,9 +3374,6 @@ tbody tr:hover td {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-card-description {
|
.task-card-description {
|
||||||
@@ -3374,13 +3381,13 @@ tbody tr:hover td {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 4;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-height: 3em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-card-footer {
|
.task-card-footer {
|
||||||
|
margin-top: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -5169,3 +5176,185 @@ body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metr
|
|||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid var(--border-primary);
|
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,8 +38,10 @@ const API = {
|
|||||||
create(data) { return API.request('POST', '/agents', data); },
|
create(data) { return API.request('POST', '/agents', data); },
|
||||||
update(id, data) { return API.request('PUT', `/agents/${id}`, data); },
|
update(id, data) { return API.request('PUT', `/agents/${id}`, data); },
|
||||||
delete(id) { return API.request('DELETE', `/agents/${id}`); },
|
delete(id) { return API.request('DELETE', `/agents/${id}`); },
|
||||||
execute(id, task, instructions, contextFiles) {
|
execute(id, task, instructions, contextFiles, workingDirectory, repoName, repoBranch) {
|
||||||
const body = { task, instructions };
|
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;
|
if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
|
||||||
return API.request('POST', `/agents/${id}/execute`, body);
|
return API.request('POST', `/agents/${id}/execute`, body);
|
||||||
},
|
},
|
||||||
@@ -82,9 +84,10 @@ const API = {
|
|||||||
create(data) { return API.request('POST', '/pipelines', data); },
|
create(data) { return API.request('POST', '/pipelines', data); },
|
||||||
update(id, data) { return API.request('PUT', `/pipelines/${id}`, data); },
|
update(id, data) { return API.request('PUT', `/pipelines/${id}`, data); },
|
||||||
delete(id) { return API.request('DELETE', `/pipelines/${id}`); },
|
delete(id) { return API.request('DELETE', `/pipelines/${id}`); },
|
||||||
execute(id, input, workingDirectory, contextFiles) {
|
execute(id, input, workingDirectory, contextFiles, repoName, repoBranch) {
|
||||||
const body = { input };
|
const body = { input };
|
||||||
if (workingDirectory) body.workingDirectory = workingDirectory;
|
if (repoName) { body.repoName = repoName; if (repoBranch) body.repoBranch = repoBranch; }
|
||||||
|
else if (workingDirectory) body.workingDirectory = workingDirectory;
|
||||||
if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
|
if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
|
||||||
return API.request('POST', `/pipelines/${id}/execute`, body);
|
return API.request('POST', `/pipelines/${id}/execute`, body);
|
||||||
},
|
},
|
||||||
@@ -141,6 +144,17 @@ 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: {
|
reports: {
|
||||||
list() { return API.request('GET', '/reports'); },
|
list() { return API.request('GET', '/reports'); },
|
||||||
get(filename) { return API.request('GET', `/reports/${encodeURIComponent(filename)}`); },
|
get(filename) { return API.request('GET', `/reports/${encodeURIComponent(filename)}`); },
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ const App = {
|
|||||||
webhooks: 'Webhooks',
|
webhooks: 'Webhooks',
|
||||||
terminal: 'Terminal',
|
terminal: 'Terminal',
|
||||||
history: 'Histórico',
|
history: 'Histórico',
|
||||||
|
files: 'Projetos',
|
||||||
settings: 'Configurações',
|
settings: 'Configurações',
|
||||||
},
|
},
|
||||||
|
|
||||||
sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'settings'],
|
sections: ['dashboard', 'agents', 'tasks', 'schedules', 'pipelines', 'webhooks', 'terminal', 'history', 'files', 'settings'],
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if (App._initialized) return;
|
if (App._initialized) return;
|
||||||
@@ -36,6 +37,7 @@ const App = {
|
|||||||
|
|
||||||
App._executeDropzone = Utils.initDropzone('execute-dropzone', 'execute-files', 'execute-file-list');
|
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._pipelineDropzone = Utils.initDropzone('pipeline-execute-dropzone', 'pipeline-execute-files', 'pipeline-execute-file-list');
|
||||||
|
App._initRepoSelectors();
|
||||||
|
|
||||||
const initialSection = location.hash.replace('#', '') || 'dashboard';
|
const initialSection = location.hash.replace('#', '') || 'dashboard';
|
||||||
App.navigateTo(App.sections.includes(initialSection) ? initialSection : 'dashboard');
|
App.navigateTo(App.sections.includes(initialSection) ? initialSection : 'dashboard');
|
||||||
@@ -113,6 +115,7 @@ const App = {
|
|||||||
case 'pipelines': await PipelinesUI.load(); break;
|
case 'pipelines': await PipelinesUI.load(); break;
|
||||||
case 'webhooks': await WebhooksUI.load(); break;
|
case 'webhooks': await WebhooksUI.load(); break;
|
||||||
case 'history': await HistoryUI.load(); break;
|
case 'history': await HistoryUI.load(); break;
|
||||||
|
case 'files': await FilesUI.load(); break;
|
||||||
case 'settings': await SettingsUI.load(); break;
|
case 'settings': await SettingsUI.load(); break;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -763,6 +766,20 @@ 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) => {
|
document.getElementById('pipeline-steps-container')?.addEventListener('click', (e) => {
|
||||||
const btn = e.target.closest('[data-step-action]');
|
const btn = e.target.closest('[data-step-action]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
@@ -844,6 +861,61 @@ const App = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_reposCache: null,
|
||||||
|
|
||||||
|
async _loadRepos(selectId) {
|
||||||
|
const select = document.getElementById(selectId);
|
||||||
|
if (!select) return;
|
||||||
|
try {
|
||||||
|
if (!App._reposCache) App._reposCache = await API.repos.list();
|
||||||
|
const current = select.value;
|
||||||
|
select.innerHTML = '<option value="">Nenhum (usar diretório manual)</option>';
|
||||||
|
App._reposCache.forEach(r => {
|
||||||
|
select.insertAdjacentHTML('beforeend',
|
||||||
|
`<option value="${Utils.escapeHtml(r.name)}">${Utils.escapeHtml(r.name)}${r.description ? ' — ' + Utils.escapeHtml(r.description.slice(0, 40)) : ''}</option>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (current) select.value = current;
|
||||||
|
} catch { }
|
||||||
|
},
|
||||||
|
|
||||||
|
_initRepoSelectors() {
|
||||||
|
const pairs = [
|
||||||
|
['execute-repo', 'execute-repo-branch', 'execute-workdir-group'],
|
||||||
|
['pipeline-execute-repo', 'pipeline-execute-repo-branch', 'pipeline-execute-workdir-group'],
|
||||||
|
];
|
||||||
|
pairs.forEach(([repoId, branchId, workdirGroupId]) => {
|
||||||
|
const repoSelect = document.getElementById(repoId);
|
||||||
|
const branchSelect = document.getElementById(branchId);
|
||||||
|
const workdirGroup = document.getElementById(workdirGroupId);
|
||||||
|
if (!repoSelect) return;
|
||||||
|
|
||||||
|
repoSelect.addEventListener('change', async () => {
|
||||||
|
const repoName = repoSelect.value;
|
||||||
|
if (repoName) {
|
||||||
|
if (workdirGroup) workdirGroup.style.display = 'none';
|
||||||
|
if (branchSelect) {
|
||||||
|
branchSelect.style.display = '';
|
||||||
|
branchSelect.innerHTML = '<option value="">Branch padrão</option>';
|
||||||
|
try {
|
||||||
|
const branches = await API.repos.branches(repoName);
|
||||||
|
branches.forEach(b => {
|
||||||
|
branchSelect.insertAdjacentHTML('beforeend', `<option value="${Utils.escapeHtml(b)}">${Utils.escapeHtml(b)}</option>`);
|
||||||
|
});
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (workdirGroup) workdirGroup.style.display = '';
|
||||||
|
if (branchSelect) branchSelect.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
repoSelect.addEventListener('focus', () => {
|
||||||
|
if (repoSelect.options.length <= 1) App._loadRepos(repoId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async _handleExecute() {
|
async _handleExecute() {
|
||||||
const agentId = document.getElementById('execute-agent-select')?.value
|
const agentId = document.getElementById('execute-agent-select')?.value
|
||||||
|| document.getElementById('execute-agent-id')?.value;
|
|| document.getElementById('execute-agent-id')?.value;
|
||||||
@@ -860,6 +932,9 @@ const App = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const instructions = document.getElementById('execute-instructions')?.value.trim() || '';
|
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 {
|
try {
|
||||||
const selectEl = document.getElementById('execute-agent-select');
|
const selectEl = document.getElementById('execute-agent-select');
|
||||||
@@ -876,7 +951,7 @@ const App = {
|
|||||||
Terminal.disableChat();
|
Terminal.disableChat();
|
||||||
App._lastAgentName = agentName;
|
App._lastAgentName = agentName;
|
||||||
|
|
||||||
await API.agents.execute(agentId, task, instructions, contextFiles);
|
await API.agents.execute(agentId, task, instructions, contextFiles, workingDirectory, repoName, repoBranch);
|
||||||
|
|
||||||
if (dropzone) dropzone.reset();
|
if (dropzone) dropzone.reset();
|
||||||
Modal.close('execute-modal-overlay');
|
Modal.close('execute-modal-overlay');
|
||||||
|
|||||||
@@ -180,6 +180,9 @@ const AgentsUI = {
|
|||||||
const maxTurns = document.getElementById('agent-max-turns');
|
const maxTurns = document.getElementById('agent-max-turns');
|
||||||
if (maxTurns) maxTurns.value = '0';
|
if (maxTurns) maxTurns.value = '0';
|
||||||
|
|
||||||
|
const workdir = document.getElementById('agent-workdir');
|
||||||
|
if (workdir) workdir.value = '/home/projetos/';
|
||||||
|
|
||||||
const permissionMode = document.getElementById('agent-permission-mode');
|
const permissionMode = document.getElementById('agent-permission-mode');
|
||||||
if (permissionMode) permissionMode.value = '';
|
if (permissionMode) permissionMode.value = '';
|
||||||
|
|
||||||
@@ -192,6 +195,8 @@ const AgentsUI = {
|
|||||||
const retryMax = document.getElementById('agent-retry-max');
|
const retryMax = document.getElementById('agent-retry-max');
|
||||||
if (retryMax) retryMax.value = '3';
|
if (retryMax) retryMax.value = '3';
|
||||||
|
|
||||||
|
AgentsUI._populateDelegateSelect('');
|
||||||
|
|
||||||
const secretsSection = document.getElementById('agent-secrets-section');
|
const secretsSection = document.getElementById('agent-secrets-section');
|
||||||
if (secretsSection) secretsSection.hidden = true;
|
if (secretsSection) secretsSection.hidden = true;
|
||||||
|
|
||||||
@@ -250,6 +255,8 @@ const AgentsUI = {
|
|||||||
const retryMax = document.getElementById('agent-retry-max');
|
const retryMax = document.getElementById('agent-retry-max');
|
||||||
if (retryMax) retryMax.value = (agent.config && agent.config.maxRetries) || '3';
|
if (retryMax) retryMax.value = (agent.config && agent.config.maxRetries) || '3';
|
||||||
|
|
||||||
|
AgentsUI._populateDelegateSelect(agent.config?.delegateTo || '', agent.id);
|
||||||
|
|
||||||
const secretsSection = document.getElementById('agent-secrets-section');
|
const secretsSection = document.getElementById('agent-secrets-section');
|
||||||
if (secretsSection) secretsSection.hidden = false;
|
if (secretsSection) secretsSection.hidden = false;
|
||||||
|
|
||||||
@@ -296,6 +303,7 @@ const AgentsUI = {
|
|||||||
permissionMode: document.getElementById('agent-permission-mode')?.value || '',
|
permissionMode: document.getElementById('agent-permission-mode')?.value || '',
|
||||||
retryOnFailure: !!document.getElementById('agent-retry-toggle')?.checked,
|
retryOnFailure: !!document.getElementById('agent-retry-toggle')?.checked,
|
||||||
maxRetries: parseInt(document.getElementById('agent-retry-max')?.value) || 3,
|
maxRetries: parseInt(document.getElementById('agent-retry-max')?.value) || 3,
|
||||||
|
delegateTo: document.getElementById('agent-delegate-to')?.value || '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -358,8 +366,19 @@ const AgentsUI = {
|
|||||||
|
|
||||||
if (App._executeDropzone) App._executeDropzone.reset();
|
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();
|
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');
|
Modal.open('execute-modal-overlay');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Toast.error(`Erro ao abrir modal de execução: ${err.message}`);
|
Toast.error(`Erro ao abrir modal de execução: ${err.message}`);
|
||||||
@@ -468,6 +487,14 @@ 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() {
|
_setupModalListeners() {
|
||||||
const retryToggle = document.getElementById('agent-retry-toggle');
|
const retryToggle = document.getElementById('agent-retry-toggle');
|
||||||
const retryMaxGroup = document.getElementById('agent-retry-max-group');
|
const retryMaxGroup = document.getElementById('agent-retry-max-group');
|
||||||
|
|||||||
210
public/js/components/files.js
Normal file
210
public/js/components/files.js
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
const FilesUI = {
|
||||||
|
currentPath: '',
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
await FilesUI.navigate('');
|
||||||
|
},
|
||||||
|
|
||||||
|
async navigate(path) {
|
||||||
|
try {
|
||||||
|
const data = await API.files.list(path);
|
||||||
|
FilesUI.currentPath = data.path || '';
|
||||||
|
FilesUI.render(data);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(`Erro ao carregar arquivos: ${err.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render(data) {
|
||||||
|
const container = document.getElementById('files-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const breadcrumb = FilesUI._renderBreadcrumb(data.path);
|
||||||
|
const entries = data.entries || [];
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
${breadcrumb}
|
||||||
|
<div class="files-empty">
|
||||||
|
<i data-lucide="folder-open" style="width:48px;height:48px;color:var(--text-muted)"></i>
|
||||||
|
<p>Nenhum arquivo encontrado neste diretório</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
Utils.refreshIcons(container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = entries.map(entry => FilesUI._renderRow(entry, data.path)).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
${breadcrumb}
|
||||||
|
<div class="files-toolbar">
|
||||||
|
<span class="files-count">${entries.length} ${entries.length === 1 ? 'item' : 'itens'}</span>
|
||||||
|
<button class="btn btn--ghost btn--sm" data-action="download-folder" data-path="${Utils.escapeHtml(data.path || '')}" title="Baixar pasta como .tar.gz"><i data-lucide="download" style="width:14px;height:14px"></i> Baixar tudo</button>
|
||||||
|
</div>
|
||||||
|
<div class="files-table-wrapper">
|
||||||
|
<table class="files-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="files-th-name">Nome</th>
|
||||||
|
<th class="files-th-size">Tamanho</th>
|
||||||
|
<th class="files-th-date">Modificado</th>
|
||||||
|
<th class="files-th-actions"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
Utils.refreshIcons(container);
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderBreadcrumb(currentPath) {
|
||||||
|
const parts = currentPath ? currentPath.split('/').filter(Boolean) : [];
|
||||||
|
let html = `<nav class="files-breadcrumb"><a href="#" data-action="navigate-files" data-path="" class="files-breadcrumb-link"><i data-lucide="home" style="width:14px;height:14px"></i> projetos</a>`;
|
||||||
|
|
||||||
|
let accumulated = '';
|
||||||
|
for (const part of parts) {
|
||||||
|
accumulated += (accumulated ? '/' : '') + part;
|
||||||
|
html += ` <span class="files-breadcrumb-sep">/</span> <a href="#" data-action="navigate-files" data-path="${Utils.escapeHtml(accumulated)}" class="files-breadcrumb-link">${Utils.escapeHtml(part)}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</nav>';
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderRow(entry, currentPath) {
|
||||||
|
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
|
||||||
|
const icon = entry.type === 'directory' ? 'folder' : FilesUI._fileIcon(entry.extension);
|
||||||
|
const iconColor = entry.type === 'directory' ? 'var(--warning)' : 'var(--text-muted)';
|
||||||
|
const size = entry.type === 'directory' ? '—' : FilesUI._formatSize(entry.size);
|
||||||
|
const date = FilesUI._formatDate(entry.modified);
|
||||||
|
|
||||||
|
const nameCell = entry.type === 'directory'
|
||||||
|
? `<a href="#" class="files-entry-link files-entry-dir" data-action="navigate-files" data-path="${Utils.escapeHtml(fullPath)}"><i data-lucide="${icon}" style="width:16px;height:16px;color:${iconColor};flex-shrink:0"></i> ${Utils.escapeHtml(entry.name)}</a>`
|
||||||
|
: `<span class="files-entry-link files-entry-file"><i data-lucide="${icon}" style="width:16px;height:16px;color:${iconColor};flex-shrink:0"></i> ${Utils.escapeHtml(entry.name)}</span>`;
|
||||||
|
|
||||||
|
const downloadBtn = entry.type === 'directory'
|
||||||
|
? `<button class="btn btn--ghost btn--sm" data-action="download-folder" data-path="${Utils.escapeHtml(fullPath)}" title="Baixar pasta"><i data-lucide="download" style="width:14px;height:14px"></i></button>`
|
||||||
|
: `<button class="btn btn--ghost btn--sm" data-action="download-file" data-path="${Utils.escapeHtml(fullPath)}" title="Baixar arquivo"><i data-lucide="download" style="width:14px;height:14px"></i></button>`;
|
||||||
|
const isRootDir = entry.type === 'directory' && !currentPath;
|
||||||
|
const publishBtn = isRootDir
|
||||||
|
? `<button class="btn btn--ghost btn--sm btn-publish" data-action="publish-project" data-path="${Utils.escapeHtml(fullPath)}" title="Publicar projeto"><i data-lucide="rocket" style="width:14px;height:14px"></i></button>`
|
||||||
|
: '';
|
||||||
|
const deleteBtn = `<button class="btn btn--ghost btn--sm btn-danger" data-action="delete-entry" data-path="${Utils.escapeHtml(fullPath)}" data-entry-type="${entry.type}" title="Excluir"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button>`;
|
||||||
|
const actions = `${downloadBtn}${publishBtn}${deleteBtn}`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="files-row">
|
||||||
|
<td class="files-td-name">${nameCell}</td>
|
||||||
|
<td class="files-td-size">${size}</td>
|
||||||
|
<td class="files-td-date">${date}</td>
|
||||||
|
<td class="files-td-actions">${actions}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_fileIcon(ext) {
|
||||||
|
const map = {
|
||||||
|
js: 'file-code-2', ts: 'file-code-2', jsx: 'file-code-2', tsx: 'file-code-2',
|
||||||
|
py: 'file-code-2', rb: 'file-code-2', go: 'file-code-2', rs: 'file-code-2',
|
||||||
|
java: 'file-code-2', c: 'file-code-2', cpp: 'file-code-2', h: 'file-code-2',
|
||||||
|
html: 'file-code-2', css: 'file-code-2', scss: 'file-code-2', vue: 'file-code-2',
|
||||||
|
json: 'file-json', xml: 'file-json', yaml: 'file-json', yml: 'file-json',
|
||||||
|
md: 'file-text', txt: 'file-text', log: 'file-text', csv: 'file-text',
|
||||||
|
pdf: 'file-text',
|
||||||
|
png: 'file-image', jpg: 'file-image', jpeg: 'file-image', gif: 'file-image',
|
||||||
|
svg: 'file-image', webp: 'file-image', ico: 'file-image',
|
||||||
|
zip: 'file-archive', tar: 'file-archive', gz: 'file-archive', rar: 'file-archive',
|
||||||
|
sh: 'file-terminal', bash: 'file-terminal',
|
||||||
|
sql: 'database',
|
||||||
|
env: 'file-lock',
|
||||||
|
};
|
||||||
|
return map[ext] || 'file';
|
||||||
|
},
|
||||||
|
|
||||||
|
_formatSize(bytes) {
|
||||||
|
if (bytes == null) return '—';
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
||||||
|
},
|
||||||
|
|
||||||
|
_formatDate(isoString) {
|
||||||
|
if (!isoString) return '—';
|
||||||
|
const d = new Date(isoString);
|
||||||
|
return d.toLocaleDateString('pt-BR') + ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadFile(path) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `/api/files/download?path=${encodeURIComponent(path)}`;
|
||||||
|
a.download = '';
|
||||||
|
a.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadFolder(path) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `/api/files/download-folder?path=${encodeURIComponent(path)}`;
|
||||||
|
a.download = '';
|
||||||
|
a.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
async publishProject(path) {
|
||||||
|
const name = path.split('/').pop();
|
||||||
|
const confirmed = await Modal.confirm(
|
||||||
|
'Publicar projeto',
|
||||||
|
`Isso irá criar o repositório "${name}" no Gitea, fazer push dos arquivos e publicar em <strong>${name}.nitro-cloud.duckdns.org</strong>. Continuar?`
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Toast.info('Publicando projeto... isso pode levar alguns segundos');
|
||||||
|
const result = await API.files.publish(path);
|
||||||
|
Toast.success(`Projeto publicado com sucesso!`);
|
||||||
|
|
||||||
|
const modal = document.getElementById('execution-detail-modal-overlay');
|
||||||
|
const title = document.getElementById('execution-detail-title');
|
||||||
|
const content = document.getElementById('execution-detail-content');
|
||||||
|
if (modal && title && content) {
|
||||||
|
title.textContent = 'Projeto Publicado';
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="publish-result">
|
||||||
|
<div class="publish-result-item"><strong>Repositório:</strong> <a href="${Utils.escapeHtml(result.repoUrl)}" target="_blank">${Utils.escapeHtml(result.repoUrl)}</a></div>
|
||||||
|
<div class="publish-result-item"><strong>Site:</strong> <a href="${Utils.escapeHtml(result.siteUrl)}" target="_blank">${Utils.escapeHtml(result.siteUrl)}</a></div>
|
||||||
|
<div class="publish-result-item"><strong>Status:</strong> <span class="badge badge-active">${Utils.escapeHtml(result.status)}</span></div>
|
||||||
|
${result.message ? `<div class="publish-result-item"><em>${Utils.escapeHtml(result.message)}</em></div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
Modal.open('execution-detail-modal-overlay');
|
||||||
|
}
|
||||||
|
|
||||||
|
await FilesUI.navigate(FilesUI.currentPath);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(`Erro ao publicar: ${err.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteEntry(path, entryType) {
|
||||||
|
const label = entryType === 'directory' ? 'pasta' : 'arquivo';
|
||||||
|
const name = path.split('/').pop();
|
||||||
|
const confirmed = await Modal.confirm(
|
||||||
|
`Excluir ${label}`,
|
||||||
|
`Tem certeza que deseja excluir "${name}"? Esta ação não pode ser desfeita.`
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await API.files.delete(path);
|
||||||
|
Toast.success(`${label.charAt(0).toUpperCase() + label.slice(1)} excluído`);
|
||||||
|
await FilesUI.navigate(FilesUI.currentPath);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(`Erro ao excluir: ${err.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.FilesUI = FilesUI;
|
||||||
@@ -476,6 +476,11 @@ const PipelinesUI = {
|
|||||||
|
|
||||||
if (App._pipelineDropzone) App._pipelineDropzone.reset();
|
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');
|
Modal.open('pipeline-execute-modal-overlay');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -503,7 +508,9 @@ const PipelinesUI = {
|
|||||||
contextFiles = uploadResult.files;
|
contextFiles = uploadResult.files;
|
||||||
}
|
}
|
||||||
|
|
||||||
await API.pipelines.execute(pipelineId, input, workingDirectory, contextFiles);
|
const repoName = document.getElementById('pipeline-execute-repo')?.value || '';
|
||||||
|
const repoBranch = document.getElementById('pipeline-execute-repo-branch')?.value || '';
|
||||||
|
await API.pipelines.execute(pipelineId, input, workingDirectory, contextFiles, repoName, repoBranch);
|
||||||
if (dropzone) dropzone.reset();
|
if (dropzone) dropzone.reset();
|
||||||
Modal.close('pipeline-execute-modal-overlay');
|
Modal.close('pipeline-execute-modal-overlay');
|
||||||
App.navigateTo('terminal');
|
App.navigateTo('terminal');
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const TasksUI = {
|
|||||||
<h4 class="task-card-name">${Utils.escapeHtml(task.name)}</h4>
|
<h4 class="task-card-name">${Utils.escapeHtml(task.name)}</h4>
|
||||||
<span class="badge ${categoryClass}">${Utils.escapeHtml(categoryLabel)}</span>
|
<span class="badge ${categoryClass}">${Utils.escapeHtml(categoryLabel)}</span>
|
||||||
</div>
|
</div>
|
||||||
${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>` : ''}
|
${task.description ? `<p class="task-card-description" title="${Utils.escapeHtml(task.description)}">${Utils.escapeHtml(task.description.length > 240 ? task.description.slice(0, 240) + '…' : task.description)}</p>` : ''}
|
||||||
<div class="task-card-footer">
|
<div class="task-card-footer">
|
||||||
<span class="task-card-date">
|
<span class="task-card-date">
|
||||||
<i data-lucide="calendar"></i>
|
<i data-lucide="calendar"></i>
|
||||||
|
|||||||
79
scripts/deploy.sh
Executable file
79
scripts/deploy.sh
Executable file
@@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
VPS_HOST="fred@192.168.1.151"
|
||||||
|
VPS_PORT=2222
|
||||||
|
VPS_APP_DIR="/home/fred/vps/apps/agents-orchestrator"
|
||||||
|
VPS_COMPOSE_DIR="/home/fred/vps"
|
||||||
|
SSH="ssh -p $VPS_PORT $VPS_HOST"
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[deploy]${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}[deploy]${NC} $1"; }
|
||||||
|
error() { echo -e "${RED}[deploy]${NC} $1"; }
|
||||||
|
|
||||||
|
SKIP_PUSH=false
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--skip-push) SKIP_PUSH=true ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$SKIP_PUSH" = false ]; then
|
||||||
|
info "Fazendo push para origin..."
|
||||||
|
git push origin main
|
||||||
|
info "Fazendo push para nitro..."
|
||||||
|
git push nitro main 2>/dev/null || warn "Push para nitro falhou (não crítico)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Verificando dados no VPS antes do deploy..."
|
||||||
|
DATA_FILES=$($SSH "ls -1 $VPS_APP_DIR/data/*.json 2>/dev/null | wc -l")
|
||||||
|
info "Arquivos de dados encontrados: $DATA_FILES"
|
||||||
|
|
||||||
|
if [ "$DATA_FILES" -gt 0 ]; then
|
||||||
|
info "Criando backup dos dados..."
|
||||||
|
$SSH "cp -r $VPS_APP_DIR/data $VPS_APP_DIR/data-backup-\$(date +%Y%m%d-%H%M%S)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Sincronizando código com o VPS..."
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='data' \
|
||||||
|
--exclude='data-backup-*' \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
-e "ssh -p $VPS_PORT" \
|
||||||
|
./ "$VPS_HOST:$VPS_APP_DIR/"
|
||||||
|
|
||||||
|
info "Corrigindo permissões do diretório data..."
|
||||||
|
$SSH "sudo chown -R 1000:1000 $VPS_APP_DIR/data"
|
||||||
|
|
||||||
|
info "Rebuilding container..."
|
||||||
|
$SSH "cd $VPS_COMPOSE_DIR && docker compose up -d --build agents-orchestrator 2>&1 | tail -5"
|
||||||
|
|
||||||
|
info "Verificando container..."
|
||||||
|
sleep 2
|
||||||
|
STATUS=$($SSH "docker ps --filter name=agents-orchestrator --format '{{.Status}}'")
|
||||||
|
if echo "$STATUS" | grep -q "Up"; then
|
||||||
|
info "Container rodando: $STATUS"
|
||||||
|
else
|
||||||
|
error "Container não está rodando! Status: $STATUS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DATA_AFTER=$($SSH "ls -1 $VPS_APP_DIR/data/*.json 2>/dev/null | wc -l")
|
||||||
|
info "Arquivos de dados após deploy: $DATA_AFTER"
|
||||||
|
|
||||||
|
if [ "$DATA_AFTER" -lt "$DATA_FILES" ]; then
|
||||||
|
error "ALERTA: Menos arquivos de dados após deploy! ($DATA_FILES -> $DATA_AFTER)"
|
||||||
|
error "Backup disponível em data-backup-*"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
$SSH "ls -dt $VPS_APP_DIR/data-backup-* 2>/dev/null | tail -n +4 | xargs rm -rf 2>/dev/null" || true
|
||||||
|
info "Deploy concluído com sucesso!"
|
||||||
30
server.js
30
server.js
@@ -12,6 +12,7 @@ import apiRouter, { setWsBroadcast, setWsBroadcastTo, hookRouter } from './src/r
|
|||||||
import * as manager from './src/agents/manager.js';
|
import * as manager from './src/agents/manager.js';
|
||||||
import { setGlobalBroadcast } from './src/agents/manager.js';
|
import { setGlobalBroadcast } from './src/agents/manager.js';
|
||||||
import { cancelAllExecutions } from './src/agents/executor.js';
|
import { cancelAllExecutions } from './src/agents/executor.js';
|
||||||
|
import { stopAll as stopAllSchedules } from './src/agents/scheduler.js';
|
||||||
import { flushAllStores } from './src/store/db.js';
|
import { flushAllStores } from './src/store/db.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -114,13 +115,9 @@ app.use('/hook', hookLimiter, verifyWebhookSignature, hookRouter);
|
|||||||
app.use(express.static(join(__dirname, 'public'), {
|
app.use(express.static(join(__dirname, 'public'), {
|
||||||
etag: true,
|
etag: true,
|
||||||
setHeaders(res, filePath) {
|
setHeaders(res, filePath) {
|
||||||
if (filePath.endsWith('.html')) {
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
res.setHeader('Pragma', 'no-cache');
|
||||||
res.setHeader('Pragma', 'no-cache');
|
res.setHeader('Expires', '0');
|
||||||
res.setHeader('Expires', '0');
|
|
||||||
} else {
|
|
||||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
app.use('/api', apiRouter);
|
app.use('/api', apiRouter);
|
||||||
@@ -177,6 +174,9 @@ setGlobalBroadcast(broadcast);
|
|||||||
function gracefulShutdown(signal) {
|
function gracefulShutdown(signal) {
|
||||||
console.log(`\nSinal ${signal} recebido. Encerrando servidor...`);
|
console.log(`\nSinal ${signal} recebido. Encerrando servidor...`);
|
||||||
|
|
||||||
|
stopAllSchedules();
|
||||||
|
console.log('Agendamentos parados.');
|
||||||
|
|
||||||
cancelAllExecutions();
|
cancelAllExecutions();
|
||||||
console.log('Execuções ativas canceladas.');
|
console.log('Execuções ativas canceladas.');
|
||||||
|
|
||||||
@@ -185,15 +185,23 @@ function gracefulShutdown(signal) {
|
|||||||
|
|
||||||
clearInterval(wsHeartbeat);
|
clearInterval(wsHeartbeat);
|
||||||
|
|
||||||
httpServer.close(() => {
|
for (const client of wss.clients) {
|
||||||
console.log('Servidor HTTP encerrado.');
|
client.close(1001, 'Servidor encerrando');
|
||||||
process.exit(0);
|
}
|
||||||
|
connectedClients.clear();
|
||||||
|
|
||||||
|
wss.close(() => {
|
||||||
|
console.log('WebSocket server encerrado.');
|
||||||
|
httpServer.close(() => {
|
||||||
|
console.log('Servidor HTTP encerrado.');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.error('Forçando encerramento após timeout.');
|
console.error('Forçando encerramento após timeout.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}, 10000);
|
}, 10000).unref();
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
|
|||||||
117
src/agents/git-integration.js
Normal file
117
src/agents/git-integration.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { join, basename } from 'path';
|
||||||
|
|
||||||
|
const PROJECTS_DIR = '/home/projetos';
|
||||||
|
const GITEA_URL = () => process.env.GITEA_URL || 'http://gitea:3000';
|
||||||
|
const GITEA_USER = () => process.env.GITEA_USER || 'fred';
|
||||||
|
const GITEA_PASS = () => process.env.GITEA_PASS || '';
|
||||||
|
const DOMAIN = () => process.env.DOMAIN || 'nitro-cloud.duckdns.org';
|
||||||
|
|
||||||
|
function exec(cmd, cwd) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn('sh', ['-c', cmd], {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, HOME: '/tmp', GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
let stdout = '', stderr = '';
|
||||||
|
proc.stdout.on('data', d => stdout += d);
|
||||||
|
proc.stderr.on('data', d => stderr += d);
|
||||||
|
proc.on('close', code =>
|
||||||
|
code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `exit ${code}`))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeader() {
|
||||||
|
return 'Basic ' + Buffer.from(`${GITEA_USER()}:${GITEA_PASS()}`).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function repoCloneUrl(repoName) {
|
||||||
|
return `${GITEA_URL().replace('://', `://${GITEA_USER()}:${GITEA_PASS()}@`)}/${GITEA_USER()}/${repoName}.git`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRepos() {
|
||||||
|
const url = `${GITEA_URL()}/api/v1/user/repos?limit=50&sort=updated`;
|
||||||
|
const res = await fetch(url, { headers: { Authorization: authHeader() } });
|
||||||
|
if (!res.ok) throw new Error('Erro ao listar repositórios');
|
||||||
|
const repos = await res.json();
|
||||||
|
return repos.map(r => ({
|
||||||
|
name: r.name,
|
||||||
|
fullName: r.full_name,
|
||||||
|
description: r.description || '',
|
||||||
|
defaultBranch: r.default_branch || 'main',
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
htmlUrl: r.html_url,
|
||||||
|
cloneUrl: r.clone_url,
|
||||||
|
empty: r.empty,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBranches(repoName) {
|
||||||
|
const url = `${GITEA_URL()}/api/v1/repos/${GITEA_USER()}/${repoName}/branches?limit=50`;
|
||||||
|
const res = await fetch(url, { headers: { Authorization: authHeader() } });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const branches = await res.json();
|
||||||
|
return branches.map(b => b.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cloneOrPull(repoName, branch) {
|
||||||
|
const targetDir = join(PROJECTS_DIR, repoName);
|
||||||
|
const cloneUrl = repoCloneUrl(repoName);
|
||||||
|
|
||||||
|
if (existsSync(join(targetDir, '.git'))) {
|
||||||
|
await exec(`git remote set-url origin "${cloneUrl}"`, targetDir);
|
||||||
|
await exec('git fetch origin', targetDir);
|
||||||
|
if (branch) {
|
||||||
|
try {
|
||||||
|
await exec(`git checkout ${branch}`, targetDir);
|
||||||
|
} catch {
|
||||||
|
await exec(`git checkout -b ${branch} origin/${branch}`, targetDir);
|
||||||
|
}
|
||||||
|
await exec(`git reset --hard origin/${branch}`, targetDir);
|
||||||
|
} else {
|
||||||
|
const currentBranch = await exec('git rev-parse --abbrev-ref HEAD', targetDir);
|
||||||
|
await exec(`git reset --hard origin/${currentBranch}`, targetDir);
|
||||||
|
}
|
||||||
|
return { dir: targetDir, action: 'pull' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchArg = branch ? `-b ${branch}` : '';
|
||||||
|
await exec(`git clone ${branchArg} "${cloneUrl}" "${targetDir}"`);
|
||||||
|
return { dir: targetDir, action: 'clone' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function commitAndPush(repoDir, agentName, taskSummary) {
|
||||||
|
try {
|
||||||
|
const status = await exec('git status --porcelain', repoDir);
|
||||||
|
if (!status) return { changed: false };
|
||||||
|
|
||||||
|
await exec('git add -A', repoDir);
|
||||||
|
|
||||||
|
const summary = taskSummary
|
||||||
|
? taskSummary.slice(0, 100).replace(/"/g, '\\"')
|
||||||
|
: 'Alterações automáticas';
|
||||||
|
|
||||||
|
const message = `${summary}\n\nExecutado por: ${agentName}`;
|
||||||
|
await exec(
|
||||||
|
`git -c user.name="Agents Orchestrator" -c user.email="agents@${DOMAIN()}" commit -m "${message}"`,
|
||||||
|
repoDir
|
||||||
|
);
|
||||||
|
|
||||||
|
await exec('git push origin HEAD', repoDir);
|
||||||
|
|
||||||
|
const commitHash = await exec('git rev-parse --short HEAD', repoDir);
|
||||||
|
const branch = await exec('git rev-parse --abbrev-ref HEAD', repoDir);
|
||||||
|
const repoName = basename(repoDir);
|
||||||
|
const commitUrl = `https://git.${DOMAIN()}/${GITEA_USER()}/${repoName}/commit/${commitHash}`;
|
||||||
|
|
||||||
|
return { changed: true, commitHash, branch, commitUrl, filesChanged: status.split('\n').length };
|
||||||
|
} catch (err) {
|
||||||
|
return { changed: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectDir(repoName) {
|
||||||
|
return join(PROJECTS_DIR, repoName);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { agentsStore, schedulesStore, executionsStore, notificationsStore, secre
|
|||||||
import * as executor from './executor.js';
|
import * as executor from './executor.js';
|
||||||
import * as scheduler from './scheduler.js';
|
import * as scheduler from './scheduler.js';
|
||||||
import { generateAgentReport } from '../reports/generator.js';
|
import { generateAgentReport } from '../reports/generator.js';
|
||||||
|
import * as gitIntegration from './git-integration.js';
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
model: 'claude-sonnet-4-6',
|
model: 'claude-sonnet-4-6',
|
||||||
@@ -172,9 +173,26 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
|||||||
|
|
||||||
const agentSecrets = loadAgentSecrets(agentId);
|
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(
|
const executionId = executor.execute(
|
||||||
agent.config,
|
effectiveConfig,
|
||||||
{ description: task, instructions },
|
{ description: task, instructions: effectiveInstructions },
|
||||||
{
|
{
|
||||||
onData: (parsed, execId) => {
|
onData: (parsed, execId) => {
|
||||||
if (cb) cb({ type: 'execution_output', executionId: execId, agentId, data: parsed });
|
if (cb) cb({ type: 'execution_output', executionId: execId, agentId, data: parsed });
|
||||||
@@ -233,7 +251,50 @@ export function executeTask(agentId, task, instructions, wsCallback, metadata =
|
|||||||
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
|
if (cb) cb({ type: 'report_generated', executionId: execId, agentId, reportFile: report.filename });
|
||||||
}
|
}
|
||||||
} catch (e) { console.error('[manager] Erro ao gerar relatório:', e.message); }
|
} 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 });
|
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
|
agentSecrets
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { pipelinesStore, agentsStore, executionsStore } from '../store/db.js';
|
import { pipelinesStore, agentsStore, executionsStore } from '../store/db.js';
|
||||||
import * as executor from './executor.js';
|
import * as executor from './executor.js';
|
||||||
|
import * as gitIntegration from './git-integration.js';
|
||||||
import { mem } from '../cache/index.js';
|
import { mem } from '../cache/index.js';
|
||||||
import { generatePipelineReport } from '../reports/generator.js';
|
import { generatePipelineReport } from '../reports/generator.js';
|
||||||
|
|
||||||
@@ -293,6 +294,19 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!pipelineState.canceled) {
|
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 {
|
try {
|
||||||
const updated = executionsStore.getById(historyRecord.id);
|
const updated = executionsStore.getById(historyRecord.id);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
|
|||||||
@@ -160,6 +160,13 @@ export function restoreSchedules(executeFn) {
|
|||||||
if (restored > 0) console.log(`[scheduler] ${restored} agendamento(s) restaurado(s)`);
|
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) {
|
export function on(event, listener) {
|
||||||
emitter.on(event, listener);
|
emitter.on(event, listener);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { execFile } from 'child_process';
|
import { execFile, spawn as spawnProcess } from 'child_process';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
@@ -8,11 +8,14 @@ import * as manager from '../agents/manager.js';
|
|||||||
import { tasksStore, settingsStore, executionsStore, webhooksStore, notificationsStore, secretsStore, agentVersionsStore } from '../store/db.js';
|
import { tasksStore, settingsStore, executionsStore, webhooksStore, notificationsStore, secretsStore, agentVersionsStore } from '../store/db.js';
|
||||||
import * as scheduler from '../agents/scheduler.js';
|
import * as scheduler from '../agents/scheduler.js';
|
||||||
import * as pipeline from '../agents/pipeline.js';
|
import * as pipeline from '../agents/pipeline.js';
|
||||||
|
import * as gitIntegration from '../agents/git-integration.js';
|
||||||
import { getBinPath, updateMaxConcurrent, cancelAllExecutions, getActiveExecutions } from '../agents/executor.js';
|
import { getBinPath, updateMaxConcurrent, cancelAllExecutions, getActiveExecutions } from '../agents/executor.js';
|
||||||
import { invalidateAgentMapCache } from '../agents/pipeline.js';
|
import { invalidateAgentMapCache } from '../agents/pipeline.js';
|
||||||
import { cached } from '../cache/index.js';
|
import { cached } from '../cache/index.js';
|
||||||
import { readdirSync, readFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
import { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, statSync, createReadStream, rmSync } from 'fs';
|
||||||
import { join, dirname, resolve as pathResolve, extname } from 'path';
|
import { join, dirname, resolve as pathResolve, extname, basename, relative } from 'path';
|
||||||
|
import { createGzip } from 'zlib';
|
||||||
|
import { Readable } from 'stream';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __apiDirname = dirname(fileURLToPath(import.meta.url));
|
const __apiDirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -163,14 +166,25 @@ function buildContextFilesPrompt(contextFiles) {
|
|||||||
return `\n\nArquivos de contexto anexados (leia cada um deles antes de iniciar):\n${lines.join('\n')}`;
|
return `\n\nArquivos de contexto anexados (leia cada um deles antes de iniciar):\n${lines.join('\n')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post('/agents/:id/execute', (req, res) => {
|
router.post('/agents/:id/execute', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { task, instructions, contextFiles } = req.body;
|
const { task, instructions, contextFiles, workingDirectory, repoName, repoBranch } = req.body;
|
||||||
if (!task) return res.status(400).json({ error: 'task é obrigatório' });
|
if (!task) return res.status(400).json({ error: 'task é obrigatório' });
|
||||||
const clientId = req.headers['x-client-id'] || null;
|
const clientId = req.headers['x-client-id'] || null;
|
||||||
const filesPrompt = buildContextFilesPrompt(contextFiles);
|
const filesPrompt = buildContextFilesPrompt(contextFiles);
|
||||||
const fullTask = task + filesPrompt;
|
const fullTask = task + filesPrompt;
|
||||||
const executionId = manager.executeTask(req.params.id, fullTask, instructions, (msg) => wsCallback(msg, clientId));
|
const metadata = {};
|
||||||
|
|
||||||
|
if (repoName) {
|
||||||
|
const syncResult = await gitIntegration.cloneOrPull(repoName, repoBranch || null);
|
||||||
|
metadata.workingDirectoryOverride = syncResult.dir;
|
||||||
|
metadata.repoName = repoName;
|
||||||
|
metadata.repoBranch = repoBranch || null;
|
||||||
|
} else if (workingDirectory) {
|
||||||
|
metadata.workingDirectoryOverride = workingDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionId = manager.executeTask(req.params.id, fullTask, instructions, (msg) => wsCallback(msg, clientId), metadata);
|
||||||
res.status(202).json({ executionId, status: 'started' });
|
res.status(202).json({ executionId, status: 'started' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = err.message.includes('não encontrado') ? 404 : 400;
|
const status = err.message.includes('não encontrado') ? 404 : 400;
|
||||||
@@ -460,11 +474,20 @@ router.delete('/pipelines/:id', (req, res) => {
|
|||||||
|
|
||||||
router.post('/pipelines/:id/execute', async (req, res) => {
|
router.post('/pipelines/:id/execute', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { input, workingDirectory, contextFiles } = req.body;
|
const { input, workingDirectory, contextFiles, repoName, repoBranch } = req.body;
|
||||||
if (!input) return res.status(400).json({ error: 'input é obrigatório' });
|
if (!input) return res.status(400).json({ error: 'input é obrigatório' });
|
||||||
const clientId = req.headers['x-client-id'] || null;
|
const clientId = req.headers['x-client-id'] || null;
|
||||||
const options = {};
|
const options = {};
|
||||||
if (workingDirectory) options.workingDirectory = workingDirectory;
|
|
||||||
|
if (repoName) {
|
||||||
|
const syncResult = await gitIntegration.cloneOrPull(repoName, repoBranch || null);
|
||||||
|
options.workingDirectory = syncResult.dir;
|
||||||
|
options.repoName = repoName;
|
||||||
|
options.repoBranch = repoBranch || null;
|
||||||
|
} else if (workingDirectory) {
|
||||||
|
options.workingDirectory = workingDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
const filesPrompt = buildContextFilesPrompt(contextFiles);
|
const filesPrompt = buildContextFilesPrompt(contextFiles);
|
||||||
const fullInput = input + filesPrompt;
|
const fullInput = input + filesPrompt;
|
||||||
const result = pipeline.executePipeline(req.params.id, fullInput, (msg) => wsCallback(msg, clientId), options);
|
const result = pipeline.executePipeline(req.params.id, fullInput, (msg) => wsCallback(msg, clientId), options);
|
||||||
@@ -1037,4 +1060,282 @@ 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;
|
export default router;
|
||||||
|
|||||||
Reference in New Issue
Block a user