Documentação do Broker v1

Fila de integração assíncrona com múltiplos endpoints externos — PostgreSQL + pg-boss, circuit breaker, rate limiting e escalonamento horizontal de workers com atribuição por fila.

Atualizada junto com o código — ver Sobre esta documentação

O que é o broker

O broker é um serviço de integração assíncrona baseada em fila: eventos são enfileirados no Postgres (via pg-boss, sem Redis/RabbitMQ/Kafka) e processados por um ou mais processos worker, que chamam uma API externa por HTTP, com retry/backoff nativos, circuit breaker e rate limiting próprios.

Desde a versão atual, o broker suporta múltiplos endpoints externos ao mesmo tempo — cada integração tem sua própria fila, credencial, circuit breaker e rate limiter, isolados uns dos outros. Um dashboard web dá visão em tempo real (SSE) de todas as filas, permite criar/editar endpoints, escalar workers e atribuir filas específicas a workers específicos.

Princípios de arquitetura

  • Postgres como única fonte de verdade. Sem infraestrutura extra — a fila, o estado de circuit breaker, a configuração de endpoints e os logs estruturados moram todos no mesmo Postgres.
  • Retry/backoff 100% nativos do pg-boss. O código da aplicação nunca reimplementa retry — só classifica erros (permanente vs. transitório) para decidir o que conta pro circuit breaker.
  • Isolamento por endpoint. Cada integração externa tem fila, client HTTP, circuit breaker e rate limiter próprios — uma API externa degradada nunca consome a capacidade de processamento de outra.
  • Live-reconfig sem restart. Endpoints, parâmetros, atribuição de filas por worker — tudo é lido de tabelas do Postgres e aplicado nos próximos ciclos de poll do worker, sem precisar reiniciar nada.
  • Observabilidade em primeiro lugar. Todo estado relevante (fila, circuit breaker, workers vivos) é visível no dashboard em tempo real via Server-Sent Events.

Arquitetura de ambiente

Em desenvolvimento/produção local, tudo roda via docker-compose, numa rede privada própria (broker_default), com um Postgres dedicado (não compartilhado com outras aplicações).

host (seu Mac / servidor) rede docker (bridge): broker_default — DNS interno por nome de serviço SQL/HTTP normal controle (DNS round-robin) só em dev postgres postgres:16-alpine · 1 réplica porta interna 5432 host: POSTGRES_PORT (5544) healthcheck: pg_isready 5s vol: broker_pgdata única fonte de verdade da app worker 1..N réplicas (--scale worker=N) WORKER_CONTROL_PORT (8081) interno — sem publish no host depends_on: postgres (healthy) vol: broker_logs (compartilhado) heartbeat em app_worker_instances mock-api dev only porta MOCK_API_PORT host: 8090 depends_on: postgres (healthy) docker-compose.dev.yml recusa subir se NODE_ENV=production dashboard 1 instância (sem scale) porta DASHBOARD_PORT host: DASHBOARD_PORT (8080) depends_on: postgres (healthy) vol: broker_logs (compartilhado) único ponto publicado p/ o usuário SQL HTTP (dev) WORKER_CONTROL_URL=http://worker:8081 (DNS round-robin entre réplicas) dashboard lê pgboss.job / app_* via SQL (somente leitura) seu navegador http://localhost:8080 HTTP + Server-Sent Events (SSE) API externa (produção) fora do docker-compose, na internet HTTPS, 1 credencial por endpoint ⚠ nuance de rede: WORKER_CONTROL_URL e DNS round-robin Com N réplicas de worker, "worker" resolve para UMA réplica aleatória a cada conexão — o dashboard nunca enxerga todas as N réplicas por esse canal. É por isso que existe o registro paralelo em Postgres (app_worker_instances, heartbeat a cada 5s) — a aba Arquitetura lista as réplicas vivas a partir do banco, não do WORKER_CONTROL_URL. portas publicadas no host: POSTGRES_PORT (padrão 5544) · DASHBOARD_PORT (8080) · MOCK_API_PORT (8090, dev only)
Fig. 1 — Topologia de ambiente: 4 serviços na rede broker_default, o navegador e a API externa ficam fora dela, e o motivo pelo qual o dashboard mantém um registro próprio de instâncias de worker em vez de confiar só no WORKER_CONTROL_URL.

Serviços do docker-compose.yml

ServiçoImagem/BuildRéplicasPorta publicadaVolume
postgrespostgres:16-alpine1 (fixo)POSTGRES_PORT → 5432broker_pgdata
workerdocker/Dockerfile.worker1..N (--scale worker=N)nenhuma (control server é interno)broker_logs
dashboarddocker/Dockerfile.dashboard1DASHBOARD_PORT → 8080broker_logs
mock-api dev onlydocker/Dockerfile.mock-api1MOCK_API_PORT → 8090—
mock-api só existe no overlay docker-compose.dev.yml e recusa iniciar se NODE_ENV=production — nunca sobe junto com o compose principal por acidente.

Subindo o ambiente completo

# stack de producao/uso normal (postgres + worker + dashboard)
docker-compose up -d --build

# + laboratorio de testes (mock-api)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build

# escalando workers horizontalmente
docker-compose up -d --scale worker=3

Arquitetura de componentes

Fluxo de dados entre os componentes lógicos da aplicação (independente de estarem em containers separados ou não):

Navegador SSE + REST Dashboard servidor HTTP + push SSE consultas SOMENTE LEITURA exceto CRUD de app_* proprias PostgreSQL schema pgboss (gerido pelo pg-boss) pgboss.job (1 fila por endpoint) tabelas app_* (proprias) app_endpoints · app_config app_worker_instances app_circuit_breaker_state · app_logs Worker A N assinaturas isoladas Worker B SKIP LOCKED, sem duplicar Runtime isolado por endpoint (dentro de cada worker) ExternalApiClient baseUrl, auth, timeout CircuitBreaker CLOSED/OPEN/ HALF_OPEN RateLimiter janela deslizante AlertLoop backlog, jobs travados API Externa HTTP, credencial por endpoint
Fig. 2 — Fluxo de dados: navegador consulta o dashboard (leitura), pg-boss distribui jobs entre workers via SKIP LOCKED, cada endpoint tem um runtime totalmente isolado dentro de cada worker.

Worker por dentro (src/worker/)

ArquivoResponsabilidade
index.jsLoop principal: sobe um runtime por endpoint habilitado, reconcilia jobs órfãos no boot, faz poll periódico de app_endpoints/atribuição de filas, heartbeat de instância, control server.
processor.jsProcessa um job: aplica rate limit, checa circuit breaker, chama a API externa, classifica erro (4xx permanente vs. 5xx/timeout transitório).
circuitBreaker.jsMáquina de estados CLOSED/OPEN/HALF_OPEN em memória, por endpoint.
rateLimiter.jsJanela deslizante de chamadas/segundo, por endpoint.
concurrencyManager.jsGerencia quantas assinaturas boss.work() ficam ativas, sem nunca ultrapassar o configurado durante uma mudança.
instanceRegistry.jsHeartbeat da instância + atribuição de filas (app_worker_instances).
controlServer.jsHTTP interno (pause/resume/status) usado pelo dashboard e pela console CLI.
alerts.jsAlerta (log + webhook opcional) para backlog e jobs travados.

Dashboard por dentro (src/dashboard/)

  • server.js — servidor HTTP com todas as rotas /api/*, SSE (/api/events), e serve os arquivos estáticos de public/ (incluindo esta página).
  • queries.js — todas as consultas SQL de leitura em pgboss.job e tabelas auxiliares.
  • public/index.html — SPA de uma página só (sem build step, sem framework), com todas as abas.

Tabelas do Postgres

TabelaSchemaDonoConteúdo
jobpgbosspg-bossFila real — um registro por job, uma fila (name) por endpoint.
schedule, subscription, versionpgbosspg-bossInternos do pg-boss — só leitura na aba Tabelas.
app_endpointspúblicobrokerConfiguração de cada integração externa (URL, credencial, limites).
app_worker_instancespúblicobrokerHeartbeat + atribuição de filas de cada processo worker vivo.
app_circuit_breaker_statepúblicobrokerÚltimo estado publicado do circuit breaker de cada fila.
app_configpúblicobrokerParâmetros globais editáveis (alertas, intervalos do dashboard).
app_logspúblicobrokerLog estruturado (JSON) de worker e dashboard, fonte principal da aba Logs.

Múltiplos endpoints

O broker integra com N endpoints externos ao mesmo tempo, cada um com sua própria fila pg-boss (nomeada ${QUEUE_NAME}.<endpoint-id>), credencial, circuit breaker, rate limiter e política de retry.

Decisão de arquitetura: uma fila por endpoint, não uma fila única com roteamento por campo no payload. O circuit breaker e o rate limiter existem para proteger uma chamada de rede específica — uma fila compartilhada deixaria um endpoint degradado consumir a capacidade de outro saudável.

Limitação conhecida: tipos de credencial

O client HTTP manda um header estático por chamada — cobre API key, Bearer token e HTTP Basic (pré-codificado). Não cobre esquemas que exigem assinar o corpo por requisição (ex: HMAC) sem uma extensão pontual do client.

Escalonamento e workers

Duas dimensões de escala

  1. Vertical — aumentar concurrency de um endpoint (ao vivo, aba Admin).
  2. Horizontal — múltiplas réplicas do processo worker (docker-compose --scale worker=N, scripts/run-local.sh worker=N, ou o botão + na aba Arquitetura).
rateLimitMaxCalls e o circuit breaker são em memória, por processo — com N réplicas na mesma fila, o volume real contra a API externa pode chegar a rateLimitMaxCalls × N. Divida o limite pelo número de réplicas planejadas.

Dedicar um worker a filas específicas

Por padrão todo worker consome todas as filas habilitadas. Pelo ícone de lápis (✎) no card de cada instância (aba Arquitetura), dá para desmarcar "Todas as filas" e escolher um subconjunto — o worker deixa de consumir as demais imediatamente (a atribuição é lida a cada ciclo de poll, sem restart), sem afetar circuit breaker/rate limiter das filas onde ele continua atuando, nem o consumo de outros workers.

A tabela "Todos os Endpoints" mostra quantos workers estão atribuídos a cada fila, com aviso se for 0 (fila parada) ou >1 (rate limit/circuit breaker multiplicado).

Instalação

Pré-requisitos

  • Docker + Docker Compose (recomendado) ou Node.js 20+ para rodar nativo.
  • Nenhum outro serviço já usando as portas escolhidas (por padrão 5544, 8080, 8090 — ajustável no .env).

Passo a passo (Docker — recomendado)

git clone <repo> broker && cd broker
cp .env.example .env
# edite .env: senha do Postgres, credenciais de cada endpoint, etc.

docker-compose up -d --build
# abre o dashboard em http://localhost:8080

Rodando nativo (sem Docker)

npm install
cp .env.example .env
cp .env.example .env.local   # ajuste DATABASE_URL/EXTERNAL_API_URL para "localhost"

scripts/run-local.sh              # 1 worker + dashboard
scripts/run-local.sh worker=3     # 3 workers nativos
scripts/stop-local.sh             # para tudo (nativo E docker-compose, se estiver rodando)
Os hostnames do .env.example (postgres, mock-api) só resolvem dentro da rede do docker-compose. Rodando nativo, use .env.local (git-ignorado) com localhost — run-local.sh detecta e avisa se isso não estiver configurado.

Criação do banco de dados

Não existe um script de migração para rodar manualmente — o schema é self-provisioning. Tanto o schema do pg-boss quanto as tabelas próprias da aplicação são criados sozinhos, automaticamente, na primeira vez que um processo sobe:

  • Schema pgboss (fila, agendamentos, versões) — criado pelo próprio pg-boss dentro de boss.start(), chamado no boot do worker, do dashboard e do console de controle. Não é código da aplicação.
  • Tabelas app_* (endpoints, config, workers, circuit breaker, logs) — cada módulo chama seu próprio ensure<X>Table() no boot, usando o helper createTableIfNotExists() de src/config/db.js (CREATE TABLE IF NOT EXISTS, com proteção contra a corrida de duas sessões criando a mesma tabela ao mesmo tempo).

Ou seja: o "processo de criar o banco" é simplesmente subir a aplicação pela primeira vez — não existe npm run migrate nem SQL para rodar à mão. O que precisa existir antes disso é só a role e o database vazios:

Via Docker (automático)

A imagem oficial postgres:16-alpine já cria a role e o database sozinha no primeiro boot do container, usando as variáveis do .env:

POSTGRES_USER=broker
POSTGRES_PASSWORD=change-me
POSTGRES_DB=broker_queue

Só acontece se o volume broker_pgdata estiver vazio — depois da primeira vez, mudar essas variáveis não recria nada.

Postgres nativo/externo (manual)

Sem o Docker cuidando disso, crie a role e o database uma vez, antes do primeiro npm run worker/dashboard:

psql -U postgres -c "CREATE ROLE broker WITH LOGIN PASSWORD 'change-me';"
psql -U postgres -c "CREATE DATABASE broker_queue OWNER broker;"

Aponte DATABASE_URL em .env.local para esse database — o resto (schemas e tabelas) se cria sozinho no boot.

É seguro subir worker e dashboard ao mesmo tempo (ou várias réplicas de worker) logo na primeira vez — o createTableIfNotExists() existe justamente para resolver a corrida de duas sessões tentando criar a mesma tabela simultaneamente (Postgres erro 23505), então não precisa subir um processo "primeiro" para preparar o banco.

Verificando a instalação

  1. docker-compose ps — todos os serviços Up e postgres healthy.
  2. Abrir http://localhost:8080 — deve carregar a tela "Todos os Endpoints".
  3. Popular jobs de teste: docker-compose exec worker node scripts/seed.js 10.
  4. Confirmar no dashboard que os jobs aparecem e completam.

Manual de configuração

Duas camadas de configuração: variáveis de ambiente (infra/boot) e configuração dinâmica no Postgres, editável ao vivo pelo dashboard sem reiniciar nada.

Endpoints (por integração externa)

Cada endpoint é um registro em app_endpoints, gerenciado 100% pelo dashboard (aba Parâmetros) ou pela API:

CampoDescrição
idSlug único (minúsculo, hifens) — vira o sufixo da fila.
labelNome amigável exibido no dashboard.
baseUrlURL HTTP (POST) da API externa.
authHeaderName / authHeaderValueHeader estático de autenticação (API key, Bearer, Basic pré-codificado). O valor nunca é reexibido depois de salvo.
timeoutMsTimeout da chamada HTTP.
concurrencyAssinaturas boss.work() paralelas por processo worker.
rateLimitMaxCalls / rateLimitWindowMsLimite de chamadas à API externa, por processo.
circuitBreakerFailureThreshold / WindowMs / OpenMsLimiares do circuit breaker.
retryLimit / retryDelay / retryBackoffRetry nativo do pg-boss — só vale para jobs enviados depois da mudança.
enabledDesativa o consumo sem apagar a configuração.
GET    /api/endpoints
POST   /api/endpoints            { id, label, baseUrl, authHeaderName, authHeaderValue, ... }
PUT    /api/endpoints/:id        (parcial - so os campos enviados sao atualizados)
DELETE /api/endpoints/:id        (remove so a config; nao apaga jobs ja enfileirados)

Parâmetros globais

Editáveis na aba Parâmetros, aplicados ao vivo (releitura a cada workerConfigPollMs):

GrupoCampos
AlertasalertPendingJobsThreshold, alertStuckActiveMinutes, alertCheckIntervalMs
DashboarddashboardRefreshMs (push SSE), workerConfigPollMs (releitura pelo worker)

Variáveis de ambiente (.env)

Lista completa e comentada em .env.example. Principais grupos:

  • Postgres: POSTGRES_USER/PASSWORD/DB/PORT, DATABASE_URL
  • pg-boss: QUEUE_NAME, PGBOSS_EXPIRE_IN_SECONDS, PGBOSS_MAINTENANCE_INTERVAL_SECONDS
  • Dashboard: DASHBOARD_PORT
  • Worker: WORKER_CONTROL_PORT, WORKER_CONTROL_URL
  • Laboratório de testes (dev): MOCK_API_*

Como usar: enviando dados para a fila

Existem duas formas de enfileirar um evento. Para qualquer sistema externo (de qualquer linguagem), use a API HTTP de ingestão — é o caminho recomendado. Se o produtor já é um processo Node.js rodando dentro da mesma infraestrutura do broker, também é possível usar o cliente pg-boss diretamente (biblioteca), sem passar pela rede.

API HTTP de ingestão

POST /api/ingest/:endpointId, servida pelo mesmo processo do dashboard. O corpo da requisição é exatamente o JSON que o worker vai enviar (via POST) para o baseUrl configurado nesse endpoint — o broker não adiciona nem remove campos.

Passo a passo

  1. Cadastre o endpoint de destino (aba Admin ou POST /api/endpoints) — é o id desse registro que vira :endpointId na URL.
  2. Na mesma aba, clique em Gerar chave na coluna "Ingestão HTTP" desse endpoint. A chave aparece uma única vez — copie na hora.
  3. Chame a API mandando essa chave no header X-Ingest-Key.

Contrato

ItemValor
Método/URLPOST /api/ingest/:endpointId
Headers obrigatóriosContent-Type: application/json, X-Ingest-Key: <chave>
CorpoObjeto JSON qualquer (não pode ser array/string/número na raiz) — vira o payload enviado à API externa.
Sucesso202 Accepted — { "jobId": "...", "queueName": "..." }
StatusQuandocode
400corpo não é JSON válido, ou não é um objeto na raizinvalid_json / invalid_payload_shape
401X-Ingest-Key ausente ou incorretainvalid_ingest_key
403endpoint desativado, ou sem chave de ingestão gerada aindaendpoint_disabled / ingest_not_configured
404endpointId não existeendpoint_not_found
413corpo maior que MAX_INGEST_BODY_BYTES (padrão 256KB)payload_too_large
415Content-Type não é application/jsonunsupported_media_type

Exemplo

curl -X POST https://seu-dominio/api/ingest/orders \
  -H "Content-Type: application/json" \
  -H "X-Ingest-Key: <chave gerada na aba Parametros>" \
  -d '{"orderId": "A-1029", "status": "paid", "total": 149.9}'

# 202 Accepted
# {"jobId":"9f2c...","queueName":"external-api-events.orders"}

Funciona de qualquer linguagem — só precisa fazer um POST com os dois headers. Em Python, por exemplo:

import requests

requests.post(
    "https://seu-dominio/api/ingest/orders",
    headers={"X-Ingest-Key": "..."},
    json={"orderId": "A-1029", "status": "paid", "total": 149.9},
)
Sem proxy TLS na frente, não exponha essa rota fora da rede confiável. O dashboard fala HTTP puro — a X-Ingest-Key viajaria em texto claro pela internet. Termine TLS num proxy reverso (Caddy, nginx) antes de aceitar tráfego de produtores externos de verdade; o Node não deveria terminar TLS ele mesmo.
O retry/backoff aplicado é sempre o configurado para esse endpoint (retryLimit/retryDelay/retryBackoff da aba Parâmetros) — ao contrário do uso via biblioteca pg-boss abaixo, aqui não tem como esquecer de passar essas opções.
Não há idempotência/deduplicação automática. Cada chamada aceita cria um job novo, mesmo que o corpo seja idêntico a uma chamada anterior — o produtor é quem precisa evitar reenviar o mesmo evento duas vezes (ex: em um retry de timeout HTTP).

Alternativa avançada: biblioteca pg-boss direto (só Node.js)

Se o produtor já é Node.js e prefere não depender de rede/HTTP (ex: outro processo dentro da mesma infraestrutura, com acesso direto ao Postgres do broker), dá para inserir o job direto na fila com o cliente pg-boss — exatamente como scripts/seed.js e scripts/console.js já fazem. Essa via não passa pela autenticação/limite de tamanho da API HTTP (é o mesmo nível de confiança que o próprio worker).

import pg from 'pg';
import { env } from './src/config/env.js';
import { createBoss } from './src/config/pgboss.js';
import { queueNameFor, listEndpoints, buildEndpointSendOptions } from './src/config/endpoints.js';

const ENDPOINT_ID = 'orders'; // id cadastrado na aba Parametros

const pool = new pg.Pool({ connectionString: env.databaseUrl });
const [endpoint] = await listEndpoints(pool, { onlyEnabled: true })
  .then((rows) => rows.filter((e) => e.id === ENDPOINT_ID));
await pool.end();
if (!endpoint) throw new Error(`endpoint "${ENDPOINT_ID}" nao encontrado ou desativado`);

const boss = createBoss();
await boss.start();

const jobId = await boss.send(
  queueNameFor(endpoint.id),
  { orderId: 'A-1029', status: 'paid', total: 149.9 },
  buildEndpointSendOptions(endpoint), // aplica o retryLimit/retryDelay/backoff DESSE endpoint
);

console.log(`job ${jobId} enfileirado na fila ${queueNameFor(endpoint.id)}`);
await boss.stop();
Se você não passar retryLimit/retryDelay/retryBackoff no boss.send(), o pg-boss usa os defaults dele — não os valores configurados para o endpoint no dashboard. Use buildEndpointSendOptions() (ou copie os mesmos valores) para que o retry realmente siga a política daquele endpoint.

Testando sem uma API real

Para popular filas com dados fake e validar o fluxo de ponta a ponta antes de conectar um produtor de verdade, use o script pronto (ver Laboratório de testes):

# popula TODOS os endpoints habilitados com 20 jobs cada
node scripts/seed.js 20

# popula so um endpoint especifico
node scripts/seed.js 20 orders

Laboratório de testes (mock-api)

Serviço à parte, só Node core, com 5 endpoints — um por tipo de credencial — que sorteiam sucesso/erro para exercitar circuit breaker, retry e rate limit sem depender de internet.

EndpointCredencial
POST /webhook/api-keyheader X-Api-Key
POST /webhook/bearerAuthorization: Bearer
POST /webhook/basicHTTP Basic
POST /webhook/hmac-signatureHMAC do corpo — referência, não testável pelo client real
POST /webhook/publicnenhuma

⚠️ Nunca sobe em produção — só existe no overlay docker-compose.dev.yml.

Guia do dashboard

Tour pelas abas do dashboard, com screenshots reais da instância local. O ícone de sol/lua no cabeçalho alterna entre tema claro e escuro (preferência salva no navegador, detecta o tema do sistema operacional na primeira visita) — as capturas abaixo estão no tema escuro (padrão), mas toda a interface tem equivalente claro.

Todos os Endpoints (tela inicial)

Visão agregada de todas as integrações lado a lado: diagrama (Worker → cada endpoint), totais consolidados, e uma tabela com circuit breaker, contagem por estado, volume e latência de cada fila — inclusive quantos workers estão atribuídos a cada uma.

Tela Todos os Endpoints
Fig. 3 — Tela "Todos os Endpoints": diagrama de workers e volumetria consolidada.

Overview (por endpoint)

Escopada pelo seletor de endpoint no topo: contagem por estado, circuit breaker, backlog, taxa de erro, throughput com seletor de período, gráficos de completados x falhados e criados x resolvidos, distribuição de retries, diagnóstico automático de gargalo (fila vs. API externa vs. rate limit).

Tela Overview
Fig. 4 — Overview de um endpoint específico, com diagnóstico de gargalo.

Jobs

Grid paginada com todos os jobs de qualquer estado. Filtro de Fila independente do seletor do topo (endpoint selecionado, todas as filas juntas, ou uma específica), filtro por estado e busca no conteúdo. Jobs failed podem ser reprocessados; created/retry podem ser editados antes de rodar.

Tela Jobs
Fig. 5 — Grid de jobs com payload e output completos.

Tabelas

Browser genérico das tabelas internas do pg-boss e das tabelas próprias da aplicação. Tabelas com identidade única têm checkbox de seleção e os botões Excluir selecionados (protegido — nunca apaga jobs pendentes/em processamento) e Limpar tabela inteira (sem exceção).

Tela Tabelas
Fig. 6 — Browser de tabelas com exclusão seletiva.

Parâmetros

Edição em tempo real dos parâmetros globais (alertas, refresh do dashboard) e a seção de manutenção para limpar dados de teste. Gestão de endpoints, usuários e o envio de carga de teste ficaram numa aba própria — ver Admin — visível só para administradores.

Tela Parametros
Fig. 7 — Parâmetros globais e manutenção (esta captura é anterior à separação da aba Admin).

Admin só administradores

Aba inteira invisível para o perfil user (nem aparece na barra de abas) — ver Autenticação e controle de acesso. Reúne tudo que é gestão de acesso/provisionamento, separado da aba Parâmetros (que ficou só com ajuste fino). Dividida em 4 sub-seções (pills no topo da aba):

Endpoints

O mesmo CRUD que antes vivia em Parâmetros (criar, editar, ativar/desativar, excluir, gerar/regenerar chave de ingestão).

Usuários

Criar/excluir usuários do dashboard e definir o perfil (admin/user).

Laboratório

Duas ferramentas para testar o fluxo de ponta a ponta sem depender de uma API externa real:

  • Mock-API — botão Subir mock-api/Encerrar mock-api que sobe/derruba o processo src/mock-api/server.js direto do dashboard (mesmo mecanismo de child_process.spawn já usado para escalar workers pela aba Arquitetura) — não depende mais do overlay docker-compose.dev.yml para ligar/desligar, e funciona também em produção (restrito a admin) — o processo escuta em todas as interfaces, mas fica inacessível de fora pelo firewall/isolamento de rede do host, então dá pra manter ligado como um "endpoint de eco" real, útil pra exercitar circuit breaker/retry sem depender de terceiro. Botão Cadastrar endpoints de exemplo cria (via upsert, idempotente) os 5 endpoints de demonstração apontando pro mock-api, um por tipo de credencial — poupa criar cada um manualmente na aba Endpoints.
  • Enviar carga de teste — checklist com todos os endpoints cadastrados (nome + status ativo/desativado, seleção múltipla, atalhos "todos"/ "nenhum"), um campo de quantidade (padrão 10, editável), botão Enviar, e ao lado um contador ao vivo ("Na fila agora") somando os jobs pendentes (created + retry) só dos endpoints marcados — atualizado via SSE, sem polling próprio. Equivale a POST /api/admin/seed com { count, endpointIds }, que devolve separadamente o que foi enfileirado (seeded) e o que foi ignorado (skipped, com o motivo) — um endpoint desativado não derruba o envio dos demais.

Agente de IA

Cadastro de qual LLM o chat flutuante vai usar — ver a seção dedicada abaixo.

Agente de IA (chat flutuante)

Um chat flutuante (ícone no canto inferior direito, disponível em qualquer aba, para admin e user logados) que responde perguntas sobre o ambiente e executa ações reais via linguagem natural — usando SOMENTE um conjunto curado de tools, nunca SQL ou shell livre.

Vários modelos ao mesmo tempo, com failover por prioridade

Aba Admin → "Agente de IA" → CRUD simples: cadastre quantos modelos quiser, de qualquer combinação dos 7 provedores, cada um com uma prioridade (número menor = tentado primeiro) e um interruptor habilitado — só as linhas habilitadas entram na lista de failover, as demais ficam cadastradas mas fora de uso (botão Ativar/Desativar direto na lista, sem precisar excluir). Botão Testar conexão faz uma chamada mínima (sem tools, sem histórico) só pra validar credencial/URL — funciona mesmo numa config desativada, útil pra testar antes de habilitar de vez.

ProvedorCampos
Claude (Anthropic)chave de API, modelo (ex: claude-sonnet-5)
OpenAIchave de API, modelo (ex: gpt-4o)
Azure OpenAIchave de API, base URL do recurso, deployment, api-version
DeepSeekchave de API, modelo
Grok (xAI)chave de API, modelo
OpenAI (Responses API)chave de API, modelo
Azure OpenAI (Responses API)chave de API, base URL do recurso, deployment, api-version — obrigatório para deployments GPT-5.x, que não expõem Chat Completions
Três famílias reais de contrato, não sete adapters: Anthropic (Messages API, tool_use/tool_result aninhados numa mensagem), "OpenAI-compat" (Chat Completions — OpenAI, Azure OpenAI, DeepSeek, Grok) e Responses API (OpenAI e Azure OpenAI — input é uma lista plana de itens irmãos message/function_call/function_call_output, sem aninhamento por role). No Azure, a URL da Responses API não leva o deployment no path — ele vai no corpo da requisição; erro clássico é colar a URL completa do "Responses playground" do Azure (com /openai/responses?api-version=... já incluído) no campo "Base URL", que deve conter só a raiz do recurso (ex: https://seu-recurso.openai.azure.com).

"Qual o melhor pra usar" é resolvido de forma 100% determinística, sem heurística nem uma segunda chamada de LLM só pra decidir: o agente sempre tenta o modelo de maior prioridade primeiro; só cai pro próximo habilitado se a chamada falhar de um jeito que justifique trocar (instabilidade do provedor — 5xx/429/timeout — ou credencial inválida daquela config específica — 401/403). Um erro de contrato (ex: schema de tool malformado, 400) não ativa failover — outro provedor tende a falhar do mesmo jeito, ou pior, aceitar silenciosamente algo que não devia.

Failover nunca acontece no meio de um turno com uma chamada de tool pendente de resposta — um tool_use gerado pelo provedor A não tem sentido pro provedor B (formato/id são específicos de quem gerou). Se a falha ocorre no meio do loop, a tentativa inteira daquele provedor é descartada (nunca persistida) e o turno recomeça do zero, só com a mensagem original do usuário, no próximo provedor da lista.
Toda chamada a um provedor tem um timeout de 45s (AbortController) — sem isso, um provedor que trava sem responder e sem fechar a conexão deixaria o turno inteiro pendurado indefinidamente, sem acionar failover nenhum (achado real durante os testes desta funcionalidade, corrigido antes de virar problema em produção).
O histórico carregado por turno é limitado às últimas 40 mensagens (janela de contexto) — mas esse corte é feito por contagem de linhas, sem saber nada sobre pares assistant(tool_calls) → tool(resultados). Numa conversa longa o suficiente, o corte pode cair bem no meio de um desses pares, deixando a janela começar com uma mensagem tool "órfã" — que provedores como a OpenAI rejeitam ("messages with role 'tool' must be a response to a preceeding message with 'tool_calls'", achado real em produção). Corrigido ajustando a janela pro primeiro limite de turno limpo (a mensagem user mais próxima), em vez de confiar que o corte por contagem caiu num lugar seguro.

A chave de API nunca é reexibida (mesmo padrão de authHeaderValue/senha de usuário) — campo em branco ao editar mantém a chave atual. Trocar/adicionar/remover modelos não quebra conversas em andamento — o histórico é guardado em formato neutro, nunca no formato nativo de um provedor específico.

Instruções permanentes (o agente "aprendendo" sobre seu ambiente)

Aba Admin → "Agente de IA" → "Instruções permanentes do agente" — textos curtos (título + conteúdo) injetados no system prompt de toda chamada, pra ele ir ficando mais afiado sobre particularidades do seu ambiente (ex: "o endpoint orders representa pedidos do site X", "sempre confirme antes de pausar uma fila"). Cadastrar uma instrução com o mesmo título de uma existente substitui o conteúdo (upsert) — corrigir uma instrução antiga nunca deixa duas versões conflitantes convivendo no prompt.

Escrita restrita a admin, deliberadamente — o agente não tem uma tool tipo remember() pra anotar algo sozinho durante a conversa. Ele já lê dados de jobs/logs (potencialmente originados de APIs externas fora do seu controle) dentro do mesmo loop de tool-calling; se pudesse "aprender" sozinho, um payload malicioso poderia plantar uma instrução permanente que afeta todos os usuários daí pra frente — prompt injection com efeito duradouro, não só na resposta de um turno.

Todas as instruções cadastradas são injetadas (não só as N mais recentes), até um teto de 3000 caracteres — corta as mais antigas primeiro se estourar. Sem busca semântica nem ranking: pra um punhado de instruções curtas escritas por um admin, um orçamento de caracteres é mais previsível e mais fácil de auditar do que qualquer heurística de relevância.

Duas famílias de integração, não cinco adapters

OpenAI, Azure OpenAI, DeepSeek e Grok são Chat Completions (tools/ tool_calls) — reaproveitam o mesmo adapter, parametrizado só por URL/headers de transporte (Azure muda pra {endpoint}/openai/deployments/{deployment}/chat/completions ?api-version=... com header api-key em vez de Authorization: Bearer). Claude é a Messages API da Anthropic (tool_use/tool_result em content blocks) — um segundo adapter, genuinamente diferente no formato de mensagem.

Memória: uma conversa contínua por usuário

Sem conceito de "conversas" plurais (tipo abas do ChatGPT) — alinhado com o resto do dashboard, que já usa sessão única por login. Histórico persistido em Postgres (app_chat_messages), cortado nas últimas ~40 mensagens ao montar o prompt. Botão lixeira no cabeçalho do chat apaga todo o histórico daquele usuário (POST /api/assistant/clear) — sem confirmação não tem como desfazer.

Execução: tools curadas, nunca acesso livre

O modelo só enxerga um conjunto fixo de funções:

  • Consulta (qualquer usuário logado): overview de endpoints, jobs, logs (worker/dashboard), lista de workers, diagnóstico de gargalo (espera na fila vs. tempo de chamada à API externa vs. saturação de concorrência — mesma análise já usada na aba Overview), throughput ao longo do tempo, distribuição de retries, e saúde do host/processo (carga de CPU, memória livre, pool de conexões do Postgres — útil quando a lentidão não está ligada a um endpoint específico).
  • Ação (exigem admin): pausar/retomar fila, popular fila de teste, CRUD de endpoint, CRUD de usuário, subir/encerrar instância de worker (add_worker/remove_worker — mesma ação do botão +/− da aba Arquitetura), update_endpoint estendido (também corrige baseUrl, credencial, e limites de rate limit/circuit breaker/retry, não só ativar/desativar) e reprocess_jobs (reenfileira jobs existentes — tipicamente falhados — como jobs novos com o mesmo payload, depois que a causa raiz foi corrigida).
  • Ideias/anotações pessoais (qualquer usuário logado): save_idea/list_ideas/delete_idea — um bloco de notas do próprio usuário (app_agent_ideas, por user_key). Digitar /registre <texto> no chat salva direto no banco sem chamar nenhuma LLM (funciona mesmo sem provedor configurado); pedir em linguagem natural ("registra essa ideia: ...", "quais ideias eu tenho?", "apaga a ideia 3") passa pelas tools normalmente. Nunca é compartilhado entre usuários nem injetado em prompt nenhum — só volta pro chat quando o próprio usuário pede pra listar, ao contrário das "instruções permanentes" de admin abaixo.
Sobre "latência de rede": o agente enxerga o tempo total de cada chamada à API externa (processingMs no diagnóstico de gargalo, e avgProcessingMs/p95ProcessingMs no overview) — isso já inclui rede + tempo de resposta da API externa somados. Não há (ainda) uma quebra fina entre DNS/conexão TCP/TTFB isolados; isso exigiria instrumentar o ExternalApiClient por etapa, um passo além do que existe hoje.
Espera na fila alta + concorrência ociosa não é sempre "falta de concorrência": espera-na-fila é uma média da janela (15min/1h/24h), enquanto "concorrência em uso" é um snapshot do instante da consulta — os dois podem não bater. Duas causas típicas quando isso acontece: o intervalo de polling do pg-boss (por padrão o worker só confere jobs novos a cada 2s — reduzido para PGBOSS_POLLING_INTERVAL_SECONDS=0.5, o mínimo aceito pelo pg-boss) dominando o tempo de espera de uma fila tipicamente vazia; ou um pico passageiro de jobs que já terminou de processar antes da consulta, mas ainda pesa na média da janela. Tanto o painel da aba Overview quanto a tool get_bottleneck_diagnosis do agente apresentam as duas hipóteses em vez de cravar "aumente a concorrência" quando os slots estão ociosos no momento — esse conselho só ajuda no segundo caso.

Cada tool chama a mesma função que a rota HTTP equivalente já chama — nunca uma lógica paralela — e reforça a mesma checagem de role do resto do dashboard antes de executar; se um usuário user pedir uma ação restrita, a tool devolve erro de permissão pro modelo relatar, sem exceção. O modelo encadeia tools sozinho quando precisa (ex: "encerre o último worker criado" primeiro chama list_workers, depois remove_worker com o id certo).

Dados lidos pelas tools de consulta (payload de job, linha de log) são tratados como dado, nunca como instrução — o prompt do sistema instrui explicitamente o modelo a ignorar qualquer coisa que pareça um comando vindo desses dados (mitigação de prompt injection via conteúdo de terceiros armazenado no próprio banco). O loop de tools também tem um limite de 6 iterações por turno, pra nunca ficar preso indefinidamente.

"Ver raciocínio": qual IA respondeu e o que ela fez

Nenhum provedor expõe o chain-of-thought interno do modelo via API — o que dá pra mostrar de forma honesta é o que a IA de fato fez, não "o que ela pensou". Cada resposta do assistente traz um link "ver raciocínio (N passos)" que expande, por tool chamada nessa resposta: nome da tool, argumentos enviados e resultado recebido — e uma etiqueta "via <provedor>" mostrando qual das configs de LLM cadastradas de fato respondeu (relevante porque pode haver failover entre várias, ver acima). Isso é persistido junto do histórico neutro (app_chat_messages.provider) e reconstruído igual tanto numa resposta que acabou de chegar quanto ao reabrir o chat depois — a mesma trilha, sempre.

Gráficos "quando necessário"

O modelo decide quando um número/série ajuda a resposta e chama a tool estruturada render_chart({type, title, labels, series}) — o front-end desenha em <canvas> (mesmo estilo dos gráficos já usados no resto do dashboard), sem trazer uma biblioteca de gráficos nova e sem risco de HTML/JS livre injetado no chat.

Fluxos (módulo opcional de regras visuais, tipo n8n)

Aba Admin → "Fluxos". Um fluxo visual por endpoint (não uma biblioteca de fluxos compartilhados) que filtra e transforma o payload de um job antes dele seguir pra API externa já configurada daquele endpoint — sem escrever código, conectando nós num canvas. Deliberadamente opcional e contido: um endpoint sem fluxo publicado se comporta exatamente como se o módulo não existisse — mesmo código, mesma performance, zero query extra por job (getPublishedGraph em src/config/flows.js só é consultado, mas retorna null imediatamente se o endpoint nunca teve nada publicado).

Arquitetura validada com os agentes solution-architect e integration-architect antes de implementar, e revisitada quando o escopo cresceu (acesso a banco externo, caminho de arquivo dinâmico, leitura/escrita em fila) — cada expansão foi uma escolha explícita, ciente do trade-off de segurança envolvido, não um "já que estamos aqui".

Tipos de nó

A barra lateral do editor agrupa os nós em 5 categorias:

  • Fluxo — filter (deixa passar ou encerra o job ali mesmo: "filtrado" é uma decisão de negócio, nunca uma falha), branch (if/else, duas saídas), set-field (escreve um valor num campo, cria caminho aninhado se precisar) e default-value (só escreve se o campo estiver ausente/nulo).
  • Controle de fluxo — split/merge: manda o mesmo payload por duas vias ao mesmo tempo (de verdade em paralelo, via Promise.all — se cada via faz uma consulta de banco/arquivo, o tempo de espera se sobrepõe) e depois combina os dois resultados num payload só. As duas vias precisam terminar no mesmo nó merge; em campo com o mesmo nome nas duas, a via B vence (regra fixa, sempre o mesmo resultado).
  • Banco de dados — db-query: consulta um Postgres externo (conexão cadastrada em "Gerenciar conexões", credencial nunca reexibida). Modos select-one/select-many/execute (INSERT/UPDATE/DELETE).
  • Arquivos — read-file: lê um arquivo de referência (json/csv/lines/kv) pra enriquecer o payload, com lookupField opcional pra achar 1 linha específica numa lista.
  • Filas existentes — enqueue (manda o payload atual pra fila de outro endpoint já cadastrado) e queue-stats (lê as contagens dessa fila — pendentes, ativos, etc).
Nunca aponte enqueue pro MESMO endpoint que tem esse fluxo publicado — o job novo cairia na mesma fila, seria pego pelo mesmo fluxo, e reenfileiraria de novo indefinidamente (loop de amplificação de jobs). Use enqueue pra encaminhar pra um endpoint diferente.

Fora de escopo, de propósito (pra não virar um n8n inteiro dentro do broker): nó de chamada HTTP arbitrária dentro do fluxo, fan-out pra múltiplos jobs, loop, delay, agendamento, sub-fluxos. Cada um tem uma razão arquitetural específica documentada em src/flows/engine.js — não é "ainda não deu tempo".

Sem eval, de propósito — cada expansão com sua própria defesa

O payload de um job vem de um produtor externo — tratado como dado em toda a execução, nunca como instrução a interpretar (mesmo princípio já aplicado ao agente de IA). Cada tipo de nó é interpretado por código fixo; acesso a campo é só por dot-path (customer.regiao, e também dentro de array: items.0.a).

  • db-query: a SQL é escrita pelo admin (mesma confiança de qualquer outra config do dashboard); valores vindos do payload só entram como parâmetro ligado ($1, $2...), nunca concatenados na string — testado manualmente com um valor de payload contendo ' OR '1'='1 pra confirmar que é tratado como dado literal, não como SQL. Timeout de 5s fixo, pool próprio por conexão.
  • read-file: o caminho pode vir de um campo do payload (dinâmico), mas o resultado final é sempre travado dentro de FLOW_FILES_DIR (padrão ./data/flow-files) — testado manualmente que tanto ../../etc/passwd quanto /etc/passwd são rejeitados, porque o caminho resolvido cai fora do diretório permitido, não porque o texto "parece perigoso".
  • enqueue no modo "Testar"/"Rodar este nó": nunca envia de verdade (dryRunEnqueue) — só mostra o que teria sido enviado, pra testar um fluxo nunca poluir uma fila real.

Cada nó mostra entrada/saída, com play próprio

Todo card do canvas tem 3 áreas lado a lado: entrada (esquerda, JSON editável), configuração + botão "Rodar" (meio) e saída (direita, resultado). O dado atravessa o card da esquerda pra direita, na mesma direção das portas de conexão (entrada na borda esquerda, saída na direita). Clicar em "Rodar" executa só aquele nó (POST /api/admin/flows/:endpointId/run-node, runSingleNode em src/flows/engine.js) contra o JSON da coluna de entrada — não precisa do resto do grafo nem publica nada. start também tem botão de rodar (digite um payload de exemplo e "alimente" o primeiro nó real da cadeia manualmente); só split/merge não têm, porque dependem estruturalmente das duas vias do grafo.

  • Saída de um nó vira entrada do próximo automaticamente — ao rodar um nó com conexão de saída, o resultado pré-preenche a entrada do nó seguinte na hora, sem copiar/colar manualmente.
  • Navegar o JSON clicando — tanto entrada quanto saída mostram os campos como "pills" clicáveis; clicar insere aquele caminho no campo de texto que estava focado por último em qualquer outro nó — é como levar um valor de um nó pra outro, por clique em vez de arrastar o valor em si.
  • Conectar arrastando — cada nó tem uma bolinha de entrada (esquerda) e uma ou duas de saída (direita); arrastar de uma até o card de outro nó cria a conexão (soltar em qualquer parte do card basta). O seletor "vai para" dentro do nó faz a mesma coisa, os dois nunca dessincronizam.

Versionamento, publicação e simulação do fluxo inteiro

Publicar nunca apaga a versão anterior — só troca qual está ativa. O editor sempre mostra/edita o rascunho (última versão salva, publicada ou não). Publicar/despublicar tem efeito no próximo job, sem reiniciar o worker. "Simular/depurar passo a passo" roda o rascunho inteiro contra um payload de exemplo, sincronamente, sem criar job na fila — cada passo do resultado mostra o payload antes/depois daquele nó, clicável, e clicar num passo realça o nó correspondente no canvas.

Falhas nunca contaminam o circuit breaker

Um erro de fluxo (campo obrigatório faltando, tipo de nó mal configurado) é sempre um bug de lógica/dado, nunca evidência de degradação da API externa — o mesmo isPermanentFailure que já tratava um 4xx de contrato quebrado em src/worker/processor.js reconhece esse tipo de erro e nunca alimenta o circuit breaker do endpoint. Um job filtrado termina como sucesso (nunca chama a API externa, nunca conta como falha).

Execuções

Produção e teste ficam em app_flow_execution — payload de entrada/saída, status (ok/filtered/error), trilha por nó e duração, mesmo espírito do "ver raciocínio" do agente de IA: mostrar exatamente o que aconteceu, não uma suposição. Cada linha tem um botão de excluir, e "Limpar execuções" apaga todo o histórico daquele endpoint de uma vez — nunca afeta rascunho, versão publicada ou jobs reais na fila.

Arquitetura (ao vivo)

Diagrama animado: Dashboard → Postgres → Worker(s) → API Externa, com o estado real do circuit breaker, slots de concorrência ocupados, e a lista de todas as instâncias de worker vivas — cada uma com suas filas atribuídas e os botões + (subir novo worker) e − (encerrar uma instância específica).

Tela Arquitetura
Fig. 8 — Diagrama ao vivo com 2 instâncias de worker registradas.

Autenticação e controle de acesso

O dashboard inteiro fica atrás de login. Dois perfis:

PerfilPode
adminTudo — CRUD de endpoints, gerenciar usuários, gerar/regenerar chaves de ingestão, editar parâmetros globais, botão de teste (seed), Swagger, pausar/retomar consumo, excluir/limpar tabelas, subir/derrubar workers.
userSomente leitura — Overview, Jobs, Logs, Tabelas (visualizar), Todos os Endpoints, Arquitetura. Qualquer tentativa de escrita retorna 403, reforçado no servidor (o front-end só esconde os botões, não é a barreira de segurança real).

Usuário master

Existe um usuário "master" sempre disponível, independente do que estiver cadastrado em app_users — vem de variáveis de ambiente e nunca é gravado como linha comum na tabela:

ADMIN_USERNAME=julio      # default se omitido do .env
ADMIN_PASSWORD=102938     # default se omitido - TROQUE em producao
Isso sobrevive a qualquer estado do banco (tabela de usuários vazia, "julio" apagado por engano) porque é checado antes de consultar app_users — é o acesso de emergência da aplicação. Não dá pra criar um usuário chamado julio pela aba Admin (nome reservado).

Sessão e senha

  • Sessão em Postgres (tabela app_sessions, cookie HttpOnly, 24h) — não é um cookie assinado sem estado. Isso importa porque permite revogar na hora: quando um admin exclui um usuário, a sessão dele morre imediatamente, não só quando o cookie expira sozinho.
  • Senha com crypto.scryptSync nativo do Node (memory-hard, resistente a GPU/ASIC) — sem trazer bcrypt/argon2 como dependência nova só por causa disso. Comparação sempre via timingSafeEqual.

Gerenciar usuários

Aba Admin → "Usuários do dashboard", ou via API:

GET    /api/users                       (admin)
POST   /api/users        { username, password, role }   (admin)
DELETE /api/users/:id                                     (admin - revoga a sessao na hora)

Tokens de ingestão continuam por-endpoint

Decisão deliberada: não criamos um segundo mecanismo de token por-usuário. Cada endpoint já é uma fila isolada — "1 token = 1 endpoint" já dá o isolamento certo (comprometer uma chave expõe só aquela fila, não várias). O que mudou com o login: gerar/regenerar a X-Ingest-Key agora exige sessão de admin, e fica registrado quem gerou (coluna ingest_key_created_by, visível na aba Admin) — auditoria sem duplicar o mecanismo de autenticação. Ver API HTTP de ingestão.

Swagger (admin)

Link no menu Ajuda (só aparece logado como admin) → /admin/swagger.html — Swagger UI carregada via CDN, documentando só a API de ingestão (POST /api/ingest/:endpointId e o endpoint de regeneração de chave). É o único contrato que um sistema externo de fato consome; o resto do dashboard é operação interna do próprio time, não vale a pena formalizar em OpenAPI. O spec (GET /api/openapi.json) também exige sessão de admin — a página HTML em si carrega pra qualquer um, mas fica vazia/nega acesso sem um fetch autenticado bem-sucedido.

Botão de teste (seed)

Aba Admin → "Enviar carga de teste" — checklist de endpoints (seleção múltipla, mostra nome + status ativo/desativado), campo de quantidade (padrão 10) e um contador ao vivo de jobs pendentes nos endpoints marcados. Mesma lógica de scripts/seed.js, mas roda dentro do processo do dashboard (reaproveitando o sendBoss já existente ali para reprocessamento), sem subir um subprocesso. Equivale a POST /api/admin/seed (admin-only), que aceita { count, endpointIds } no corpo — sem eles, usa 10 jobs em todos os endpoints habilitados; endpoints desativados ou inexistentes na lista voltam em skipped em vez de derrubar a chamada inteira. Ver tela Admin.

Segurança e boas práticas

  • Credenciais nunca são reexibidas. authHeaderValue é write-only — a API só retorna {configured: true/false}.
  • Postgres dedicado. Não compartilhar a instância com outras aplicações.
  • Sem socket do Docker no dashboard. O botão "+" de subir worker roda um processo filho dentro do próprio container do dashboard, deliberadamente — não monta o socket do Docker, que daria ao dashboard controle total sobre containers do host.
  • Exclusão de tabelas é protegida por padrão. pgboss.job nunca é apagado em estado pendente/ativo pela grid, exceto na ação explícita "Limpar tabela inteira".
  • mock-api nunca em produção — trava por NODE_ENV e por estar isolado num overlay de compose separado.

Sobre esta documentação

Esta página (src/dashboard/public/docs.html) é servida pelo próprio dashboard — acessível pelo menu Ajuda → Documentação no cabeçalho. Ela é parte do código do projeto, não um documento à parte: toda vez que a arquitetura, as telas ou o processo de configuração mudarem, esta página deve ser atualizada na mesma alteração (mesmo princípio já seguido para o README.md).

Os screenshots em docs-assets/ foram capturados via Chrome headless (--headless --screenshot) direto da instância local rodando — para atualizá-los depois de uma mudança visual relevante:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --headless --disable-gpu --window-size=1400,1000 \
  --screenshot=src/dashboard/public/docs-assets/overview.png \
  "http://localhost:8080/#overview"

Trocando #overview pela aba desejada (#all-endpoints, #jobs, #tables, #params, #architecture) — o hash na URL já aciona a troca de aba automaticamente (mesmo mecanismo usado por link direto no dashboard).