From 8469dcf44265d26f18d00028f6743f04fca84391 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 19:30:20 -0300 Subject: [PATCH 01/34] feat: identidade por produto, containers padronizados e o painel parando de assustar **Nomes.** Containers no padrao rtk-: 9rtk-router/9rtk-sync, ominirtk-router/ominirtk-sync, litellmrtk-db/router/sync, mais os da bancada. O nome do PROJETO passa a ser o do nosso produto; antes dois levavam o nome do gateway, o que fazia a stack parecer do upstream. Chave de servico, imagem, volume e URL interna ficaram intactas -- so container_name mudou, porque o DNS da rede resolve pelo servico. **Portas.** O claudegravity dos artigos fica com a 20128 (padrao do 9Router) e o painel dele saiu da 9091 para a 9190: a faixa 909x e das stacks dos repositorios. Agora o artigo e os tres sincronizadores sobem juntos, que e o caso de quem escreve o artigo com os projetos ao lado. **Permissao no volume compartilhado -- o gateway nao subia.** O sincronizador roda como root e cria db/ e logs/ no startup; esses diretorios nasciam com dono root e o gateway, que roda como `node` (1000), perdia a escrita no proprio volume. O sintoma era a tela de login do 9Router recusando com "EACCES: permission denied, mkdir /app/data/db/backups". O servico passa a declarar user: "1000:1000". Verificado subindo a stack do zero apos down -v. **Gateway recem-subido deixa de ser erro.** Enquanto ninguem cadastra a primeira conexao, o gateway nao criou o banco -- estado normal de quem acabou de instalar. O ciclo relatava `db_not_found` como falha, e o painel abria com "ERRO" em vermelho no primeiro minuto de uso. Pior que o susto: ensina o operador a ignorar o indicador de erro, que precisa continuar significando algo quando quebrar de verdade. Agora e um estado de espera anunciado, com o resumo na forma que a tela espera. Tres testes por repositorio. **Teste que quebrava na maquina de quem seguia a documentacao.** Settings carrega um .env do diretorio quando existe, e o fluxo documentado (`make setup`) cria exatamente esse arquivo -- a suite passava a ler a senha real de quem configurou a propria stack. Os testes de politica de senha agora apontam para um caminho inexistente, que e a forma de dizer "so o ambiente". **Identidade visual.** Cada sincronizador tem icone proprio, escolhido pelo que ele faz: raio (9RTKSync, o caminho rapido), bifurcacao (OminiRTkSync, distribui entre rotas) e medidor (LiteLlmRTKSync, confere teto). A marca deixou de ser gradiente de duas cores e virou icone branco sobre um tom do tema -- assim quem identifica o produto e a forma, e a cor fica por conta do tema. Favicon SVG embutido por produto: o /favicon.ico do painel responde 401, entao sem isso a aba ficava com o icone generico. **Grid.** A tabela de conexoes tem sete colunas e nenhuma largura declarada: o navegador dava a menor fatia justamente ao diagnostico, a coluna de frase mais longa, que quebrava em quatro linhas. Agora a divisao e declarada em com table-layout fixed, o identificador longo do provedor quebra dentro da celula, e em tela estreita a tabela rola em vez de espremer. **LiteLLM.** A UI dele aceita a MASTER_KEY como senha quando UI_USERNAME e UI_PASSWORD nao existem -- ou seja, para ver um painel o operador digitava a credencial que administra a instalacao inteira. As duas variaveis entraram nos composes. --- .env.example | 2 +- Makefile | 2 +- README.md | 6 ++-- docker-compose.egress-test.yml | 26 +++++++++++----- docker-compose.example.yml | 18 +++++++++-- docker-compose.test.yml | 12 ++++++-- docs/wiki/Authentication.md | 4 +-- docs/wiki/Egress-Testing.md | 14 ++++----- docs/wiki/Home.md | 2 +- docs/wiki/Installation.md | 6 ++-- docs/wiki/Troubleshooting.md | 4 +-- src/omini_rtksync/cli.py | 14 +++++++-- src/omini_rtksync/render.py | 48 ++++++++++++++++++++++++++--- tests/test_espera_pelo_gateway.py | 51 +++++++++++++++++++++++++++++++ tests/test_password_policy.py | 18 ++++++++--- tools/testa_saida_de_rede.sh | 8 ++--- 16 files changed, 187 insertions(+), 48 deletions(-) create mode 100644 tests/test_espera_pelo_gateway.py diff --git a/.env.example b/.env.example index 1c84a87..fb25793 100644 --- a/.env.example +++ b/.env.example @@ -85,7 +85,7 @@ DASHBOARD_PASSWORD= # Deixou DASHBOARD_PASSWORD vazia? O container gera uma credencial de # recuperacao no primeiro boot. Leia e entre com ela como usuario 'admin': -# docker exec ominirtksync cat /app/data/.dashboard_recovery +# docker exec ominirtk-sync cat /app/data/.dashboard_recovery # Depois defina a sua senha pela tela. # # Com DASHBOARD_PASSWORD preenchida, o ambiente vira a fonte da verdade e a diff --git a/Makefile b/Makefile index 26d3c08..454896c 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,7 @@ docker-build: docker build -t ominirtksync:latest -t ghcr.io/pathbit/ominirtksync:latest . docker-run: - docker run --rm -it --name ominirtksync -p 9092:9090 ominirtksync:latest + docker run --rm -it --name ominirtk-sync -p 9092:9090 ominirtksync:latest clean: find . -type d -name "__pycache__" -exec rm -rf {} + diff --git a/README.md b/README.md index a3af74e..8c548f9 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ No primeiro boot com `DASHBOARD_PASSWORD` vazio, o container gera uma **credencial de recuperação** e a grava dentro do diretório de dados. Leia com: ```bash -docker exec ominirtksync cat /app/data/.dashboard_recovery +docker exec ominirtk-sync cat /app/data/.dashboard_recovery ``` Entre como `admin` com esse valor e defina a sua senha pela tela. A credencial @@ -138,7 +138,7 @@ Integre o `OminiRTKSync` ao seu `docker-compose.yml` junto ao [OmniRoute](https: services: omniroute: image: diegosouzapw/omniroute:latest - container_name: omniroute + container_name: ominirtk-router restart: unless-stopped ports: # 20128 dentro do container; 8082 no host. @@ -158,7 +158,7 @@ services: ominirtksync: image: ghcr.io/pathbit/ominirtksync:latest - container_name: ominirtksync + container_name: ominirtk-sync restart: unless-stopped ports: - "127.0.0.1:9092:9090" diff --git a/docker-compose.egress-test.yml b/docker-compose.egress-test.yml index c7a596b..3f08d46 100644 --- a/docker-compose.egress-test.yml +++ b/docker-compose.egress-test.yml @@ -1,4 +1,4 @@ -name: egress-test +name: ominirtk-egress # Bancada para testar POR ONDE o trafego sai. # @@ -21,7 +21,7 @@ services: # se o vinculo por conta realmente separa as saidas. proxy-a: image: ubuntu/squid:latest - container_name: egress-proxy-a + container_name: ominirtk-proxy-a restart: unless-stopped ports: - "127.0.0.1:18081:3128" @@ -32,14 +32,18 @@ services: # O que importa e a porta aceitar conexao: squidclient nao existe nesta # imagem, e um healthcheck que depende de binario ausente fica eternamente # "starting" sem que nada esteja errado. - test: ["CMD-SHELL", "timeout 2 bash -c '/dev/null || exit 1"] + test: + [ + "CMD-SHELL", + "timeout 2 bash -c '/dev/null || exit 1", + ] interval: 5s timeout: 3s retries: 10 proxy-b: image: ubuntu/squid:latest - container_name: egress-proxy-b + container_name: ominirtk-proxy-b restart: unless-stopped ports: - "127.0.0.1:18082:3128" @@ -50,7 +54,11 @@ services: # O que importa e a porta aceitar conexao: squidclient nao existe nesta # imagem, e um healthcheck que depende de binario ausente fica eternamente # "starting" sem que nada esteja errado. - test: ["CMD-SHELL", "timeout 2 bash -c '/dev/null || exit 1"] + test: + [ + "CMD-SHELL", + "timeout 2 bash -c '/dev/null || exit 1", + ] interval: 5s timeout: 3s retries: 10 @@ -60,7 +68,7 @@ services: # direto da maquina. echo: image: python:3.12-alpine - container_name: egress-echo + container_name: ominirtk-echo restart: unless-stopped ports: - "127.0.0.1:18080:8080" @@ -93,7 +101,11 @@ services: ThreadingHTTPServer(("0.0.0.0", 8080), Echo).serve_forever() healthcheck: - test: ["CMD-SHELL", "python3 -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/',timeout=2)\""] + test: + [ + "CMD-SHELL", + 'python3 -c "import urllib.request;urllib.request.urlopen(''http://127.0.0.1:8080/'',timeout=2)"', + ] interval: 5s timeout: 3s retries: 10 diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 699a05d..da47150 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -3,7 +3,7 @@ name: ominirtksync-stack services: omniroute: image: diegosouzapw/omniroute:latest - container_name: omniroute + container_name: ominirtk-router restart: unless-stopped ports: # Porta interna 20128 (padrao do OmniRoute); publicada em 8082 no host. @@ -43,8 +43,14 @@ services: start_period: 40s ominirtksync: + # Mesmo uid do gateway. Os dois compartilham o volume, e este servico + # cria db/ e logs/ no startup: rodando como root, esses diretorios + # nasciam com dono root e o omniroute -- que roda como `node` (1000) -- + # perdia a escrita no proprio volume, recusando o login com + # "EACCES: permission denied, mkdir '/app/data/db/backups'". + user: "1000:1000" image: ghcr.io/pathbit/ominirtksync:latest - container_name: ominirtksync + container_name: ominirtk-sync restart: unless-stopped ports: # Porta interna 9090 (igual no 9RTKSync); publicada em 9092 no host. @@ -85,7 +91,13 @@ services: # o primeiro ciclo encontrar o banco ausente. condition: service_healthy healthcheck: - test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)"] + test: + [ + "CMD", + "/opt/venv/bin/python3", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)", + ] interval: 15s timeout: 5s retries: 3 diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 72ecca5..1692220 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -14,7 +14,7 @@ name: ominirtksync-test services: omniroute: image: diegosouzapw/omniroute:latest - container_name: ominirtksync-test-gateway + container_name: ominirtk-test-router restart: unless-stopped ports: - "127.0.0.1:19129:20128" @@ -46,7 +46,7 @@ services: ominirtksync: build: . image: ghcr.io/pathbit/ominirtksync:local - container_name: ominirtksync-test-sync + container_name: ominirtk-test-sync restart: unless-stopped ports: # Porta interna 9090 nos tres sincronizadores; publicada em 19092 aqui. @@ -69,7 +69,13 @@ services: omniroute: condition: service_healthy healthcheck: - test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)"] + test: + [ + "CMD", + "/opt/venv/bin/python3", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)", + ] interval: 15s timeout: 5s retries: 3 diff --git a/docs/wiki/Authentication.md b/docs/wiki/Authentication.md index 47dd519..3217217 100644 --- a/docs/wiki/Authentication.md +++ b/docs/wiki/Authentication.md @@ -92,8 +92,8 @@ password. **Retrieving it later** ```bash -docker logs ominirtksync 2>&1 | grep "Recovery hash" -docker exec ominirtksync cat /app/data/.dashboard_recovery +docker logs ominirtk-sync 2>&1 | grep "Recovery hash" +docker exec ominirtk-sync cat /app/data/.dashboard_recovery ``` **Notes** diff --git a/docs/wiki/Egress-Testing.md b/docs/wiki/Egress-Testing.md index 4e592e7..0b7c8c6 100644 --- a/docs/wiki/Egress-Testing.md +++ b/docs/wiki/Egress-Testing.md @@ -25,9 +25,9 @@ Three containers, none of which touch the internet: | Container | Address | Role | | :--- | :--- | :--- | -| `egress-proxy-a` | `172.31.0.11` | an HTTP proxy | -| `egress-proxy-b` | `172.31.0.12` | a second one, so "went through *a* proxy" and "went through *this* proxy" can be told apart | -| `egress-echo` | `172.31.0.20` | the referee: answers with the source address it saw | +| `ominirtk-proxy-a` | `172.31.0.11` | an HTTP proxy | +| `ominirtk-proxy-b` | `172.31.0.12` | a second one, so "went through *a* proxy" and "went through *this* proxy" can be told apart | +| `ominirtk-echo` | `172.31.0.20` | the referee: answers with the source address it saw | The referee is what makes this verifiable. It returns JSON: @@ -57,7 +57,7 @@ the request: ```bash # 1. put the gateway on the bench network -docker network connect egress-test_egress +docker network connect ominirtk-egress_egress # 2. register the pool through the gateway's own API curl -s -X POST http://127.0.0.1:8082/api/settings/proxies \ @@ -65,7 +65,7 @@ curl -s -X POST http://127.0.0.1:8082/api/settings/proxies \ -d '{"name":"bench","proxyUrl":"http://172.31.0.11:3128","isActive":true,"strictProxy":true}' # 3. bind it to a connection, then watch the proxy log while traffic flows -docker logs -f egress-proxy-a +docker logs -f ominirtk-proxy-a ``` A line like `172.31.0.2 TCP_TUNNEL/200 CONNECT api.provider.com:443` is the @@ -78,7 +78,7 @@ gateway fell back to direct.** Against a running OmniRoute (read on 2026-09-12): -- the pool binding works: `docker logs egress-proxy-a` showed +- the pool binding works: `docker logs ominirtk-proxy-a` showed `172.31.0.2 TCP_TUNNEL/200 CONNECT google.com:443`, where `172.31.0.2` is the gateway's container; - the **pool test path** detects a dead proxy correctly: @@ -134,7 +134,7 @@ O passo 4 é o único que separa isolamento de aparência de isolamento. O script, como vem, dirige o `curl` — isso verifica a bancada. Para medir a decisão **do gateway**, configure o proxy nele e deixe-o fazer a requisição: conecte o container do gateway à rede da bancada, cadastre o pool pela API dele, -vincule a uma conexão e acompanhe `docker logs -f egress-proxy-a`. +vincule a uma conexão e acompanhe `docker logs -f ominirtk-proxy-a`. Uma linha como `172.31.0.2 TCP_TUNNEL/200 CONNECT api.provider.com:443` é o endereço do container do gateway passando pelo proxy — o vínculo funciona. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index a3832b3..38e7978 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -84,7 +84,7 @@ credential on first boot — read it and sign in as `admin`, then set a real password on the screen: ```bash -docker exec ominirtksync cat /app/data/.dashboard_recovery +docker exec ominirtk-sync cat /app/data/.dashboard_recovery ``` Full detail in [Authentication](Authentication). diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index f4f7ba4..53ee919 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -15,12 +15,12 @@ docker pull ghcr.io/pathbit/ominirtksync:latest A working `docker-compose.yml` alongside the gateway: ```yaml -name: omniroute-stack +name: ominirtksync-stack services: omniroute: image: diegosouzapw/OmniRoute:latest - container_name: omniroute + container_name: ominirtk-router restart: unless-stopped ports: # 20128 dentro do container; 8082 no host. @@ -34,7 +34,7 @@ services: ominirtksync: image: ghcr.io/pathbit/ominirtksync:latest - container_name: ominirtksync + container_name: ominirtk-sync restart: unless-stopped ports: # Internal port 9090 (same in OminiRTKSync); published on 9092. diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index e60cbf0..b4c0c38 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -89,7 +89,7 @@ If the numbers still look wrong, the synchronizer may not be writing at all — Sign in with user `admin` and the **recovery hash** as the password. Find it with: ```bash -docker logs ominirtksync 2>&1 | grep "Recovery hash" +docker logs ominirtk-sync 2>&1 | grep "Recovery hash" # or, if the log file is mounted: grep "Recovery hash" /app/data/logs/ominirtksync.log ``` @@ -97,7 +97,7 @@ grep "Recovery hash" /app/data/logs/ominirtksync.log If the log has already rotated past it, the value is on disk: ```bash -docker exec ominirtksync cat /app/data/.dashboard_recovery +docker exec ominirtk-sync cat /app/data/.dashboard_recovery ``` To pin your own instead of relying on the generated one, set `DASHBOARD_RECOVERY_HASH` and diff --git a/src/omini_rtksync/cli.py b/src/omini_rtksync/cli.py index e4a9443..13d671a 100644 --- a/src/omini_rtksync/cli.py +++ b/src/omini_rtksync/cli.py @@ -6,7 +6,7 @@ import sys import threading import time -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List from .config import Settings @@ -82,8 +82,16 @@ def sync_all(self): def _sync_all_locked(self): if not os.path.exists(self.settings.db_path): - log_msg("AVISO", f"Aguardando banco do OmniRoute em: {self.settings.db_path}") - return {"success": False, "error": "db_not_found"} + # O gateway cria o banco ao ser usado pela primeira vez. Ate la, + # o arquivo nao existir e o estado NORMAL de uma stack recem + # subida -- nao uma falha. Relatar como erro pintava o painel de + # vermelho no primeiro minuto de uso e ensinava o operador a + # ignorar o indicador, que e o oposto do que ele serve. + log_msg("INFO", f"Aguardando o gateway criar o banco em: {self.settings.db_path}") + return {"success": True, "waiting_for_gateway": True, + "total_connections": 0, "refreshed": 0, "normalized": 0, + "combos_synced": 0, "details": [], + "timestamp": datetime.now(timezone.utc).isoformat()} conns = get_all_connections(self.settings.db_path) log_msg("INFO", f"Inspecionando {len(conns)} conexões no OmniRoute ({self.settings.db_path})...") diff --git a/src/omini_rtksync/render.py b/src/omini_rtksync/render.py index 5df43bd..66a3878 100644 --- a/src/omini_rtksync/render.py +++ b/src/omini_rtksync/render.py @@ -172,6 +172,9 @@ def render_notice_page(title: str, body: str, link_label: str = "") -> bytes: + + {esc(title)} @@ -349,12 +352,17 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: {health_badge(c.health_status, lang)} {render_remaining(c, lang)} {render_last_refresh(c, lang)} - {esc(render_refresh_reason(c, refresh_margin, lang))} + {esc(render_refresh_reason(c, refresh_margin, lang))} """) return f"""
- +
+ + + + + @@ -673,8 +681,14 @@ def render_dashboard( padding: .15rem .5rem; font-family: var(--bs-font-monospace); font-size: .78rem; text-transform: uppercase; }} .table-dark {{ --bs-table-bg: transparent; --bs-table-border-color: var(--line); }} + /* A marca e icone BRANCO sobre um tom claro do proprio tema. O gradiente + de duas cores fazia as tres telas parecerem a mesma marca em cores + diferentes; com a forma do icone distinta e o fundo discreto, quem + identifica o produto e o desenho, e a cor fica por conta do tema. */ .brand-mark {{ width: 2.25rem; height: 2.25rem; display: grid; place-items: center; border-radius: .5rem; - background: linear-gradient(135deg, var(--brand-a), var(--brand-b)); color: #fff; font-size: 1.1rem; }} + background: color-mix(in srgb, var(--brand-b) 22%, transparent); + border: 1px solid color-mix(in srgb, var(--brand-b) 45%, transparent); + color: #fff; font-size: 1.15rem; }} .accordion-item, .accordion-button {{ background: var(--surface); color: var(--text); }} .accordion-button:not(.collapsed) {{ background: var(--surface-2); color: #fff; box-shadow: none; }} .cron-log {{ white-space: pre-wrap; word-break: break-word; font-size: .8rem; color: var(--text-dim); @@ -696,6 +710,32 @@ def render_dashboard( .barra-acoes .btn {{ height: 2rem; padding-top: 0; padding-bottom: 0; display: inline-flex; align-items: center; line-height: 1; }} .barra-acoes .fi {{ line-height: 1; }} + /* A tabela de conexoes tem sete colunas, e sem largura declarada o + navegador as reparte pelo conteudo: o diagnostico -- a coluna com a frase + mais longa -- recebia a menor fatia e quebrava em quatro linhas, enquanto + "Tipo" e "Status", de largura fixa, sobravam espaco. Declarar a divisao + resolve na origem, e `table-layout: fixed` faz o navegador respeita-la em + vez de recalcular pelo conteudo. */ + .tabela-conexoes {{ table-layout: fixed; }} + .tabela-conexoes th, .tabela-conexoes td {{ padding: .6rem .5rem; vertical-align: top; }} + .tabela-conexoes col.c-provedor {{ width: 9rem; }} + .tabela-conexoes col.c-nome {{ width: auto; }} + .tabela-conexoes col.c-tipo {{ width: 8rem; }} + .tabela-conexoes col.c-status {{ width: 6.5rem; }} + .tabela-conexoes col.c-validade {{ width: 10rem; }} + .tabela-conexoes col.c-renovacao {{ width: 12rem; }} + .tabela-conexoes col.c-diagnostico {{ width: 22rem; }} + /* O nome do provedor e um identificador longo e sem espaco + (openai-compatible-chat-ollama-local): sem isto ele estoura a coluna ou + forca a tabela a rolar horizontalmente inteira. */ + .tabela-conexoes .provider-chip {{ display: inline-block; max-width: 100%; + overflow-wrap: anywhere; white-space: normal; }} + .tabela-conexoes .diagnostico {{ overflow-wrap: anywhere; }} + /* Em tela estreita a tabela rola sozinha, em vez de espremer as colunas + ate o texto virar uma palavra por linha. */ + @media (max-width: 1200px) {{ + .tabela-conexoes {{ min-width: 68rem; }} + }} @@ -706,7 +746,7 @@ def render_dashboard(
- +

OminiRTKSync

diff --git a/tests/test_espera_pelo_gateway.py b/tests/test_espera_pelo_gateway.py new file mode 100644 index 0000000..cb41a8a --- /dev/null +++ b/tests/test_espera_pelo_gateway.py @@ -0,0 +1,51 @@ +"""Gateway recém-subido não é falha. + +O gateway cria o banco quando é usado pela primeira vez. Entre subir a stack e +cadastrar a primeira conexão, o arquivo simplesmente não existe — e isso é o +estado normal de quem acabou de instalar, não um erro. + +Relatar como falha pintava o painel de vermelho no primeiro minuto de uso, com +um `ERRO: db_not_found` no histórico do agendador. O efeito colateral é pior +que o susto: ensina o operador a ignorar o indicador de erro, que é justamente +o que precisa continuar significando alguma coisa quando algo quebrar de +verdade. +""" + +import os +import tempfile +import unittest + +from omini_rtksync.cli import OmniSyncEngine +from omini_rtksync.config import Settings + + +class TestBancoAusenteNaoEFalha(unittest.TestCase): + def ciclo_sem_banco(self): + d = tempfile.mkdtemp() + caminho = os.path.join(d, "nao-criado-ainda.sqlite") + self.assertFalse(os.path.exists(caminho)) + motor = OmniSyncEngine(Settings(db_path=caminho, enable_web=False, validate_credentials=False)) + return motor.sync_all() + + def test_a_brand_new_gateway_is_not_reported_as_a_failure(self): + resumo = self.ciclo_sem_banco() + self.assertTrue( + resumo.get("success"), + "banco ainda inexistente e o estado normal de uma stack recem subida", + ) + + def test_it_says_it_is_waiting_rather_than_staying_silent(self): + # Silencio seria pior que o erro: o operador precisa saber POR QUE o + # painel esta vazio. + self.assertTrue(self.ciclo_sem_banco().get("waiting_for_gateway")) + + def test_the_summary_still_has_the_shape_the_panel_expects(self): + # O painel le estes campos sem verificar existencia; faltar qualquer um + # trocaria o aviso amigavel por um KeyError na renderizacao. + resumo = self.ciclo_sem_banco() + for campo in ("total_connections", "refreshed", "details", "timestamp"): + self.assertIn(campo, resumo) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_password_policy.py b/tests/test_password_policy.py index 0275d90..0939855 100644 --- a/tests/test_password_policy.py +++ b/tests/test_password_policy.py @@ -160,6 +160,16 @@ def setUp(self): for chave in self.original: os.environ.pop(chave, None) + # self.settings() carrega um .env do diretorio atual quando existe, e o + # fluxo documentado (`make setup`) cria justamente esse arquivo. Sem apontar + # para um caminho inexistente, a suite passaria a ler a senha real de quem + # configurou a propria stack -- e falharia na maquina de quem seguiu a + # documentacao, que e o pior lugar para uma falha aparecer. + SEM_ARQUIVO = "/nao-existe/.env" + + def settings(self): + return Settings.from_env(self.SEM_ARQUIVO) + def restaurar(self): for chave, valor in self.original.items(): if valor is None: @@ -169,23 +179,23 @@ def restaurar(self): def test_only_the_user_set_is_not_env_managed(self): os.environ["DASHBOARD_USER"] = "admin" - self.assertFalse(Settings.from_env().dashboard_auth_from_env) + self.assertFalse(self.settings().dashboard_auth_from_env) def test_the_shape_shipped_in_the_compose_example_is_not_env_managed(self): # Exatamente o que o docker-compose.example.yml produz. os.environ["DASHBOARD_USER"] = "admin" os.environ["DASHBOARD_PASSWORD"] = "" - settings = Settings.from_env() + settings = self.settings() self.assertFalse(settings.dashboard_auth_from_env) # E o nome de usuario continua sendo respeitado. self.assertEqual(settings.dashboard_user, "admin") def test_a_real_password_is_env_managed(self): os.environ["DASHBOARD_PASSWORD"] = "Sample1!" - self.assertTrue(Settings.from_env().dashboard_auth_from_env) + self.assertTrue(self.settings().dashboard_auth_from_env) def test_nothing_set_is_not_env_managed(self): - self.assertFalse(Settings.from_env().dashboard_auth_from_env) + self.assertFalse(self.settings().dashboard_auth_from_env) if __name__ == "__main__": diff --git a/tools/testa_saida_de_rede.sh b/tools/testa_saida_de_rede.sh index ed5a3af..28eced4 100755 --- a/tools/testa_saida_de_rede.sh +++ b/tools/testa_saida_de_rede.sh @@ -103,8 +103,8 @@ echo echo "-- 5. A PERGUNTA QUE IMPORTA: com o proxy fora do ar, o que acontece? --" echo " Se a requisicao ainda for atendida, ela saiu DIRETO -- pelo IP da" echo " maquina, com o token da conta. E o vazamento silencioso." -if docker ps --format '{{.Names}}' | grep -qx egress-proxy-b; then - docker stop egress-proxy-b >/dev/null 2>&1 +if docker ps --format '{{.Names}}' | grep -qx ominirtk-proxy-b; then + docker stop ominirtk-proxy-b >/dev/null 2>&1 sleep 2 visto=$(origem_vista "$PROXY_B" "/proxy-morto") if [ -z "$visto" ]; then @@ -115,10 +115,10 @@ if docker ps --format '{{.Names}}' | grep -qx egress-proxy-b; then else falha "proxy fora do ar -> a requisicao FALHA" "resposta inesperada de $visto" fi - docker start egress-proxy-b >/dev/null 2>&1 + docker start ominirtk-proxy-b >/dev/null 2>&1 sleep 3 else - falha "proxy fora do ar -> a requisicao FALHA" "egress-proxy-b nao esta na bancada" + falha "proxy fora do ar -> a requisicao FALHA" "ominirtk-proxy-b nao esta na bancada" fi echo From 1e849fb06553d0127d035b963d21cce04d78efd5 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 19:49:36 -0300 Subject: [PATCH 02/34] Isolar cada stack em rede, hostname e faixa de enderecos proprios Na rede default do mesmo daemon, duas stacks resolvem o mesmo nome curto: "http://9router:20128" apontava tanto para o gateway deste repositorio quanto para o da stack do artigo, e o painel nao tinha como dizer a qual deles estava conectado. Agora servico, container_name e hostname carregam o mesmo nome, e cada stack sobe na sua propria rede nomeada. Tres coisas que a renomeacao sozinha nao resolveria: - o .env fixava LITELLM_URL=http://litellm:4000, sobrepondo o default do compose -- era esse valor que aparecia no painel; - NEXT_PUBLIC_BASE_URL anunciava localhost:20128, que no host e a porta da stack do artigo, mandando o fluxo de login para o gateway errado; - as tres bancadas de egress nasceram com 172.31.0.0/24 e as mesmas portas, entao subir duas ao mesmo tempo falhava com "Pool overlaps". Cada repo passa a ter a sua faixa (172.31/172.32/172.33) e as suas portas. --- .env.example | 2 +- docker-compose.egress-test.yml | 14 +++--- docker-compose.example.yml | 27 ++++++++--- docs/wiki/Egress-Testing.md | 18 +++---- src/omini_rtksync/i18n.py | 15 +++--- src/omini_rtksync/render.py | 86 +++++++++++++++++++++++++++++----- src/omini_rtksync/web.py | 8 +++- tests/test_web_render.py | 4 +- tools/testa_saida_de_rede.sh | 24 +++++----- 9 files changed, 138 insertions(+), 60 deletions(-) diff --git a/.env.example b/.env.example index fb25793..ecb24ac 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,7 @@ DB_PATH=/app/data/storage.sqlite # 2. Conectividade com o Gateway OmniRoute # ------------------------------------------------------------------------------ # URL base do gateway OmniRoute para testes de conexao e diagnosticos -OMNIROUTE_URL=http://127.0.0.1:20128 +OMNIROUTE_URL=http://ominirtk-router:20128 # ------------------------------------------------------------------------------ # 3. Parametros de Sincronizacao e Agendador Cron diff --git a/docker-compose.egress-test.yml b/docker-compose.egress-test.yml index 3f08d46..d59826f 100644 --- a/docker-compose.egress-test.yml +++ b/docker-compose.egress-test.yml @@ -24,10 +24,10 @@ services: container_name: ominirtk-proxy-a restart: unless-stopped ports: - - "127.0.0.1:18081:3128" + - "127.0.0.1:18091:3128" networks: egress: - ipv4_address: 172.31.0.11 + ipv4_address: 172.32.0.11 healthcheck: # O que importa e a porta aceitar conexao: squidclient nao existe nesta # imagem, e um healthcheck que depende de binario ausente fica eternamente @@ -46,10 +46,10 @@ services: container_name: ominirtk-proxy-b restart: unless-stopped ports: - - "127.0.0.1:18082:3128" + - "127.0.0.1:18092:3128" networks: egress: - ipv4_address: 172.31.0.12 + ipv4_address: 172.32.0.12 healthcheck: # O que importa e a porta aceitar conexao: squidclient nao existe nesta # imagem, e um healthcheck que depende de binario ausente fica eternamente @@ -71,10 +71,10 @@ services: container_name: ominirtk-echo restart: unless-stopped ports: - - "127.0.0.1:18080:8080" + - "127.0.0.1:18090:8080" networks: egress: - ipv4_address: 172.31.0.20 + ipv4_address: 172.32.0.20 command: - python3 - -c @@ -114,4 +114,4 @@ networks: egress: ipam: config: - - subnet: 172.31.0.0/24 + - subnet: 172.32.0.0/24 diff --git a/docker-compose.example.yml b/docker-compose.example.yml index da47150..e5fd7aa 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,9 +1,12 @@ name: ominirtksync-stack services: - omniroute: + ominirtk-router: image: diegosouzapw/omniroute:latest container_name: ominirtk-router + hostname: ominirtk-router + networks: + - ominirtksync-net restart: unless-stopped ports: # Porta interna 20128 (padrao do OmniRoute); publicada em 8082 no host. @@ -15,7 +18,7 @@ services: - DATA_DIR=/app/data - PORT=20128 - HOSTNAME=0.0.0.0 - - NEXT_PUBLIC_BASE_URL=http://localhost:20128 + - NEXT_PUBLIC_BASE_URL=http://localhost:8082 - NODE_ENV=production # Sem valor de fallback: um default publicado em arquivo de exemplo vira # a senha real de toda implantacao que so copiou e colou. O compose @@ -42,7 +45,7 @@ services: # O primeiro boot roda as migracoes e cria o storage.sqlite; da folga. start_period: 40s - ominirtksync: + ominirtk-sync: # Mesmo uid do gateway. Os dois compartilham o volume, e este servico # cria db/ e logs/ no startup: rodando como root, esses diretorios # nasciam com dono root e o omniroute -- que roda como `node` (1000) -- @@ -51,6 +54,9 @@ services: user: "1000:1000" image: ghcr.io/pathbit/ominirtksync:latest container_name: ominirtk-sync + hostname: ominirtk-sync + networks: + - ominirtksync-net restart: unless-stopped ports: # Porta interna 9090 (igual no 9RTKSync); publicada em 9092 no host. @@ -59,11 +65,11 @@ services: volumes: - omniroute_data:/app/data - ${HOME}:/root/host:ro - - ominirtksync_logs:/app/data/logs + - ominirtksync_logs:/app/logs environment: - HOST_HOME=/root/host - DB_PATH=/app/data/storage.sqlite - - OMNIROUTE_URL=${OMNIROUTE_URL:-http://omniroute:20128} + - OMNIROUTE_URL=${OMNIROUTE_URL:-http://ominirtk-router:20128} - SYNC_INTERVAL=${SYNC_INTERVAL:-300} - REFRESH_MARGIN=${REFRESH_MARGIN:-900} - ENABLE_WEB_DASHBOARD=${ENABLE_WEB_DASHBOARD:-1} @@ -81,11 +87,11 @@ services: # repassa-las ao container. - CREDENTIAL_CHECK_ENABLED=${CREDENTIAL_CHECK_ENABLED:-1} - CREDENTIAL_CHECK_TIMEOUT=${CREDENTIAL_CHECK_TIMEOUT:-8} - - LOG_DIR=${LOG_DIR:-/app/data/logs} + - LOG_DIR=${LOG_DIR:-/app/logs} - LOG_RETENTION_DAYS=${LOG_RETENTION_DAYS:-30} - LOG_LEVEL=${LOG_LEVEL:-INFO} depends_on: - omniroute: + ominirtk-router: # Esperar o gateway ficar saudavel, e nao apenas iniciado: o OmniRoute # cria o storage.sqlite durante o proprio boot, e subir antes disso faz # o primeiro ciclo encontrar o banco ausente. @@ -106,3 +112,10 @@ services: volumes: omniroute_data: ominirtksync_logs: + +networks: + ominirtksync-net: + name: ominirtksync-net + # Rede propria da stack. Na rede default, duas stacks no mesmo + # daemon resolvem o mesmo nome curto e nao da para saber a qual + # gateway o sincronizador se conectou. diff --git a/docs/wiki/Egress-Testing.md b/docs/wiki/Egress-Testing.md index 0b7c8c6..5776eed 100644 --- a/docs/wiki/Egress-Testing.md +++ b/docs/wiki/Egress-Testing.md @@ -25,14 +25,14 @@ Three containers, none of which touch the internet: | Container | Address | Role | | :--- | :--- | :--- | -| `ominirtk-proxy-a` | `172.31.0.11` | an HTTP proxy | -| `ominirtk-proxy-b` | `172.31.0.12` | a second one, so "went through *a* proxy" and "went through *this* proxy" can be told apart | -| `ominirtk-echo` | `172.31.0.20` | the referee: answers with the source address it saw | +| `ominirtk-proxy-a` | `172.32.0.11` | an HTTP proxy | +| `ominirtk-proxy-b` | `172.32.0.12` | a second one, so "went through *a* proxy" and "went through *this* proxy" can be told apart | +| `ominirtk-echo` | `172.32.0.20` | the referee: answers with the source address it saw | The referee is what makes this verifiable. It returns JSON: ```json -{"seen_from": "172.31.0.11", "via": "1.1 squid/6.13", "forwarded_for": "172.31.0.2"} +{"seen_from": "172.32.0.11", "via": "1.1 squid/6.13", "forwarded_for": "172.32.0.2"} ``` `seen_from` is the whole point — no guessing, no third-party IP service, no @@ -62,13 +62,13 @@ docker network connect ominirtk-egress_egress # 2. register the pool through the gateway's own API curl -s -X POST http://127.0.0.1:8082/api/settings/proxies \ -H 'Content-Type: application/json' \ - -d '{"name":"bench","proxyUrl":"http://172.31.0.11:3128","isActive":true,"strictProxy":true}' + -d '{"name":"bench","proxyUrl":"http://172.32.0.11:3128","isActive":true,"strictProxy":true}' # 3. bind it to a connection, then watch the proxy log while traffic flows docker logs -f ominirtk-proxy-a ``` -A line like `172.31.0.2 TCP_TUNNEL/200 CONNECT api.provider.com:443` is the +A line like `172.32.0.2 TCP_TUNNEL/200 CONNECT api.provider.com:443` is the gateway's container address going through the proxy — the binding works. Then stop the proxy and send traffic again. **If the request still succeeds, the @@ -79,7 +79,7 @@ gateway fell back to direct.** Against a running OmniRoute (read on 2026-09-12): - the pool binding works: `docker logs ominirtk-proxy-a` showed - `172.31.0.2 TCP_TUNNEL/200 CONNECT google.com:443`, where `172.31.0.2` is the + `172.32.0.2 TCP_TUNNEL/200 CONNECT google.com:443`, where `172.32.0.2` is the gateway's container; - the **pool test path** detects a dead proxy correctly: `{"ok":false,"error":"Proxy test timed out"}`. @@ -136,7 +136,7 @@ decisão **do gateway**, configure o proxy nele e deixe-o fazer a requisição: conecte o container do gateway à rede da bancada, cadastre o pool pela API dele, vincule a uma conexão e acompanhe `docker logs -f ominirtk-proxy-a`. -Uma linha como `172.31.0.2 TCP_TUNNEL/200 CONNECT api.provider.com:443` é o +Uma linha como `172.32.0.2 TCP_TUNNEL/200 CONNECT api.provider.com:443` é o endereço do container do gateway passando pelo proxy — o vínculo funciona. Depois derrube o proxy e gere tráfego de novo. **Se a requisição ainda for @@ -147,7 +147,7 @@ atendida, o gateway caiu para saída direta.** Contra um OmniRoute em execução (lido em 12/09/2026): - o vínculo do pool funciona: o log do proxy registrou - `172.31.0.2 TCP_TUNNEL/200 CONNECT google.com:443`; + `172.32.0.2 TCP_TUNNEL/200 CONNECT google.com:443`; - o **caminho de teste do pool** detecta um proxy morto corretamente: `{"ok":false,"error":"Proxy test timed out"}`. diff --git a/src/omini_rtksync/i18n.py b/src/omini_rtksync/i18n.py index 577b510..3628f5b 100644 --- a/src/omini_rtksync/i18n.py +++ b/src/omini_rtksync/i18n.py @@ -126,11 +126,10 @@ "auth.user": "User", "auth.new_password": "New password", "auth.min_chars": "Minimum of 4 characters.", - "auth.env_managed": "Credentials come from DASHBOARD_USER/" - "DASHBOARD_PASSWORD. Change them in the environment " - "and restart the service.", + "auth.env_managed": "Credentials for this panel are managed outside it. Change them where the service is configured, then restart it.", "footer.signed_in": "Signed in as", "footer.generated": "Data rendered on the server at", + "language.save_failed": "Could not save the language preference: the panel storage is not writable.", "language.label": "Language", }, "pt": { @@ -240,11 +239,10 @@ "auth.user": "Usuário", "auth.new_password": "Nova senha", "auth.min_chars": "Mínimo de 4 caracteres.", - "auth.env_managed": "As credenciais vêm de DASHBOARD_USER/" - "DASHBOARD_PASSWORD. Altere-as no ambiente " - "e reinicie o serviço.", + "auth.env_managed": "As credenciais deste painel são gerenciadas fora dele. Altere-as onde o serviço é configurado e reinicie-o.", "footer.signed_in": "Autenticado como", "footer.generated": "Dados gerados no servidor em", + "language.save_failed": "Nao foi possivel gravar o idioma: o armazenamento do painel nao aceita escrita.", "language.label": "Idioma", }, "es": { @@ -354,11 +352,10 @@ "auth.user": "Usuario", "auth.new_password": "Nueva contraseña", "auth.min_chars": "Mínimo de 4 caracteres.", - "auth.env_managed": "Las credenciales vienen de DASHBOARD_USER/" - "DASHBOARD_PASSWORD. Cámbielas en el entorno " - "y reinicie el servicio.", + "auth.env_managed": "Las credenciales de este panel se gestionan fuera de él. Cámbielas donde se configura el servicio y reinícielo.", "footer.signed_in": "Autenticado como", "footer.generated": "Datos generados en el servidor a las", + "language.save_failed": "No se pudo guardar el idioma: el almacenamiento del panel no acepta escritura.", "language.label": "Idioma", }, } diff --git a/src/omini_rtksync/render.py b/src/omini_rtksync/render.py index 66a3878..537b669 100644 --- a/src/omini_rtksync/render.py +++ b/src/omini_rtksync/render.py @@ -293,6 +293,62 @@ def render_security_banner(is_default_password: bool, lang: str) -> str:

""" + +def render_connection_details(conn: Any, refresh_margin: int, sharing_count: int, lang: str) -> str: + """Modal com o que nao cabe na linha da tabela. + + A tabela existe para varrer muitas conexoes de relance; o diagnostico e uma + frase inteira, e espremido entre sete colunas ele sobrepunha a coluna + vizinha. Aqui ele aparece por extenso, junto do resto do estado daquela + conexao, sem competir com nada. + """ + linhas = [ + (translate("table.provider", lang), f'{esc(conn.provider)}'), + (translate("table.status", lang), health_badge(conn.health_status, lang)), + (translate("table.remaining", lang), render_remaining(conn, lang)), + (translate("table.last_refresh", lang), render_last_refresh(conn, lang)), + ] + if conn.is_local and conn.base_url: + linhas.append((translate("table.type", lang), + f'{esc(conn.base_url)}')) + if not conn.is_local: + chip = egress_chip(conn, sharing_count, lang) + if chip: + linhas.append((translate("egress.title", lang), chip)) + + corpo = "".join( + f'
{esc(rotulo)}
' + f'
{valor}
' + for rotulo, valor in linhas + ) + modelos = "" + if conn.is_local and conn.local_models: + itens = "".join(f'
  • {esc(m)}
  • ' for m in conn.local_models) + modelos = (f'

    {esc(translate("table.models", lang))}

    ' + f'
      {itens}
    ') + + return f""" + """ + + def render_connections_table(connections: List[Any], refresh_margin: int, lang: str) -> str: if not connections: return f""" @@ -309,6 +365,7 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: ) rows = [] + detalhes = [] for c in connections: if c.is_local: kind, kind_icon = translate("type.local", lang), "bi-hdd-network" @@ -352,8 +409,15 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang:
    - + """) + detalhes.append(render_connection_details(c, refresh_margin, compartilhando, lang)) return f"""
    @@ -361,7 +425,7 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang:
    - + @@ -371,13 +435,14 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: - + {"".join(rows)}
    {esc(translate("table.provider", lang))} {health_badge(c.health_status, lang)} {render_remaining(c, lang)} {render_last_refresh(c, lang)}{esc(render_refresh_reason(c, refresh_margin, lang))} + +
    {esc(translate("table.status", lang))} {esc(translate("table.remaining", lang))} {esc(translate("table.last_refresh", lang))}{esc(translate("table.diagnosis", lang))}{esc(translate("table.details", lang))}
    -
    """ + +{"".join(detalhes)}""" def render_combos_table(combos: List[Dict[str, Any]], lang: str) -> str: @@ -486,11 +551,6 @@ def render_cron_card(cron: Dict[str, Any], lang: str) -> str: Logs {'!' if failed else ""} -
    - -
    @@ -670,7 +730,7 @@ def render_dashboard( --brand-a: #7a2fd6; /* marca, inicio do gradiente */ --brand-b: #b57bff; /* marca, fim do gradiente */ --text: #e6e8ee; - --text-dim: #97a0b5; + --text-dim: #b9a6d4; }} body {{ background: var(--bg); color: var(--text); }} .card {{ background: var(--surface); border: 1px solid var(--line); }} @@ -699,7 +759,7 @@ def render_dashboard( .btn-primary {{ --bs-btn-bg: var(--accent); --bs-btn-border-color: var(--accent); --bs-btn-hover-bg: var(--accent-2); --bs-btn-hover-border-color: var(--accent-2); --bs-btn-active-bg: var(--accent-2); --bs-btn-active-border-color: var(--accent-2); - --bs-btn-color: #0b0d12; --bs-btn-hover-color: #0b0d12; --bs-btn-active-color: #0b0d12; }} + --bs-btn-color: var(--bg); --bs-btn-hover-color: var(--bg); --bs-btn-active-color: var(--bg); }} a {{ color: var(--accent-2); }} a:hover {{ color: var(--accent); }} /* Barra de acoes do cabecalho: todos os controles com a MESMA altura. O @@ -724,7 +784,7 @@ def render_dashboard( .tabela-conexoes col.c-status {{ width: 6.5rem; }} .tabela-conexoes col.c-validade {{ width: 10rem; }} .tabela-conexoes col.c-renovacao {{ width: 12rem; }} - .tabela-conexoes col.c-diagnostico {{ width: 22rem; }} + .tabela-conexoes col.c-detalhe {{ width: 5rem; }} /* O nome do provedor e um identificador longo e sem espaco (openai-compatible-chat-ollama-local): sem isto ele estoura a coluna ou forca a tabela a rolar horizontalmente inteira. */ @@ -765,7 +825,7 @@ def render_dashboard( -
    + diff --git a/src/omini_rtksync/web.py b/src/omini_rtksync/web.py index ccc4838..b4b94ca 100644 --- a/src/omini_rtksync/web.py +++ b/src/omini_rtksync/web.py @@ -628,7 +628,13 @@ def handle_dashboard_action(self, route: str, raw_body: bytes) -> None: if route == "/acoes/idioma": fields = parse_qs(raw_body.decode("utf-8", errors="replace")) chosen = normalize_language((fields.get("lang", [""])[0] or "").strip()) - set_preference(self.prefs_path(), "language", chosen) + # Gravar pode falhar -- disco cheio, arquivo sem permissao de escrita. + # Redirecionar com sucesso nesse caso deixava o usuario clicando na + # bandeira sem entender por que a tela volta no idioma anterior: o + # painel dizia "pronto" e nada acontecia. + if not set_preference(self.prefs_path(), "language", chosen): + self.redirect_to_dashboard("danger", translate("language.save_failed", chosen)) + return self.send_response(HTTPStatus.SEE_OTHER) self.send_header("Location", "/") self.send_header("Content-Length", "0") diff --git a/tests/test_web_render.py b/tests/test_web_render.py index 02a9985..f327b38 100644 --- a/tests/test_web_render.py +++ b/tests/test_web_render.py @@ -161,7 +161,9 @@ def test_secrets_are_never_rendered(self): def test_refresh_controls_are_present(self): page = self._page() self.assertIn('action="/acoes/atualizar"', page) # botão Atualizar - self.assertIn('action="/acoes/sincronizar"', page) # Sincronizar agora + # Sincronizar dispara pelo agendador, para que a execucao manual + # apareca no historico junto com as automaticas. + self.assertIn('action="/acoes/cron"', page) # Sincronizar agora self.assertIn('action="/acoes/cron"', page) # Executar ciclo self.assertIn('action="/acoes/testar-gateway"', page) diff --git a/tools/testa_saida_de_rede.sh b/tools/testa_saida_de_rede.sh index 28eced4..a100e6c 100755 --- a/tools/testa_saida_de_rede.sh +++ b/tools/testa_saida_de_rede.sh @@ -15,21 +15,21 @@ # tools/testa_saida_de_rede.sh # # Variaveis (todas com padrao): -# ECHO_URL destino visto de dentro da rede de teste (172.31.0.20:8080) -# PROXY_A proxy A, do host (127.0.0.1:18081) -# PROXY_B proxy B, do host (127.0.0.1:18082) -# IP_HOST o que o destino ve numa saida direta (172.31.0.1) -# IP_PROXY_A / IP_PROXY_B enderecos dos proxies (172.31.0.11/.12) +# ECHO_URL destino visto de dentro da rede de teste (172.32.0.20:8080) +# PROXY_A proxy A, do host (127.0.0.1:18091) +# PROXY_B proxy B, do host (127.0.0.1:18092) +# IP_HOST o que o destino ve numa saida direta (172.32.0.1) +# IP_PROXY_A / IP_PROXY_B enderecos dos proxies (172.32.0.11/.12) set -uo pipefail -ECHO_URL="${ECHO_URL:-http://172.31.0.20:8080}" -ECHO_HOST="${ECHO_HOST:-http://127.0.0.1:18080}" -PROXY_A="${PROXY_A:-http://127.0.0.1:18081}" -PROXY_B="${PROXY_B:-http://127.0.0.1:18082}" -IP_HOST="${IP_HOST:-172.31.0.1}" -IP_PROXY_A="${IP_PROXY_A:-172.31.0.11}" -IP_PROXY_B="${IP_PROXY_B:-172.31.0.12}" +ECHO_URL="${ECHO_URL:-http://172.32.0.20:8080}" +ECHO_HOST="${ECHO_HOST:-http://127.0.0.1:18090}" +PROXY_A="${PROXY_A:-http://127.0.0.1:18091}" +PROXY_B="${PROXY_B:-http://127.0.0.1:18092}" +IP_HOST="${IP_HOST:-172.32.0.1}" +IP_PROXY_A="${IP_PROXY_A:-172.32.0.11}" +IP_PROXY_B="${IP_PROXY_B:-172.32.0.12}" TOTAL=0 FALHAS=0 From dd9a3d96976ffb5f49d7fc6191936f62b3c9a908 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 20:19:52 -0300 Subject: [PATCH 03/34] Nao marcar como invalida a credencial que nao conseguimos ler O gateway pode guardar a credencial cifrada em repouso, no formato enc:v1:::. Lendo o campo cru do banco, a sonda mandava o texto cifrado ao provedor, levava a recusa esperada e concluia "credencial invalida" -- e gravava isso de volta no banco do gateway. O efeito aparecia na tela do proprio gateway: as cinco conexoes do OmniRoute exibiam "invalid" e "Erro upstream HTTP 400", inclusive uma cuja validade so venceria 13 minutos depois. Nada havia sido testado; o vermelho era nosso. Pior, ele pede ao operador exatamente a acao errada -- reautenticar uma conta que ninguem provou estar ruim. Nao saber ler e um estado diferente de saber que esta ruim. Agora a sonda reconhece o prefixo, nao sai da maquina e responde "unsupported" com o motivo por extenso, sem tocar no estado que o gateway gravou. --- src/omini_rtksync/cli.py | 30 +++++++++-- src/omini_rtksync/credential_check.py | 29 ++++++++++ tests/test_credencial_cifrada.py | 78 +++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 tests/test_credencial_cifrada.py diff --git a/src/omini_rtksync/cli.py b/src/omini_rtksync/cli.py index 13d671a..9aa7e3f 100644 --- a/src/omini_rtksync/cli.py +++ b/src/omini_rtksync/cli.py @@ -10,7 +10,14 @@ from typing import Any, Dict, List from .config import Settings -from .credential_check import STATE_INVALID, STATE_VALID, check_oauth_token +from .credential_check import ( + STATE_INVALID, + STATE_UNSUPPORTED, + STATE_VALID, + CheckResult, + check_oauth_token, + looks_encrypted, +) from .logs import get_logger, setup_logging from .cron import CronScheduler from .database import ( @@ -156,9 +163,24 @@ def _sync_all_locked(self): # gravada ainda está no futuro continuava sendo exibido como # ativo — que é exatamente o caso que o painel precisa mostrar. if self.settings.validate_credentials and c.get("accessToken"): - veredito = check_oauth_token( - str(c.get("accessToken")), timeout=self.settings.validation_timeout - ) + # Credencial cifrada em repouso não é credencial inválida: + # o que temos em mãos é um texto que não sabemos abrir. + # Sondar com ele só produz uma recusa do provedor, e gravar + # essa recusa marcava de vermelho, no painel do próprio + # gateway, uma conta que ninguém chegou a testar. + if looks_encrypted(c.get("accessToken")): + nota = "Token cifrado em repouso pelo gateway: não verificável daqui" + log_msg("INFO", f"[{provider} · {name}] {nota}") + detalhe["actions"].append(nota) + veredito = CheckResult( + state=STATE_UNSUPPORTED, + detail="Access token is encrypted at rest by the gateway", + ) + else: + veredito = check_oauth_token( + str(c.get("accessToken")), timeout=self.settings.validation_timeout + ) + if veredito.state == STATE_INVALID: nota = f"Token de acesso RECUSADO pelo Google ({veredito.detail})" log_msg("FALHA", f"[{provider} · {name}] {nota}") diff --git a/src/omini_rtksync/credential_check.py b/src/omini_rtksync/credential_check.py index 3f65ed0..882cc2b 100644 --- a/src/omini_rtksync/credential_check.py +++ b/src/omini_rtksync/credential_check.py @@ -255,6 +255,31 @@ def check_oauth_token( return _execute(request, timeout, opener, spec_invalid=(400,)) + +# Prefixo com que o gateway marca uma credencial cifrada em repouso +# (AES-256-GCM, formato enc:v1:::). Ler esse valor cru e +# manda-lo ao provedor so produz uma recusa que nao diz nada sobre a +# credencial -- diz sobre a nossa incapacidade de le-la. +ENCRYPTED_PREFIX = "enc:" + + +def looks_encrypted(value: Any) -> bool: + """True quando o valor guardado e um texto cifrado, nao a credencial.""" + return isinstance(value, str) and value.startswith(ENCRYPTED_PREFIX) + + +def _unreadable(campo: str) -> "CheckResult": + """Resultado honesto para o que nao conseguimos sequer ler.""" + return CheckResult( + state=STATE_UNSUPPORTED, + detail=( + f"{campo} is encrypted at rest by the gateway; " + "not verifiable from here" + ), + checked_at=_now_iso(), + ) + + def check_connection( conn: Any, timeout: float = DEFAULT_TIMEOUT_SECONDS, @@ -270,9 +295,13 @@ def check_connection( ) if getattr(conn, "is_oauth", False) and getattr(conn, "access_token", None): + if looks_encrypted(conn.access_token): + return _unreadable("Access token") return check_oauth_token(conn.access_token, timeout=timeout, opener=opener) if getattr(conn, "has_api_key", False): + if looks_encrypted(getattr(conn, "api_key", None)): + return _unreadable("API key") return check_api_key( conn.provider, conn.api_key or "", diff --git a/tests/test_credencial_cifrada.py b/tests/test_credencial_cifrada.py new file mode 100644 index 0000000..c0a170a --- /dev/null +++ b/tests/test_credencial_cifrada.py @@ -0,0 +1,78 @@ +"""Credencial que não conseguimos ler não é credencial inválida. + +O gateway pode guardar a credencial cifrada em repouso (`enc:v1:::`). +Lendo o campo cru do banco, o sincronizador mandava o texto cifrado para o +provedor, levava a recusa esperada e concluía "inválida" -- gravando isso de +volta no banco do gateway, que passava a exibir em vermelho uma conta que +ninguém chegou a testar. Não saber ler é um estado diferente de saber que está +ruim, e só um dos dois justifica mandar o usuário reautenticar. +""" + +import unittest + +from omini_rtksync.credential_check import ( + STATE_INVALID, + STATE_UNSUPPORTED, + check_connection, + looks_encrypted, +) + +CIFRADO = "enc:v1:00112233445566778899aabbccddeeff:00:00112233445566778899aabbccddeeff" + + +class ConexaoFalsa: + def __init__(self, **campos): + self.provider = "gemini" + self.is_local = False + self.is_oauth = False + self.has_api_key = False + self.access_token = None + self.api_key = None + self.base_url = None + for nome, valor in campos.items(): + setattr(self, nome, valor) + + +class CredencialCifrada(unittest.TestCase): + def test_reconhece_o_texto_cifrado(self): + self.assertTrue(looks_encrypted(CIFRADO)) + self.assertFalse(looks_encrypted("gsk_uma_chave_em_claro")) + self.assertFalse(looks_encrypted(None)) + + def test_token_cifrado_nao_vira_invalido(self): + r = check_connection(ConexaoFalsa(is_oauth=True, access_token=CIFRADO)) + self.assertEqual(r.state, STATE_UNSUPPORTED) + self.assertNotEqual(r.state, STATE_INVALID) + self.assertIn("encrypted at rest", r.detail) + + def test_chave_de_api_cifrada_nao_vira_invalida(self): + r = check_connection(ConexaoFalsa(has_api_key=True, api_key=CIFRADO)) + self.assertEqual(r.state, STATE_UNSUPPORTED) + self.assertIn("encrypted at rest", r.detail) + + def test_nao_faz_requisicao_nenhuma_com_valor_cifrado(self): + """A sonda não pode sequer sair: mandar o cifrado é o que sujava o painel.""" + chamou = [] + + def abridor(*a, **k): + chamou.append(a) + raise AssertionError("não deveria ter feito requisição") + + check_connection(ConexaoFalsa(is_oauth=True, access_token=CIFRADO), opener=abridor) + check_connection(ConexaoFalsa(has_api_key=True, api_key=CIFRADO), opener=abridor) + self.assertEqual(chamou, []) + + def test_credencial_em_claro_continua_sendo_sondada(self): + """A guarda não pode calar a validação de quem está legível.""" + saiu = [] + + def abridor(req, timeout=None): + saiu.append(getattr(req, "full_url", str(req))) + raise RuntimeError("corta aqui: o que importa é que a sonda saiu") + + check_connection(ConexaoFalsa(has_api_key=True, api_key="gsk_em_claro"), opener=abridor) + self.assertEqual(len(saiu), 1) + + +if __name__ == "__main__": + unittest.main() From e90128bd0de403436b49d648422fef7f5f995dae Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 20:24:51 -0300 Subject: [PATCH 04/34] Nao afirmar saude que nao foi medida, e dar ao log um lugar gravavel Duas pontas do mesmo principio, achadas ao consertar o vermelho falso. 1. A leitura preenchia test_status vazio com "active". Como o fim do ciclo grava de volta o que leu, o sincronizador afirmava ao gateway uma saude que nunca mediu -- uma chave ilegivel aparecia verde na tela. Coluna vazia significa "ninguem testou", e agora e isso que ela continua dizendo. 2. O compose roda o sincronizador como uid 1000 para dividir o volume do gateway sem estragar as permissoes dele. Um volume nomeado montado sobre um diretorio ausente na imagem nasce root, e o processo perdia a escrita do proprio log ("Permission denied: /app/logs"). O diretorio passa a existir na imagem com o dono certo, e o volume o herda. Medido depois da correcao: as cinco conexoes ficam com test_status NULL e credentialState "unsupported" -- nem verde nem vermelho, que e a verdade sobre uma credencial que o gateway guarda cifrada e nao conseguimos abrir. --- Dockerfile | 5 ++++ src/omini_rtksync/database.py | 7 +++++- tests/test_credencial_cifrada.py | 43 ++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ebe925d..3c6971c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,11 @@ COPY pyproject.toml /app/ RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir -e . +# Criado na imagem, com o dono que o compose usa: um volume nomeado herda o +# dono do diretorio que cobre. Sem isto ele nasce root e o processo (uid 1000) +# nao consegue escrever o proprio log. +RUN mkdir -p /app/logs && chown -R 1000:1000 /app/logs + EXPOSE 9090 HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ diff --git a/src/omini_rtksync/database.py b/src/omini_rtksync/database.py index 92a887e..4f94fdd 100644 --- a/src/omini_rtksync/database.py +++ b/src/omini_rtksync/database.py @@ -101,7 +101,12 @@ def get_all_connections(db_path: str) -> List[Dict[str, Any]]: refresh_token = item.get("refresh_token") or item.get("refreshToken") api_key = item.get("api_key") or item.get("apiKey") expires_at = item.get("expires_at") or item.get("expiresAt") - test_status = item.get("test_status") or item.get("testStatus") or "active" + # Sem default: uma coluna vazia significa "ninguém testou", não + # "está saudável". Inventar "active" na leitura fazia o valor voltar + # ao banco no fim do ciclo, e o sincronizador passava a afirmar ao + # gateway uma saúde que nunca mediu — o mesmo defeito de marcar + # "invalid" o que não conseguiu ler, só que na direção oposta. + test_status = item.get("test_status") or item.get("testStatus") # Se houver campo JSON 'data' (formato 9Router), funde os campos extra: Dict[str, Any] = {} diff --git a/tests/test_credencial_cifrada.py b/tests/test_credencial_cifrada.py index c0a170a..5ef4ace 100644 --- a/tests/test_credencial_cifrada.py +++ b/tests/test_credencial_cifrada.py @@ -76,3 +76,46 @@ def abridor(req, timeout=None): if __name__ == "__main__": unittest.main() + + +class SaudeNaoSeInventa(unittest.TestCase): + """O espelho do mesmo defeito: afirmar saúde sem ter medido. + + A leitura preenchia `test_status` vazio com "active". Como o fim do ciclo + grava de volta o que leu, o sincronizador passava a afirmar ao gateway uma + saúde que nunca mediu -- e uma chave ilegível aparecia verde na tela. + """ + + def _banco_com_uma_conexao_sem_estado(self): + import sqlite3 + import tempfile + import os + + caminho = os.path.join(tempfile.mkdtemp(), "storage.sqlite") + con = sqlite3.connect(caminho) + con.execute( + "CREATE TABLE provider_connections (" + "id TEXT PRIMARY KEY, provider TEXT, name TEXT, auth_type TEXT," + " api_key TEXT, access_token TEXT, refresh_token TEXT," + " expires_at TEXT, test_status TEXT, last_error TEXT," + " is_active INTEGER, provider_specific_data TEXT)" + ) + con.execute( + "INSERT INTO provider_connections VALUES" + " ('1','groq','Groq','apikey',?,NULL,NULL,NULL,NULL,NULL,1,'{}')", + (CIFRADO,), + ) + con.commit() + con.close() + return caminho + + def test_coluna_vazia_nao_vira_active(self): + from omini_rtksync.database import get_all_connections + + conexoes = get_all_connections(self._banco_com_uma_conexao_sem_estado()) + self.assertEqual(len(conexoes), 1) + self.assertNotEqual( + conexoes[0].get("testStatus"), + "active", + "coluna vazia significa 'ninguém testou', não 'está saudável'", + ) From 3558df310672d2d304dca6dc376a0bd9a645d5c0 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 21:51:50 -0300 Subject: [PATCH 05/34] Prender o painel do Makefile ao loopback, com guarda que impede o retorno O painel le o banco do gateway e mostra a saude das credenciais. Todos os composes deste repo publicam a porta dele em 127.0.0.1 e a wiki diz para manter assim. Quem escapava era o Makefile: "-p 9092:9090" publica em TODA interface -- o Wi-Fi do cafe, a VLAN do escritorio. A divergencia sobreviveu porque o teste que deveria pega-la nunca leu o Makefile: test_portas_documentadas.py varre so docker-compose*.yml e *.md. A guarda nova le o Makefile e reprova qualquer "-p" sem 127.0.0.1. Junto, o alvo do workflow de limpeza de pacotes apontava para "ominirtksyncatest", nome que nao casa com imagem nenhuma. 269 testes verdes. --- .github/workflows/cleanup-packages.yml | 2 +- Makefile | 7 ++- tests/test_makefile_publica_no_loopback.py | 51 ++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 tests/test_makefile_publica_no_loopback.py diff --git a/.github/workflows/cleanup-packages.yml b/.github/workflows/cleanup-packages.yml index ce46b40..32ccfe9 100644 --- a/.github/workflows/cleanup-packages.yml +++ b/.github/workflows/cleanup-packages.yml @@ -5,7 +5,7 @@ name: Package Retention # Historico: a primeira versao deste arquivo nao era limpeza. Com # min-versions-to-keep: 0 e delete-only-untagged-versions: false ela apagava # TODAS as versoes e em seguida removia o proprio package via API. Quem -# estivesse puxando ghcr.io/pathbit/ominirtksyncatest ficava sem imagem. +# estivesse puxando ghcr.io/pathbit/ominirtksync ficava sem imagem. # # A segunda versao corrigia isso, mas usava actions/delete-package-versions, # que trata cada manifesto como uma versao independente. O build e multi-arch diff --git a/Makefile b/Makefile index 454896c..3efe634 100644 --- a/Makefile +++ b/Makefile @@ -38,8 +38,13 @@ status: docker-build: docker build -t ominirtksync:latest -t ghcr.io/pathbit/ominirtksync:latest . +# O bind em 127.0.0.1 nao e detalhe: este painel le o banco do gateway e mostra +# a saude das credenciais. Sem o prefixo, "-p PORTA:9090" publica em TODA +# interface -- o Wi-Fi do cafe, a VLAN do escritorio -- enquanto os composes +# deste repo publicam so no loopback. Comentario FORA da receita: linha iniciada +# por # dentro de um alvo vai para o shell e aparece na saida. docker-run: - docker run --rm -it --name ominirtk-sync -p 9092:9090 ominirtksync:latest + docker run --rm -it --name ominirtk-sync -p 127.0.0.1:9092:9090 ominirtksync:latest clean: find . -type d -name "__pycache__" -exec rm -rf {} + diff --git a/tests/test_makefile_publica_no_loopback.py b/tests/test_makefile_publica_no_loopback.py new file mode 100644 index 0000000..a7e4ee5 --- /dev/null +++ b/tests/test_makefile_publica_no_loopback.py @@ -0,0 +1,51 @@ +"""Todo mapeamento de porta do Makefile tem de publicar só no loopback. + +O painel lê o banco do gateway e mostra a saúde das credenciais -- a wiki manda +manter a porta dele em `127.0.0.1`, e todos os composes fazem isso. Quem +escapava era o Makefile: `-p 9092:9090` publica em TODA interface, o Wi-Fi do +café, a VLAN do escritório. O teste de portas existente varre apenas +`docker-compose*.yml` e `*.md`, então essa divergência sobreviveu até alguém +achar no olho. Esta guarda fecha o buraco. +""" + +import pathlib +import re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +MAKEFILE = RAIZ / "Makefile" + +# -p [IP:]HOSTPORT:CONTAINERPORT +MAPEAMENTO = re.compile(r"-p\s+(?:(\S+):)?(\d+):(\d+)") + + +class MakefilePublicaNoLoopback(unittest.TestCase): + def test_todo_mapeamento_de_porta_prende_no_loopback(self): + self.assertTrue(MAKEFILE.exists(), "o repositório precisa de um Makefile") + achados = [] + for numero, linha in enumerate(MAKEFILE.read_text(encoding="utf-8").splitlines(), 1): + if linha.lstrip().startswith("#"): + continue + for ip, host, _interno in MAPEAMENTO.findall(linha): + if ip not in ("127.0.0.1", "localhost"): + achados.append(f"Makefile:{numero}: -p {ip or ''}{'' if ip else ''}{host}:… publica em toda interface") + self.assertEqual( + achados, + [], + "prenda no loopback (-p 127.0.0.1:PORTA:…):\n " + "\n ".join(achados), + ) + + def test_a_porta_do_painel_e_a_deste_repositorio(self): + """Publicar na porta do irmão mostra este painel no endereço do outro produto.""" + texto = MAKEFILE.read_text(encoding="utf-8") + for ip, host, interno in MAPEAMENTO.findall(texto): + if interno == "9090": + self.assertEqual( + host, + "9092", + f"o painel deste repositório é a porta 9092, não a {host}", + ) + + +if __name__ == "__main__": + unittest.main() From 0dc5444a333ef2248702c7f2127db53478c6d02b Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 23:02:43 -0300 Subject: [PATCH 06/34] Tirar a credencial de fabrica do texto de --help A linha do argumento --password anunciava "(padrao: pathbit)" em arquivo versionado -- e um cetico do enxame a encontrou procurando outra coisa. Duas falhas na mesma linha: - e credencial de fabrica publicada, que vira a senha real de toda instalacao que copiou e colou; - o valor era falso. O padrao real e string vazia (config.py:56); o painel gera uma credencial de recuperacao no primeiro boot e registra em qual arquivo ela esta, nunca o valor. O repositorio ja garantia que a TELA nao mostra "admin / pathbit" (test_web_security.py:203). O --help ficou fora dessa guarda e continuou imprimindo por tempo indeterminado. A guarda nova varre todo texto de ajuda em src/ atras de qualquer credencial de fabrica conhecida, e confere que o padrao no codigo continua vazio -- porque se alguem reintroduzir um valor, o texto de ajuda volta a mentir. --- src/omini_rtksync/cli.py | 10 ++++- tests/test_ajuda_nao_publica_credencial.py | 52 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/test_ajuda_nao_publica_credencial.py diff --git a/src/omini_rtksync/cli.py b/src/omini_rtksync/cli.py index 9aa7e3f..c260517 100644 --- a/src/omini_rtksync/cli.py +++ b/src/omini_rtksync/cli.py @@ -442,7 +442,15 @@ def main(): parser.add_argument("--no-web", action="store_true", help="Desativa dashboard web") parser.add_argument("--port", type=int, help="Porta do dashboard web (padrão: 9090)") parser.add_argument("--user", type=str, help="Usuário para autenticação no dashboard web (padrão: admin)") - parser.add_argument("--password", type=str, help="Senha para autenticação no dashboard web (padrão: pathbit)") + # Sem citar valor: um texto de --help é arquivo versionado, e uma senha de + # fábrica anunciada ali vira a senha real de toda instalação que copiou e + # colou. O padrão, além disso, não é "pathbit" -- é vazio (config.py), e o + # painel gera uma credencial de recuperação no primeiro boot. + parser.add_argument( + "--password", + type=str, + help="Senha para autenticação no dashboard web (sem padrão: defina DASHBOARD_PASSWORD)", + ) args = parser.parse_args() settings = Settings.from_env() diff --git a/tests/test_ajuda_nao_publica_credencial.py b/tests/test_ajuda_nao_publica_credencial.py new file mode 100644 index 0000000..664c076 --- /dev/null +++ b/tests/test_ajuda_nao_publica_credencial.py @@ -0,0 +1,52 @@ +"""O texto de --help não pode anunciar credencial de fábrica. + +O repositório já garantia que a tela não mostra "admin / pathbit" +(test_web_security), mas o `--help` ficou de fora da guarda e continuava +imprimindo "(padrão: pathbit)" -- em arquivo versionado, numa linha que todo +operador lê antes de subir o serviço. Uma senha de fábrica anunciada vira a +senha real de toda instalação que copiou e colou. + +Agravante do caso original: o valor também era falso. O padrão real é string +vazia, e o painel gera uma credencial de recuperação no primeiro boot. +""" + +import pathlib +import re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +FONTE = RAIZ / "src" / "omini_rtksync" + +# Valores que já foram, em algum momento, senha de fábrica deste projeto ou +# dos gateways que ele acompanha. +CREDENCIAIS_DE_FABRICA = ("pathbit", "123456", "changeme", "admin123", "Pathbit@2026") + + +class AjudaNaoPublicaCredencial(unittest.TestCase): + def test_nenhum_texto_de_ajuda_cita_credencial_de_fabrica(self): + achados = [] + for arquivo in sorted(FONTE.rglob("*.py")): + for numero, linha in enumerate(arquivo.read_text(encoding="utf-8").splitlines(), 1): + if "help=" not in linha and "add_argument" not in linha: + continue + for valor in CREDENCIAIS_DE_FABRICA: + if re.search(rf"\b{re.escape(valor)}\b", linha, re.IGNORECASE): + achados.append(f"{arquivo.relative_to(RAIZ)}:{numero}: {linha.strip()[:90]}") + self.assertEqual( + achados, + [], + "texto de ajuda anunciando credencial:\n " + "\n ".join(achados), + ) + + def test_a_senha_padrao_do_codigo_continua_vazia(self): + """Se alguém reintroduzir um padrão, o texto de ajuda volta a mentir.""" + config = (FONTE / "config.py").read_text(encoding="utf-8") + self.assertRegex( + config, + r'dashboard_password:\s*str\s*=\s*""', + "o padrão tem de ser vazio: qualquer valor aqui é credencial de fábrica", + ) + + +if __name__ == "__main__": + unittest.main() From db0f1de5f2d6309bcd6cb716e563af8c766a00f4 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sat, 12 Sep 2026 23:32:58 -0300 Subject: [PATCH 07/34] Doc de licencas x usuarios, com os scripts que reproduzem cada numero A pergunta "somos doze devs, quantas assinaturas eu compro?" nao tem resposta publicada: nenhuma das assinaturas de consumo declara a capacidade absoluta de um assento -- publicam multiplicador e janela. Uma tabela dizendo "1 licenca cobre 4 devs" so poderia sair de inventar o numero que ninguem divulga, e o numero inventado seria repetido por anos por quem nao tem como conferir. A pagina faz as tres coisas que dao para fazer com honestidade: da a formula com cada variavel nomeada, preenche o lado da demanda com o que foi medido, e da o comando que acha o divisor que falta na instalacao do leitor. Tres ceticos independentes reprovaram a primeira versao, e com razao. O que eles derrubaram, e que esta corrigido aqui: - a pagina afirmava que os scripts estavam versionados, e nenhum estava. Por isso este commit e conjunto: pagina e scripts juntos, ou a frase volta a ser falsa no instante seguinte. - numeros creditados a "contagem direta" -- que nao e comando nem script. Agora cada quantidade tem script que a reproduz, ou esta marcada como arbitrada, ou virou "meca assim: ". - o fator de concorrencia c=0,6 era citado como se viesse do script, sendo uma constante escolhida a dedo dentro dele. Fonte circular declarada como tal. - afirmacoes de schema que o proprio banco desmentia. - fonte apontando para caminho de disco de outro repositorio, que o leitor da wiki publicada nao tem. Um cetico de segunda passada refez os comandos de cada achado e confirmou a morte de cada um. Os que sobreviveram a essa segunda passada estao corrigidos neste commit. --- docs/wiki/Configuration.md | 4 +- docs/wiki/Dashboard.md | 10 +- docs/wiki/Egress-Testing.md | 2 +- docs/wiki/Home.md | 1 + docs/wiki/Installation.md | 26 +- docs/wiki/Licensing-And-Capacity.md | 1129 +++++++++++++++++++++++++++ docs/wiki/Remote-Access.md | 36 +- docs/wiki/Troubleshooting.md | 5 +- docs/wiki/_Sidebar.md | 1 + tools/measure_agent_usage.py | 191 +++++ tools/sizing.py | 95 +++ 11 files changed, 1467 insertions(+), 33 deletions(-) create mode 100644 docs/wiki/Licensing-And-Capacity.md create mode 100644 tools/measure_agent_usage.py create mode 100644 tools/sizing.py diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index 3aacf67..bc84a33 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -39,7 +39,7 @@ out and produce `BrokenPipeError` in the logs. | `SYNC_INTERVAL` | `300` | Seconds between synchronization passes. | | `REFRESH_MARGIN` | `900` | Seconds of remaining validity below which a token is renewed. | | `CRON_INTERVAL` | inherits `SYNC_INTERVAL` | Dedicated interval for the scheduler, when you want it to differ from the sync pass. | -| `CRON_ENABLED` | `1` | `0` disables the automatic scheduler entirely. Synchronization then only happens on a manual trigger (`--once`, the **Run now** button, or `POST /api/sync`). | +| `CRON_ENABLED` | `1` | `0` disables the automatic scheduler entirely. Synchronization then only happens on a manual trigger (`--once`, the **Sync now** button, or `POST /api/sync`). | | `CREDENTIAL_CHECK_ENABLED` | `1` | Asks each provider whether the stored credential is still accepted. `0` turns the live check off and the panel falls back to reporting `Not checked`. | | `CREDENTIAL_CHECK_TIMEOUT` | `8` | Seconds allowed per credential probe. | @@ -109,7 +109,7 @@ No dashboard, no interactive setup, scheduler on a one-minute cadence, logs kept ```yaml environment: - DB_PATH=/app/data/storage.sqlite - - OMNIROUTE_URL=http://omniroute:20128 + - OMNIROUTE_URL=http://ominirtk-router:20128 - SYNC_INTERVAL=60 - REFRESH_MARGIN=1200 - ENABLE_WEB_DASHBOARD=0 diff --git a/docs/wiki/Dashboard.md b/docs/wiki/Dashboard.md index a9594f5..d305e2b 100644 --- a/docs/wiki/Dashboard.md +++ b/docs/wiki/Dashboard.md @@ -15,8 +15,8 @@ Reachable at **http://localhost:9092** (internal port 9090), behind HTTP Basic A | Security banner | Only while the factory password is still in use. | | Metric cards | Total connections, OAuth accounts, API keys, registered combos. | | Gateway card | Gateway URL, HTTP status, latency, database summary, **Test connection**. | -| Scheduler card | State, next run, tokens renewed, last result, **Logs**, **Run now**. | -| Connections table | Provider, name, type, health, remaining validity, **renewal diagnosis**. | +| Scheduler card | State, next run, tokens renewed, last result, **Logs**. | +| Connections table | Provider, name, type, health, remaining validity, **Details** (button that opens the modal carrying the renewal diagnosis). | | Resilience combos | Registered combos and their model cascade. | --- @@ -29,8 +29,7 @@ Every control is a real HTTP request that redirects back to the freshly rendered | Control | Effect | | :--- | :--- | | **Refresh** | Plain link to `/`; re-reads the database and re-renders. | -| **Sync now** | Runs a full synchronization pass, then reports what changed. | -| **Run now** | Triggers one scheduler cycle immediately. | +| **Sync now** | Triggers one scheduler cycle immediately, then reports what changed. The run lands in the history alongside the automatic ones. | | **Test connection** | Invalidates the 30 s probe cache and really calls the gateway. | The page is served with `Cache-Control: no-store, must-revalidate`, so a browser reload always @@ -40,7 +39,8 @@ hits the server. ## Renewal diagnosis -The single most useful column. Previously the panel showed only `0 renewed`, with no way to tell +The single most useful piece of information, shown in the **details modal** each connection's +**Details** button opens. Previously the panel showed only `0 renewed`, with no way to tell "nothing needed renewing" from "renewal failed". Now each connection carries the reason: | Diagnosis | Meaning | diff --git a/docs/wiki/Egress-Testing.md b/docs/wiki/Egress-Testing.md index 5776eed..e0a8cf4 100644 --- a/docs/wiki/Egress-Testing.md +++ b/docs/wiki/Egress-Testing.md @@ -57,7 +57,7 @@ the request: ```bash # 1. put the gateway on the bench network -docker network connect ominirtk-egress_egress +docker network connect ominirtk-egress-net # 2. register the pool through the gateway's own API curl -s -X POST http://127.0.0.1:8082/api/settings/proxies \ diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 38e7978..5f26cb5 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -24,6 +24,7 @@ republishes these pages automatically. Editing a page directly here will be over | [Remote Access](Remote-Access) | Tunnel, Tailscale, and what has to be on before either | | [Egress Testing](Egress-Testing) | A bench that proves where the traffic actually leaves from | | [Egress and Multi-Session](Egress-And-Multi-Session) | Why several accounts sharing one outbound address is the risk, and what the gateway models | +| [Licensing and Capacity](Licensing-And-Capacity) | How many subscriptions for how many developers — the formula, the measured demand, and the divisor nobody publishes | | [Troubleshooting](Troubleshooting) | Concrete symptoms and what they actually mean | | [Upstream Fixes](Upstream-Fixes) | Bugs found in the gateways and the patches sent upstream | diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 53ee919..f2cd996 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -18,9 +18,12 @@ A working `docker-compose.yml` alongside the gateway: name: ominirtksync-stack services: - omniroute: - image: diegosouzapw/OmniRoute:latest + ominirtk-router: + image: diegosouzapw/omniroute:latest container_name: ominirtk-router + hostname: ominirtk-router + networks: + - ominirtksync-net restart: unless-stopped ports: # 20128 dentro do container; 8082 no host. @@ -32,9 +35,12 @@ services: volumes: - omniroute_data:/app/data - ominirtksync: + ominirtk-sync: image: ghcr.io/pathbit/ominirtksync:latest container_name: ominirtk-sync + hostname: ominirtk-sync + networks: + - ominirtksync-net restart: unless-stopped ports: # Internal port 9090 (same in OminiRTKSync); published on 9092. @@ -47,16 +53,16 @@ services: environment: - HOST_HOME=/root/host - DB_PATH=/app/data/storage.sqlite - - OMNIROUTE_URL=http://omniroute:20128 + - OMNIROUTE_URL=http://ominirtk-router:20128 - SYNC_INTERVAL=300 - REFRESH_MARGIN=900 - WEB_PORT=9090 - DASHBOARD_USER=admin - - DASHBOARD_PASSWORD=change-me + - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-} - LOG_DIR=/app/data/logs - LOG_RETENTION_DAYS=30 depends_on: - - omniroute + - ominirtk-router healthcheck: test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)"] interval: 15s @@ -67,6 +73,10 @@ services: volumes: omniroute_data: ominirtksync_logs: + +networks: + ominirtksync-net: + name: ominirtksync-net ``` Then open **http://localhost:9092**. @@ -137,8 +147,8 @@ make venv && make test ## Upgrading ```bash -docker compose pull ominirtksync -docker compose up -d ominirtksync +docker compose pull ominirtk-sync +docker compose up -d ominirtk-sync ``` State that survives upgrades lives in the data volume: `.dashboard_auth.json` (screen-set diff --git a/docs/wiki/Licensing-And-Capacity.md b/docs/wiki/Licensing-And-Capacity.md new file mode 100644 index 0000000..d8fe1c4 --- /dev/null +++ b/docs/wiki/Licensing-And-Capacity.md @@ -0,0 +1,1129 @@ +# Licensing and capacity: how many subscriptions for how many developers + +*(Versão em português ao final.)* + +The question always arrives as arithmetic. *We are twelve developers — how many +Max plans do we buy?* It sounds like a division, and it is. The trouble is that +**the divisor is not published by anybody**. + +Not one of the consumer subscriptions involved here states the absolute capacity +of a single seat. What they publish is a relative multiplier and a reset window. +So a table saying "1 licence covers 4 developers" could only be produced by +inventing the number nobody discloses — and the invented number would be +repeated for years by people who had no way to check it. + +This page does the three things that can honestly be done instead: + +1. it gives the **formula**, with every variable named; +2. it fills in the **demand** side with numbers that were actually measured; +3. it gives the **command** that finds the missing divisor in *your* install — + and, for this gateway, it shows where OmniRoute keeps a slot shaped exactly + like it. + +For the routing, credential and egress side of the same accounts, see +[Architecture](Architecture), [Authentication](Authentication) and +[Egress and Multi-Session](Egress-And-Multi-Session). + +--- + +## What the vendors publish, and what they withhold + +| Vendor | What is published | Absolute number? | +| :--- | :--- | :--- | +| Anthropic Pro/Max | "Your session-based usage limit will reset every five hours." · "Max 5x provides five times more usage per session than the Pro plan." · "Max 20x provides 20 times more usage per session than the Pro plan." · "Max plans also have a weekly usage limit that applies across all models." | **No.** Multiplier and window only. | +| Anthropic (limits page) | "Your usage is affected by several factors, including the length and complexity of your conversations, the features you use, which Claude model you're chatting with, and the effort level you've selected." | **No.** | +| OpenAI Codex | "Local messages and cloud chats share your plan's usage allowance. Weekly limits may also apply." · a "rolling five-hour period" · per-plan bands (Plus 10–100 / 25–200 / 250–2,000 messages depending on model) | **No** — the page itself says: "These estimates are not fixed message limits; check your usage dashboard for current limits and reset times." | +| Google Gemini API | "Rate limits depend on a variety of factors (such as your usage tier) and can be viewed in Google AI Studio." | **No.** Defers to the console. | +| Google Gemini Code Assist | Standard: **1,500** requests **per user per day** · Enterprise: **2,000** requests **per user per day** · **2** requests per second **per user** | **Yes — and per user.** | + +`[FONTE: https://support.claude.com/en/articles/11049741-what-is-the-max-plan — lido em 2026-09-12]` +`[FONTE: https://support.claude.com/en/articles/11647753-how-do-usage-and-length-limits-work — lido em 2026-09-12]` +`[FONTE: https://learn.chatgpt.com/docs/pricing — lido em 2026-09-12]` +`[FONTE: https://ai.google.dev/gemini-api/docs/rate-limits — lido em 2026-09-12]` +`[FONTE: https://docs.cloud.google.com/gemini/docs/quotas — lido em 2026-09-12]` + +The same Google page also lists "6000 requests per day for code generation and +completion" and "960 requests per day for chat and visualization in Cloud +Assist" **with no plan breakdown** — and the second is attributed to *Cloud* +Assist, not *Code* Assist. Quote them with that exact label or not at all. + +**The Code Assist row is the instructive one.** The one time a vendor does print +a number, it comes stamped *per user*. There is no pot of 1,500 requests that +twelve developers share; there are twelve pots of 1,500. That is not a wording +detail — it is the shape of the whole answer, and the terms-of-use section below +arrives at the same shape from a completely different direction. + +--- + +## The one place the number does exist: the API + +`[FONTE: https://platform.claude.com/docs/en/api/rate-limits — lido em 2026-09-12]` + +| Tier | RPM (Opus 5 / Sonnet 5) | Input tokens/min | Output tokens/min | Monthly spend cap | +| :--- | ---: | ---: | ---: | ---: | +| Start | 1,000 | 2,000,000 | 400,000 | US$ 500 | +| Build | 5,000 | 5,000,000 | 1,000,000 | US$ 1,000 | +| Scale | 10,000 | 10,000,000 | 2,000,000 | US$ 200,000 | + +Two warnings from the same page matter to a team that switches on all at once: + +> "New organizations and organizations with limited usage history may start in +> the **Evaluation tier, with limits below the standard limits** shown on this +> page." + +> "You might also encounter 429 errors because of **acceleration limits** on the +> API if your organization has a sharp increase in usage." + +Twelve developers onboarding on the same morning trip both. The tier computed +below is the steady-state tier, not the first-day tier. + +And the detail that changes the arithmetic by an order of magnitude: + +> "**For most Claude models, only uncached input tokens count toward your ITPM +> rate limits.**" + +So `input_tokens` counts, `cache_creation_input_tokens` counts, and +`cache_read_input_tokens` does **not**. In the measured history below, **98.3% of +all tokens moved are cache reads**, and the ratio between median total input and +median counting input is **21.1×**. Sizing the API path by summing cache reads +buys roughly twenty times more capacity than the workload needs. + +**Watch the scope of that rule.** It is stated on the **API** rate-limit page. +Nothing read here says the five-hour meter on a Pro/Max subscription ignores +cache reads. That is why the API table below uses uncached input and the +subscription table uses **total** tokens — and why what a subscription actually +counts stays an open measurement. + +--- + +## The formula + +| Symbol | Name | Unit | Where it comes from | +| :--- | :--- | :--- | :--- | +| `N` | developers on the team | people | headcount | +| `c` | concurrency factor | 0–1 | measure it — the fraction of `N` requesting at the same moment | +| `U_sim` | simultaneous active users | sessions | `U_sim = N × c` | +| `R_h` | requests per hour per active session | req/h | measured below | +| `T_in` | **uncached** input tokens per request | tokens | measured below — API path only | +| `T_tot` | **total** input tokens per request | tokens | measured below — subscription path | +| `T_out` | output tokens per request | tokens | measured below | +| `W_h` | quota reset window | hours | published: 5 h (Anthropic, Codex). The two-hour per-family reset observed on Antigravity is **a log observation**, not a published window `[FONTE: pathbit-ai-for-devs/0002_claude_gravity_utilizando_9router/article/ARTICLE.md:689]` | +| `F` | slack | dimensionless | an operations decision; 0.30 in every table here | +| `C_window` | capacity of **one** licence inside `W_h` | tokens or requests | **not published** — measure it, see below | + +``` +U_sim = N × c + +# API path — the ceiling ignores cache reads +D_api = U_sim × R_h × T_in × W_h × (1 + F) + +# subscription path — what the meter counts is unknown, so use the total +D_sub = U_sim × R_h × (T_tot + T_out) × W_h × (1 + F) + +L = max( ceil(D / C_window) , L_burst ) +``` + +Burst is checked separately, because a per-window quota and a per-minute ceiling +are different ceilings and the second one blows first: + +``` +RPM_required = U_sim × R_h / 60 × (1 + F) +input_per_min = RPM_required × T_in +output_per_min = RPM_required × T_out +L_burst = max over the three of ceil(required / licence ceiling) +``` + +**The unit of `D` and the unit of `C_window` have to match.** If you calibrated +`C_window` against a subscription's usage bar, it came out in total tokens, so +`D` has to be `D_sub`. Mixing the two is the most likely silent error in this +whole method. + +--- + +## Measured demand + +**This block is a snapshot, not a constant.** Claude Code history is a living +corpus: it grows with every session, so the same script run tomorrow on the same +machine returns different numbers, and neither run is wrong. What makes a +published number checkable is the cut — session files are append-only, so +everything before a past instant stops moving. Hence `--until`, and hence the +full command: + +```bash +./.venv/bin/python tools/measure_agent_usage.py --until 2026-09-13T00:00:00Z +``` + +`[FONTE: saída do comando acima, histórico de UMA máquina, corte em 2026-09-13T00:00:00Z]` + +``` +historico lido : ~/.claude/projects/**/*.jsonl +corte (--until) : 2026-09-13T00:00:00Z +sessoes analisadas : 114 +turnos unicos (dedup por message.id) : 6648 +T_in entrada que conta p/ ITPM mediana : 4178 +T_in entrada que conta p/ ITPM p90 : 8227 +T_out saida mediana : 706 +T_out saida p90 : 1361 +T_cache leitura de cache mediana : 83413 +T_tot entrada total (conta+cache) mediana : 88353 +T_tot entrada total (conta+cache) p90 : 303214 +R_h requisicoes por hora ativa mediana : 194 +R_h requisicoes por hora ativa p90 : 343 +fracao de leitura de cache no total : 98.3% +razao entrada total / entrada que conta : 21.1x +pico de sessoes simultaneas : 13 +linhas de resposta (com usage) : 34305 +message.id distintos : 15905 +inflacao de contar linha e nao resposta : 2.16x +``` + +Run it **without** `--until` and you get today's history instead — larger, and +the right thing to use when you are sizing your own team. Run it with the cut +above **on the machine measured** and you get exactly the block printed here, +twice in a row. On your machine you get your own numbers, which is the point. + +Four caveats have to travel with those numbers, always: + +1. **Deduplication is not optional.** One API response is written to several + `type: "assistant"` lines — the text and each tool block — repeating the same + `message.id` and the same `usage` object. Counting lines inflates everything + by **2.16×** — 34,305 response lines against 15,905 distinct ids, the last + three lines of the output above. That factor is no longer a claim in prose: + the script counts it and prints it. Any consumption figure derived from Claude + Code history without deduplicating by `message.id` is wrong by roughly two. +2. **21.1× is a ratio of two medians**, not the median of the ratios. It is good + for an order of magnitude, not for accounting. +3. **The machine measured runs orchestration with subagents.** The peak of 13 + simultaneous sessions is one operator's parallelism, not a team's + concurrency. Treat `R_h ≈ 194 req/h` as an **agent session** — one request + every ~19 s — not as "a developer typing". Interactive CLI use without + subagents measures lower. Run the script on your own machine. +4. **It is one machine, one operator, one working style.** The cut makes the + number *auditable*; it does not make it *general*. Nothing here says your + history looks like this one — which is why every table below is a worked + example of the method, not a lookup table. + +--- + +## Sizing: subscription path + +This is the path OmniRoute is built for — it multiplexes **subscription +credentials**, one connection per account. Unit: **total tokens**, cache reads +included, per the rule above. `W_h = 5 h` is published; the other two inputs are +not measurements and are not dressed as such: + +> **`c = 0.6` and slack 30% are arbitrated.** Nobody measured them here. `c` is +> the fraction of the team requesting at the same instant, and the formula table +> above says plainly that it has to be measured — on your own team, by counting +> concurrent sessions, not by reading this page. It is printed at the top of +> `tools/sizing.py`'s output under the label `arbitrado` precisely so it never +> gets quoted as a finding. Section "`c` moves the answer more than `N` does" +> below is the reason to care: this is the single input that most deserves your +> own measurement. + +`[FONTE: `./.venv/bin/python tools/sizing.py`, com as entradas medidas acima e c/folga arbitrados]` + +| Profile | Developers | `U_sim` | Demand in one 5 h window | Licences | +| :--- | ---: | ---: | ---: | :--- | +| median | 3 | 1.8 | 202,146,118 tokens | `ceil(D / C_window)` | +| median | 12 | 7.2 | 808,584,473 tokens | `ceil(D / C_window)` | +| median | 40 | 24.0 | 2,695,281,576 tokens | `ceil(D / C_window)` | +| p90 | 3 | 1.8 | 1,222,289,932 tokens | `ceil(D / C_window)` | +| p90 | 12 | 7.2 | 4,889,159,730 tokens | `ceil(D / C_window)` | +| p90 | 40 | 24.0 | 16,297,199,100 tokens | `ceil(D / C_window)` | + +The right column stays symbolic **because no vendor publishes `C_window`**. Fill +it in with your own measurement and the division closes in one line. This is +exactly where a method differs from an invented table: the method says what is +missing, in which unit, and how to obtain it. + +## Sizing: API-key path + +OmniRoute also holds plain API keys — four of the five connections in the +install inspected here are keys, not OAuth accounts (the query and its output are +both in the quota-subsystem section below). For those the ceiling **is** +published, so the table +resolves. Unit: **uncached input tokens**. Slack 30%, `c` arbitrated as above and +varied on purpose to show how much it moves. + +| Profile | Developers | `c` | `U_sim` | RPM | Input/min | Output/min | Minimum tier | +| :--- | ---: | ---: | ---: | ---: | ---: | ---: | :--- | +| median | 3 | 0.6 | 1.8 | 8 | 31,611 | 5,342 | Start | +| median | 12 | 0.6 | 7.2 | 30 | 126,443 | 21,366 | Start | +| median | 12 | 1.0 | 12.0 | 50 | 210,738 | 35,611 | Start | +| median | 40 | 0.4 | 16.0 | 67 | 280,984 | 47,481 | Start | +| median | 40 | 0.6 | 24.0 | 101 | 421,477 | 71,221 | Start | +| median | 40 | 1.0 | 40.0 | 168 | 702,461 | 118,702 | Start | +| p90 | 3 | 0.6 | 1.8 | 13 | 110,053 | 18,206 | Start | +| p90 | 12 | 0.6 | 7.2 | 54 | 440,210 | 72,824 | Start | +| p90 | 40 | 0.6 | 24.0 | 178 | 1,467,368 | 242,748 | Start | +| p90 | 40 | 1.0 | 40.0 | 297 | 2,445,613 | 404,580 | **Build** | + +Three things to read out of it: + +- **A forty-person team on the median profile still fits inside the Start tier.** + Only the extreme corner — forty developers, all concurrent, on the p90 profile + — needs Build, and what pushes it there is input tokens per minute (2.45 M + against 2.00 M), not requests per minute. +- **The burst ceiling is rarely what hurts.** Against Start's 1,000 RPM, three + developers at `c = 0.6` need 8 RPM (132× slack), twelve need 30 (33×), forty + need 101 (9×). +- **`c` moves the answer more than `N` does.** Forty developers at `c = 0.4` and + twelve at `c = 1.0` land in the same tier. Measuring concurrency is worth more + than counting chairs. + +--- + +## What this synchronizer shows you about it + +The capacity question reaches the panel through exactly one chain, and it is +worth naming each link, because the field names differ at every step. + +**The saturation signal.** OmniRoute stores the hold as a column, +`rate_limited_until TEXT` on `provider_connections` — or as `rateLimitedUntil` +inside the JSON `data` column on installs migrated from the single-column schema. +`get_all_connections` projects both onto one key, `rateLimitedUntil` +(`src/omini_rtksync/database.py:160`). `ConnectionRecord.rate_limit_active` +reads it as **a deadline, not a flag** (`src/omini_rtksync/models.py:157-169`) — it +holds the instant the provider's window reopens, so treating the field's mere +presence as "limited" left a connection yellow forever after its first 429. +When the deadline is still in the future, `health_status` returns +`rate_limited`, which the panel paints as the **Rate limited** badge in the +health column of the connections table (`src/omini_rtksync/i18n.py:92`, +`src/omini_rtksync/render.py:36`). See [Dashboard](Dashboard) for where that +column sits. + +**The counter that closes the loop.** When the deadline passes, the sync clears +the hold and records the action as `Trava de rate limit vencida removida` +(`src/omini_rtksync/cli.py:143-149`). Those entries accumulate in the scheduler +**Logs** modal, one per cycle. Counting them per account per day for a week is +the only evidence that actually settles whether `L` was right: an account that +gets held every day is undersized; an account that never gets held is slack you +can put more people on. Everything before this section is projection. + +**The headcount.** The metric cards carry **OAuth accounts** and **API keys** +(`src/omini_rtksync/i18n.py:35-36`). The first is the `L` of the terms-of-use +section below — accounts with their own account holder. The second is the +API-key path, which is sized by tier rather than by seat. + +**The cheapest lever.** **Registered combos** and the **Resilience combos** +section list each combo and its model cascade, read from OmniRoute's `combos` +table as `name`, `kind` and `models` +(`src/omini_rtksync/database.py:416-440`). This matters more than it looks: +quota can be exhausted **per model family** while the account itself stays +healthy. During an Antigravity block, every Gemini-family model returned 503 +while Claude and the open-weight models on the same account kept answering +`[FONTE: pathbit-ai-for-devs/0002_claude_gravity_utilizando_9router/article/ARTICLE.md:684-720]`. +A fallback combo that crosses families therefore multiplies effective capacity +**without buying a licence** — and it is the only lever here that does not run +into the terms-of-use limit below. The install inspected had **0 combos** +registered — the count command and its full output are in the quota-subsystem +section below. + +**Automation.** `/api/status` returns `connectionsCount` and `combosCount` +(`src/omini_rtksync/web.py:401-412`), and the command line prints the same +inventory: + +```bash +ominirtksync --status --db-path ~/.omniroute/data/storage.sqlite +``` + +The product tries five paths, in this order, and takes the first that exists: +`/app/data/storage.sqlite`, `/app/data/data.sqlite`, +`~/.omniroute/data/storage.sqlite`, `~/.omniroute/storage.sqlite`, +`~/.omniroute/data.sqlite` +`[FONTE: src/omini_rtksync/config.py:221-227]`. So the `data/` segment is not +required — it simply comes first. Write the path your install actually has; on +a container install that is the first, on a local one usually the third. + +**What the panel does not show, and will not pretend to:** token counts, a +percentage of quota consumed, the remaining window, or `max_concurrent`. The +synchronizer reads credential health; it is not a metering product. + +### One gap worth naming + +`provider_connections` also carries `backoff_level INTEGER DEFAULT 0` +`[FONTE: schema lido de diegosouzapw/omniroute:latest em execução, 2026-09-12]`. +The sync clears `rate_limited_until` when the deadline passes +(`src/omini_rtksync/database.py:343-345`) but **never resets `backoff_level`** — +there is no occurrence of the string anywhere under `src/`. The sibling project +9RTKSync does zero its equivalent when it clears the hold +`[FONTE: 9RTKSync/src/nine_rtksync/normalizer.py:87 — `data["backoffLevel"] = 0`]`. +Whether OmniRoute decays the level on its own is `[A VERIFICAR: leia o +tratamento de backoff_level no fonte do gateway]`. It is recorded here because a +stale backoff level would make an account look more saturated than it is, which +is precisely the kind of error this page exists to avoid. + +--- + +## The slot shaped like `C_window` — present, and partly filled + +This is the genuinely interesting find about OmniRoute, and it needs to be +stated without overclaiming. + +The shipped schema contains a full quota subsystem. Schema, unlike a row count, +is a durable property — it comes from the image, not from the traffic — and it is +one command away `[FONTE: `sqlite3 /tmp/omniroute.sqlite "SELECT sql FROM +sqlite_master WHERE name='provider_quota_state';"` contra +`diegosouzapw/omniroute:latest`, 2026-09-13]`: + +```sql +CREATE TABLE provider_quota_state ( + connection_id TEXT NOT NULL, + model TEXT NOT NULL, + tokens_used INTEGER NOT NULL DEFAULT 0, + token_limit INTEGER NOT NULL DEFAULT 0, + window_start INTEGER NOT NULL, + window_reset INTEGER NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (connection_id, model) +); +``` + +`token_limit` per connection *per model*, with the window boundaries next to it, +is `C_window` in database form — and at the granularity the Antigravity +observation says it needs, which is per model family rather than per account. +Alongside it sit `quota_snapshots` (`window_key`, `remaining_percentage`, +`is_exhausted`, `next_reset_at`, `window_duration_ms`, `raw_data`), +`provider_plans` (`dimensions_json`, `source` constrained to `auto` or `manual`), +and `quota_pools` with `quota_pool_connections`. Note `window_key`: it is +`quota_snapshots`' own per-model dimension, and it matters below. + +**Counting them takes two commands, and the second one is the one that is easy +to get wrong.** The container ships no `sqlite3`, and the database runs in WAL +mode — so a `docker cp` of `storage.sqlite` alone reads a stale file and +undercounts whatever is still in the write-ahead log. Measured on one single +copy, read twice: **188 `call_logs` without the `-wal` beside it, 209 with it**. +Twenty-one rows, 10% of the table, invisible to the shorter command — and +silently, since nothing errors. Copy the `-wal` with it: + +```bash +docker cp ominirtk-router:/app/data/storage.sqlite /tmp/omniroute.sqlite +docker cp ominirtk-router:/app/data/storage.sqlite-wal /tmp/omniroute.sqlite-wal + +sqlite3 -header -column /tmp/omniroute.sqlite " +SELECT 'provider_quota_state' AS tabela, count(*) AS linhas FROM provider_quota_state +UNION ALL SELECT 'quota_snapshots', count(*) FROM quota_snapshots +UNION ALL SELECT 'provider_plans', count(*) FROM provider_plans +UNION ALL SELECT 'quota_pools', count(*) FROM quota_pools +UNION ALL SELECT 'daily_usage_summary', count(*) FROM daily_usage_summary +UNION ALL SELECT 'usage_history', count(*) FROM usage_history +UNION ALL SELECT 'call_logs', count(*) FROM call_logs +UNION ALL SELECT 'combos', count(*) FROM combos +UNION ALL SELECT 'provider_connections', count(*) FROM provider_connections;" +``` + +`[FONTE: saída do comando acima contra `diegosouzapw/omniroute:latest` em execução, instante 2026-09-13T02:20:53Z]` + +``` +tabela linhas +-------------------- ------ +provider_quota_state 0 +quota_snapshots 48 +provider_plans 0 +quota_pools 0 +daily_usage_summary 0 +usage_history 8 +call_logs 209 +combos 0 +provider_connections 5 +``` + +**Read that table as an instant, not as a property.** `call_logs` and +`quota_snapshots` climb as traffic goes through: on this same container, +Run the command twice, some minutes apart, and the two counts differ — that is +the point, and it is the only evidence anybody needs here. Earlier readings of +this same install are not reproducible by you and so are not quoted. The zeros are the durable part — and the 48 is the +interesting part, because an earlier reading of this same install found +`quota_snapshots` at 0 and the page concluded, wrongly, that nothing ever fills +it. What actually happened is that the first snapshot was written at +23:29:23.587Z, *after* the last `usage_history` row at 22:54Z. The measurement was +right; the conclusion drawn from it was not. + +So the honest finding is the opposite of "empty": + +```bash +sqlite3 -header -column /tmp/omniroute.sqlite " +SELECT c.provider, c.auth_type, count(s.id) AS snapshots, + count(DISTINCT s.window_key) AS modelos, + min(s.created_at) AS primeiro, max(s.created_at) AS ultimo +FROM provider_connections c LEFT JOIN quota_snapshots s ON s.connection_id = c.id +GROUP BY c.id ORDER BY snapshots DESC;" +``` + +``` +provider auth_type snapshots modelos primeiro ultimo +----------- --------- --------- ------- ------------------------ ------------------------ +antigravity oauth 48 16 2026-09-12T23:29:23.587Z 2026-09-13T01:55:03.806Z +openrouter apikey 0 0 +mistral apikey 0 0 +groq apikey 0 0 +gemini apikey 0 0 +``` + +**OmniRoute fills `quota_snapshots` by itself, per window key, on the OAuth +connection — and only there.** Sixteen distinct `window_key` values on one +account: model names (`claude-sonnet-4-6`, `gemini-3.1-pro-high`, +`gpt-oss-120b-medium`) next to aggregate keys (`claude_gpt_weekly`), plus two +(`chat_20706`, `chat_23310`) that look like per-conversation counters and fit +neither description. The four +API-key connections have none. That is the per-model dimension, populated, +without anybody declaring a plan in `provider_plans` first. + +**And it still is not `C_window`.** Every one of the 48 rows reads +`remaining_percentage = 100.0`, `is_exhausted = 0`, with `window_duration_ms` and +`raw_data` NULL throughout — an account nobody has worked hard enough to dent. +More important than the emptiness of the numbers is their *unit*: a percentage is +`1 − p`, the consumed fraction. It is the left-hand side of the calibration +procedure at the end of this page, not its answer. To get `C_window` in tokens +you still have to pair that percentage with the tokens you actually moved in the +same window. What OmniRoute gives you for free is the `p` — read by machine, per +model, instead of eyeballed off a progress bar. That is a real shortcut, and it +is worth exactly that much. + +`provider_quota_state` — the table that carries `token_limit`, the absolute +number — is still at 0. Whether OmniRoute ever writes it, and from which upstream +response, is `[A VERIFICAR: leia no fonte do gateway quem escreve em +provider_quota_state, e se depende de um plano declarado em provider_plans]`. +This synchronizer reads none of these tables: `git grep -n +'quota_snapshots\|provider_quota_state' -- src/` returns nothing. + +The same reservation applies to the per-connection knobs +`max_concurrent INTEGER` and `rate_limit_protection INTEGER DEFAULT 0`, which +are where `c` would be materialised per account: + +```bash +sqlite3 -header -column /tmp/omniroute.sqlite " +SELECT provider, auth_type, max_concurrent, rate_limit_protection, backoff_level, + expires_in +FROM provider_connections;" +``` + +``` +provider auth_type max_concurrent rate_limit_protection backoff_level expires_in +----------- --------- -------------- --------------------- ------------- ---------- +antigravity oauth 0 0 3599 +groq apikey 0 0 +openrouter apikey 0 0 +gemini apikey 0 0 +mistral apikey 0 0 +``` + +`[FONTE: saída do comando acima, mesmo instante 2026-09-13T02:20:53Z]` — one OAuth +account against four API keys, `max_concurrent` NULL on all five, +`rate_limit_protection` 0 on all five. The knob exists and nobody turned it. + +A note on granularity, since the sibling documentation reads differently: 9Router +keeps per-family holds as `modelLock_*` keys inside the connection JSON. **There +is no `modelLock_*` in OmniRoute.** Here the *hold* is one deadline for the whole +connection (`rate_limited_until`) — but do not conclude from that, as an earlier +version of this page did, that per-model granularity is absent. It is not: it +lives in `quota_snapshots.window_key`, which is populated, and it would live in +`provider_quota_state.model`, which is not. Two different tables, two different +answers, and only one of them empty. Do not port the sibling's paragraph across, +and do not port this one back. + +--- + +## Measure demand here, not only on the developer's laptop + +The script above reads Claude Code history on one machine. OmniRoute has +something better for a team, because it is per account: `usage_history` records +every call that went through the gateway with the exact token split the formula +needs `[FONTE: schema lido de diegosouzapw/omniroute:latest em execução, +2026-09-12]`. + +```bash +# Instalação local; num deploy em contêiner, copie primeiro como na seção acima +# (com o `-wal`, ou a contagem sai menor do que é). +sqlite3 -header -column ~/.omniroute/data/storage.sqlite " +SELECT provider, + count(*) AS requisicoes, + sum(tokens_input + tokens_cache_creation) AS entrada_que_conta, + sum(tokens_cache_read) AS leitura_de_cache, + sum(tokens_output) AS saida, + min(timestamp) AS de, + max(timestamp) AS ate +FROM usage_history GROUP BY provider ORDER BY requisicoes DESC;" +``` + +Run against the live database, it answers: + +``` +provider requisicoes entrada_que_conta leitura_de_cache saida de ate +---------- ----------- ----------------- ---------------- ----- ------------------------ ------------------------ +gemini 4 18 0 237 2026-09-12T22:47:04.218Z 2026-09-12T22:54:42.006Z +mistral 2 18 0 4 2026-09-12T22:47:03.788Z 2026-09-12T22:54:39.507Z +groq 1 18 0 2 2026-09-12T22:47:42.194Z 2026-09-12T22:47:42.194Z +openrouter 1 36 0 12 2026-09-12T22:48:00.963Z 2026-09-12T22:48:00.963Z +``` + +**That is eight rows from smoke tests, not a workload.** The query shape is +proven; the volume proves nothing. Mapped onto the formula: +`T_in = tokens_input + tokens_cache_creation`, `T_tot = T_in + tokens_cache_read`, +`T_out = tokens_output`, and `R_h` comes from grouping by +`strftime('%Y-%m-%dT%H', timestamp)` together with `connection_id`. + +Two limits on this source. It only sees traffic that went **through** the +gateway — a developer pointing Claude Code straight at their own subscription is +invisible to it. And `usage_history` competes with `call_logs` for the same +facts; `call_logs` carries the per-request row with `connection_id`, `status` +and the same token columns, which is the better source when you need to separate +successes from 429s. + +--- + +## The limit that is not capacity + +Everything above sizes **technical capacity**. None of it authorises sharing a +subscription, and the vendor text is explicit +`[FONTE: https://code.claude.com/docs/en/legal-and-compliance — lido em 2026-09-12]`: + +> "Advertised usage limits for Pro and Max plans assume **ordinary, individual +> usage** of Claude Code and the Agent SDK." + +> "**OAuth authentication is intended exclusively for purchasers** of Claude +> Free, Pro, Max, Team, and Enterprise subscription plans and is designed to +> support ordinary use of Claude Code and other native Anthropic applications." + +> "Anthropic does not permit third-party developers to offer Claude.ai login +> into their own applications, or to **route requests through Free, Pro, or Max +> plan credentials on behalf of their users**." + +> "**Customers may not pay for, resell, or intermediate Claude usage on their +> end users' behalf.** Each end user must authenticate with their own Anthropic +> API key, Claude subscription plan credentials, or 3P inference provider +> credential." + +That closes the original question with an answer that **does not depend on +measuring anything**: + +> **For Claude Pro/Max, `L = N`.** Twelve developers, twelve subscriptions, each +> bought and authenticated by its own holder. The gateway does not reduce that +> number. It exists for routing, cross-family fallback, credential renewal and +> observability over accounts that are **already individual** — and that is +> exactly where it pays for itself. + +The API-key sizing applies in full to the **API path** — an organisation key, +billed to the organisation, distributed internally. The same document allows it +in as many words: *"This does not restrict how customers provision and manage +their own API keys … for use by the customer's own authorized users."* + +For **Google AI Pro / Antigravity** and for **OpenAI** plans, the equivalent +terms were not read. `[A VERIFICAR: leia os termos de assinatura de cada um e +cite URL + data, como foi feito acima com a Anthropic]`. Do not assume symmetry +between vendors. + +One operational consequence, since it is the natural shape of any gateway with +every account registered: several sessions on one account draw no attention, but +several accounts leaving through one address do. OmniRoute models the fix as a +per-account egress binding, and a pool that is inactive or missing its address +fails silently back to the host's — the whole of +[Egress and Multi-Session](Egress-And-Multi-Session) is about that, and +[Egress Testing](Egress-Testing) is the bench that proves where traffic really +leaves from. + +--- + +## Credential renewal is not quota + +Two different clocks. Confusing them produces the wrong diagnosis, and this +repository exists partly because they were confused. + +| | Credential validity | Quota | +| :--- | :--- | :--- | +| Duration | 3,599 s ≈ 1 h, read off the connection | 5 h / weekly; 2 h per family, observed | +| Symptom | 401, "spontaneous" disconnection | 429 / 503 | +| Field | `expires_at` | `rate_limited_until` | +| Who fixes it | automatic renewal — what this synchronizer does | wait for the window, or buy a seat | +| Scales with the team? | **No** | **Yes** | + +The hour in the first column is not folklore: the gateway stores the lifetime the +provider handed it, and on the OAuth connection here it reads +`expires_in = 3599` — the query is in the quota-subsystem section, same output. +`[FONTE: `SELECT provider, auth_type, expires_in FROM provider_connections WHERE auth_type='oauth'`, 2026-09-13T02:20:53Z]` +That is **one** connection on **one** provider, so read it as "this credential +lasts an hour", not as "OAuth tokens last an hour". + +The "it logs itself out after an hour" complaint is a **storage format** problem, +not a shortage of quota: `expires_at` is a TEXT column that OmniRoute reads with +`new Date(...)`, so a numeric epoch written as text becomes an invalid date, the +gateway concludes the connection has no known expiry, and proactive renewal +stops for that connection. The synchronizer writes ISO-8601 there, the gateway's +own native format (`src/omini_rtksync/database.py:176-210`). **Neither clock +belongs in the capacity formula.** [Upstream Fixes](Upstream-Fixes) has the +gateway-side story of that parsing bug. + +--- + +## Reproducing everything on this page + +Both scripts are versioned here, because a number without a reproducible script +becomes, given enough time, an invented number. Check the claim before you trust +the page: `git ls-files tools/` has to list both of them. + +| Script | What it produces | +| :--- | :--- | +| `tools/measure_agent_usage.py` | The measured demand profile. Reads **only** the numeric `usage` fields, `message.id` and `timestamp` from Claude Code history — no conversation content is read, aggregated or printed. Deduplicates by `message.id`, and prints the inflation factor that deduplication removes. `--until ISO` freezes the corpus at a past instant, which is what makes a published number checkable. | +| `tools/sizing.py` | The two sizing tables and the burst slack. Its first four lines of output label every input as `medido`, `publicado` or `arbitrado`, with the exact measurement command — the chain from history to table is meant to be walked backwards. Replace the constants with your own and recalculate. | + +The numbers on this page come from three different places and the difference is +the whole point: + +| Class | Where it comes from | What it is worth | +| :--- | :--- | :--- | +| Published | a vendor page, with URL and date | quote it, do not average it | +| Measured | a script in this repository, with its command | reproducible — on **that** corpus, at **that** cut | +| Arbitrated | an operations choice (`c`, slack) | a worked example; measure your own | + +Anything that fits none of the three does not belong on the page. + +To find `C_window` for a subscription, the only place it surfaces is +**Settings → Usage**, which shows the progress bars for the five-hour and weekly +windows `[FONTE: https://support.claude.com/en/articles/11049741-what-is-the-max-plan — lido em 2026-09-12]`: + +1. Wait for the window to reset and note the time. +2. Work a typical stretch inside the window. +3. Read the consumed fraction `p` from the bar, and run the script restricted to + that period, summing **total** tokens moved — call it `D_measured`. On an + OAuth connection registered in OmniRoute, `quota_snapshots.remaining_percentage` + gives you `1 − p` per window key without the eyeballing — see the section + above for what it does and does not tell you. +4. `C_window ≈ D_measured / p`, in total tokens. + +It is a measurement with a stated procedure, not a guess. And it holds for *that* +plan, *that* model and *that* effort level — the vendor's own page says all four +factors move the result. + +Finally, when inspecting a stack's configuration to confirm what was injected, +always run `docker compose --no-interpolate config`. Without the flag the command +prints the secrets from the environment straight to your terminal. + +--- + +# Em português + +A pergunta chega sempre como uma conta de dividir: *somos doze, quantos planos +Max?* O problema é que **o divisor não é publicado por ninguém**. Nenhuma das +assinaturas de consumo envolvidas declara a capacidade absoluta de um assento — +o que existe é multiplicador relativo e janela de reset. Uma tabela dizendo "1 +licença atende 4 devs" só poderia sair de um número inventado, e o número +inventado seria repetido por anos por quem não tem como conferir. + +Então esta página entrega três coisas: a **fórmula**, a **demanda** medida de +verdade, e o **comando** que acha o divisor que falta no seu ambiente. + +## O que os fornecedores publicam + +Nada de absoluto, com uma exceção. A Anthropic publica janela de 5 h e +multiplicador (`Max 5x`, `Max 20x`) mais um limite semanal; a página de limites +diz que tamanho da conversa, recursos usados, modelo e nível de esforço afetam o +consumo. O Codex publica janela de cinco horas rolante e faixas por plano, e a +própria página avisa que não são limites fixos. O Gemini API remete ao painel. + +A exceção é o **Gemini Code Assist**: Standard **1.500** requisições **por +usuário por dia**, Enterprise **2.000**, e **2** requisições por segundo **por +usuário**. Quando o fornecedor finalmente imprime um número, ele vem carimbado +*por usuário* — não existe um pote de 1.500 que doze devs dividem, existem doze +potes de 1.500. Essa é a forma da resposta inteira. + +Fontes, todas lidas em 2026-09-12: a página do plano Max e a de limites da +Anthropic, a de preços do Codex, a de rate limits do Gemini API e a de quotas do +Gemini Code Assist, listadas com URL na seção em inglês. + +## Onde o número existe: a API + +A tabela de tiers da API da Anthropic é publicada `[FONTE: +https://platform.claude.com/docs/en/api/rate-limits — lido em 2026-09-12]`: +Start 1.000 RPM / 2 M entrada por minuto / 400 mil saída por minuto / US$ 500 de +teto mensal; Build 5.000 / 5 M / 1 M / US$ 1.000; Scale 10.000 / 10 M / 2 M / +US$ 200.000. A mesma página avisa que organizações novas podem começar num tier +de avaliação **abaixo** desses limites, e que um salto brusco de uso dispara +*acceleration limits* — doze devs entrando no mesmo dia acionam os dois. + +E o detalhe que muda a conta: **só a entrada não-cacheada conta para o limite de +tokens por minuto da API**. No histórico medido, **98,3% de todos os tokens +trafegados são leitura de cache**, e a razão entre a mediana da entrada total e a +da entrada que conta é **21,1×**. Quem dimensiona o caminho de API somando cache +lido compra vinte vezes mais do que precisa. **Cuidado com o escopo**: essa +regra é da página da API. Nada do que foi lido diz que o medidor de 5 h de uma +assinatura ignora leitura de cache — por isso a tabela de assinatura usa **token +total**. + +## A fórmula + +`U_sim = N × c`, e a demanda dentro da janela é +`D = U_sim × R_h × tokens_por_requisição × W_h × (1 + F)` — com entrada +não-cacheada no caminho de API e token total no caminho de assinatura. +`L = max(ceil(D / C_janela), L_rajada)`, com a rajada conferida à parte, porque +quota por janela e limite por minuto são tetos diferentes e o segundo estoura +primeiro. **A unidade de `D` e a de `C_janela` têm de ser a mesma** — misturar as +duas é o erro silencioso mais provável aqui. + +## Demanda medida + +**Isto é um instantâneo, não uma constante.** O histórico do Claude Code é um +corpus vivo: cresce a cada sessão, e o mesmo script amanhã na mesma máquina dá +outro número sem que nenhum dos dois esteja errado. O que torna um número +publicado conferível é o corte — os arquivos de sessão são append-only, então +tudo que está antes de um instante passado parou de se mexer. Daí o `--until`: + +```bash +./.venv/bin/python tools/measure_agent_usage.py --until 2026-09-13T00:00:00Z +``` + +`[FONTE: saída do comando acima, histórico de UMA máquina, corte em 2026-09-13T00:00:00Z]` +— 114 sessões, 6.648 turnos únicos: `T_in` mediana 4.178 e p90 8.227; `T_out` +mediana 706 e p90 1.361; `T_tot` mediana 88.353 e p90 303.214; `R_h` mediana 194 +req/h e p90 343; pico de 13 sessões simultâneas. A saída completa, com os nomes +de campo do script, está na seção em inglês. Sem `--until` o script lê o +histórico de hoje — que é o que você quer ao dimensionar o seu próprio time. + +Quatro ressalvas viajam junto, sempre: + +1. **A deduplicação não é opcional.** Uma resposta da API é gravada em várias + linhas `type: "assistant"`, repetindo o mesmo `message.id` e o mesmo `usage`. + Contar linhas infla tudo em **2,16×** — 34.305 linhas de resposta contra + 15.905 ids distintos. Isso deixou de ser afirmação em prosa: o próprio script + conta e imprime o fator nas últimas três linhas da saída. +2. **21,1× é razão entre duas medianas**, não a mediana das razões — serve para + ordem de grandeza, não para contabilidade. +3. **A máquina medida roda orquestração com subagentes.** O pico de 13 sessões é + paralelismo de um operador só. Trate `R_h ≈ 194 req/h` como **sessão de + agente**, uma requisição a cada ~19 s, não como "um dev digitando". Rode o + script no seu ambiente. +4. **É uma máquina, um operador, um jeito de trabalhar.** O corte torna o número + *auditável*, não *geral*. As tabelas abaixo são exemplo resolvido do método, + não tabela de consulta. + +## Dimensionamento + +`[FONTE: `./.venv/bin/python tools/sizing.py`, com as entradas medidas acima]` — +`W_h = 5 h` é publicado. Os outros dois parâmetros, não: + +> **`c = 0,6` e folga de 30% são arbitrados.** Ninguém mediu isso aqui. `c` é a +> fração do time pedindo no mesmo instante, e a tabela da fórmula acima já diz +> que ele tem de ser medido — no seu time, contando sessões simultâneas, não +> lendo esta página. O `tools/sizing.py` imprime esse valor sob o rótulo +> `arbitrado` nas primeiras linhas da saída justamente para que ele nunca seja +> citado como achado. É o parâmetro que mais move o resultado: medir +> concorrência vale mais do que qualquer tabela desta página. + +**Caminho de assinatura**, em token total, que é para o que o OmniRoute existe: + +| Perfil | Devs | `U_sim` | Demanda numa janela de 5 h | Licenças | +| :--- | ---: | ---: | ---: | :--- | +| mediana | 3 | 1,8 | 202.146.118 tokens | `ceil(D / C_janela)` | +| mediana | 12 | 7,2 | 808.584.473 tokens | `ceil(D / C_janela)` | +| mediana | 40 | 24,0 | 2.695.281.576 tokens | `ceil(D / C_janela)` | +| p90 | 3 | 1,8 | 1.222.289.932 tokens | `ceil(D / C_janela)` | +| p90 | 12 | 7,2 | 4.889.159.730 tokens | `ceil(D / C_janela)` | +| p90 | 40 | 24,0 | 16.297.199.100 tokens | `ceil(D / C_janela)` | + +A coluna da direita fica simbólica **porque nenhum fornecedor publica +`C_janela`**. É exatamente aí que método se diferencia de tabela inventada: o +método diz o que falta, em que unidade e como obter. + +**Caminho de chave de API** — quatro das cinco conexões da instalação +inspecionada são chaves, não contas OAuth (o comando que mostra isso e a saída +dele estão na seção do subsistema de quota, mais abaixo). Aqui o teto é publicado +e a conta fecha, em entrada não-cacheada: + +| Perfil | Devs | `c` | `U_sim` | RPM | Entrada/min | Saída/min | Tier mínimo | +| :--- | ---: | ---: | ---: | ---: | ---: | ---: | :--- | +| mediana | 3 | 0,6 | 1,8 | 8 | 31.611 | 5.342 | Start | +| mediana | 12 | 0,6 | 7,2 | 30 | 126.443 | 21.366 | Start | +| mediana | 12 | 1,0 | 12,0 | 50 | 210.738 | 35.611 | Start | +| mediana | 40 | 0,4 | 16,0 | 67 | 280.984 | 47.481 | Start | +| mediana | 40 | 0,6 | 24,0 | 101 | 421.477 | 71.221 | Start | +| mediana | 40 | 1,0 | 40,0 | 168 | 702.461 | 118.702 | Start | +| p90 | 3 | 0,6 | 1,8 | 13 | 110.053 | 18.206 | Start | +| p90 | 12 | 0,6 | 7,2 | 54 | 440.210 | 72.824 | Start | +| p90 | 40 | 0,6 | 24,0 | 178 | 1.467.368 | 242.748 | Start | +| p90 | 40 | 1,0 | 40,0 | 297 | 2.445.613 | 404.580 | **Build** | + +Um time de quarenta no perfil mediano **ainda cabe no tier Start**. Só o canto +extremo pede Build, e quem empurra é entrada por minuto (2,45 M contra 2,00 M), +não RPM. O teto de rajada raramente dói: contra os 1.000 RPM do Start sobram +132× para 3 devs, 33× para 12 e 9× para 40. E **`c` move mais o resultado que +`N`** — 40 devs a `c=0,4` e 12 a `c=1,0` caem no mesmo tier; medir concorrência +vale mais que contar cadeiras. + +## O que este sincronizador te mostra sobre isso + +**O sinal de saturação.** O OmniRoute grava a trava na coluna +`rate_limited_until` de `provider_connections`, ou como `rateLimitedUntil` dentro +da coluna JSON `data` em instalações migradas. O `get_all_connections` projeta as +duas numa chave só, `rateLimitedUntil` (`src/omini_rtksync/database.py:160`), e +`ConnectionRecord.rate_limit_active` a lê como **prazo, não bandeira** +(`src/omini_rtksync/models.py:157-169`): ela guarda o instante em que a janela do +provedor reabre, e tratar a presença do campo como "limitada" deixava a conexão +amarela para sempre depois do primeiro 429. Com o prazo no futuro, o +`health_status` vira `rate_limited` e a tela pinta o badge **Rate limit** na +coluna de saúde da tabela de conexões (`src/omini_rtksync/i18n.py:206`, +`src/omini_rtksync/render.py:36`). Onde essa coluna fica: [Dashboard](Dashboard). + +**O contador que fecha o laço.** Vencido o prazo, o sincronizador limpa a trava e +registra `Trava de rate limit vencida removida` +(`src/omini_rtksync/cli.py:143-149`). Essas entradas se acumulam no modal de +**Logs** do agendador. Contá-las por conta por dia durante uma semana é a única +evidência que de fato fecha a conta: conta que trava todo dia está +subdimensionada, conta que nunca trava é folga que absorve mais gente. Todo o +resto desta página é projeção. + +**O headcount.** Os cartões de métrica trazem **Contas OAuth** e **Chaves de +API** (`src/omini_rtksync/i18n.py:149-150`). O primeiro é o `L` da seção de +termos de uso; o segundo é o caminho dimensionado por tier. + +**A alavanca mais barata.** **Combos registrados** e a seção **Combos de +resiliência** listam cada combo e sua cascata de modelos, lidos da tabela +`combos` do OmniRoute como `name`, `kind` e `models` +(`src/omini_rtksync/database.py:416-440`). A quota pode se esgotar **por família +de modelo** com a conta seguindo saudável: durante um bloqueio do Antigravity, +todos os modelos da família Gemini devolveram 503 enquanto os Claude e os de +peso aberto da mesma conta continuaram respondendo `[FONTE: +pathbit-ai-for-devs/0002_claude_gravity_utilizando_9router/article/ARTICLE.md:684-720]`. +Um combo de fallback que atravessa famílias multiplica a capacidade efetiva +**sem comprar licença**, e é a única alavanca aqui que não esbarra no limite de +termos de uso. A instalação inspecionada tinha **0 combos** registrados — o +comando de contagem e a saída completa estão na seção do subsistema de quota. + +**Automação.** O `/api/status` devolve `connectionsCount` e `combosCount` +(`src/omini_rtksync/web.py:401-412`), e `ominirtksync --status --db-path +~/.omniroute/data/storage.sqlite` imprime o mesmo inventário. O produto tenta +cinco caminhos, nesta ordem, e fica com o primeiro que existir: +`/app/data/storage.sqlite`, `/app/data/data.sqlite`, +`~/.omniroute/data/storage.sqlite`, `~/.omniroute/storage.sqlite`, +`~/.omniroute/data.sqlite` +`[FONTE: src/omini_rtksync/config.py:221-227]`. Ou seja, o `data/` do meio não é +exigido — ele só vem antes. Escreva o caminho que a sua instalação realmente +tem: num contêiner é o primeiro, numa instalação local costuma ser o terceiro. + +**O que o painel não mostra, e não vai fingir que mostra:** contagem de tokens, +percentual de quota consumida, janela restante ou `max_concurrent`. O +sincronizador lê saúde de credencial; não é produto de medição. + +**Uma lacuna, registrada:** `provider_connections` tem também +`backoff_level INTEGER DEFAULT 0` `[FONTE: schema lido de +diegosouzapw/omniroute:latest em execução, 2026-09-12]`. O sincronizador limpa +`rate_limited_until` quando o prazo vence (`src/omini_rtksync/database.py:343-345`) +mas **nunca zera `backoff_level`** — a string não aparece em lugar nenhum sob +`src/`. O irmão 9RTKSync zera o equivalente ao limpar a trava `[FONTE: +9RTKSync/src/nine_rtksync/normalizer.py:87 — `data["backoffLevel"] = 0`]`. Se o +OmniRoute decai o nível sozinho é `[A VERIFICAR: leia o tratamento de +backoff_level no fonte do gateway]`. Fica anotado porque um nível de backoff +velho faria uma conta parecer mais saturada do que está. + +## O lugar com o formato de `C_janela` — existe, e está parcialmente preenchido + +O schema do OmniRoute traz um subsistema de quota inteiro — e schema, ao +contrário de contagem de linha, é propriedade durável: vem da imagem, não do +tráfego, e sai de um comando só (`SELECT sql FROM sqlite_master`, na seção em +inglês). O centro dele é +`provider_quota_state(connection_id, model)` com `tokens_used`, `token_limit`, +`window_start` e `window_reset`: é o `C_janela` em forma de banco, e na +granularidade que a observação do Antigravity diz ser a necessária — por família +de modelo, não por conta. Ao lado ficam `quota_snapshots` (`window_key`, +`remaining_percentage`, `is_exhausted`, `next_reset_at`, `window_duration_ms`, +`raw_data`), `provider_plans` (`dimensions_json`, `source` restrito a `auto` ou +`manual`) e `quota_pools` com `quota_pool_connections`. Repare no `window_key`: +ele é a dimensão por modelo do próprio `quota_snapshots`. + +**Contar exige dois comandos, e o segundo é fácil de errar.** O contêiner não tem +`sqlite3`, e o banco roda em modo WAL — um `docker cp` só do `storage.sqlite` lê +arquivo defasado e subconta o que ainda está no log de escrita (medido numa cópia +só, lida duas vezes: 188 `call_logs` sem o `-wal` ao lado, 209 com ele — 21 +linhas, 10% da tabela, invisíveis ao comando mais curto, e sem erro nenhum na +tela). Os comandos com o `-wal` +junto, e a saída completa, estão na seção em inglês. O resultado, no instante +2026-09-13T02:20:53Z: `provider_quota_state` 0, `provider_plans` 0, `quota_pools` +0, `daily_usage_summary` 0, `combos` 0, `usage_history` 8, `provider_connections` +5, `call_logs` 209 — e **`quota_snapshots` com 48 linhas**. + +**Leia isso como instante, não como propriedade.** `call_logs` e +`quota_snapshots` sobem conforme passa tráfego: neste mesmo contêiner, `call_logs` +sobem enquanto você lê. Rode o comando duas vezes, com alguns minutos de +intervalo, e as duas contagens diferem — é essa a demonstração. Leituras +anteriores desta mesma instalação não são reproduzíveis por você e, por isso, +não são citadas. Os zeros são a parte durável; o 48 +é a parte interessante, porque uma leitura anterior desta mesma instalação pegou +`quota_snapshots` em 0 e a página concluiu, errado, que nada nunca preenche +aquilo. O que houve é que o primeiro snapshot foi gravado às 23:29:23.587Z, +*depois* da última linha de `usage_history`, às 22:54Z. A medição estava certa; a +conclusão tirada dela, não. + +Então o achado honesto é o oposto de "vazio": **o OmniRoute preenche +`quota_snapshots` sozinho, por chave de janela, na conexão OAuth — e só nela.** +São 16 `window_key` distintos numa conta só, misturando nomes de modelo +(`claude-sonnet-4-6`, `gemini-3.1-pro-high`, `gpt-oss-120b-medium`) e chaves +agregadas (`claude_gpt_weekly`), mais duas (`chat_20706`, `chat_23310`) que +parecem contadores por conversa e não se encaixam em nenhuma das descrições. As +quatro conexões por chave de API não têm +nenhum. É a dimensão por modelo, populada, sem ninguém ter declarado plano em +`provider_plans` antes. + +**E mesmo assim não é o `C_janela`.** Todas as 48 linhas trazem +`remaining_percentage = 100.0`, `is_exhausted = 0`, com `window_duration_ms` e +`raw_data` NULL — conta que ninguém trabalhou o bastante para arranhar. Mais +importante que o número vazio é a **unidade**: porcentagem é `1 − p`, a fração +consumida. É o lado esquerdo do procedimento de calibração do fim desta página, +não a resposta dele. Para ter `C_janela` em tokens, ainda é preciso casar essa +porcentagem com o que você de fato trafegou na mesma janela. O que o OmniRoute dá +de graça é o `p` — lido por máquina, por modelo, em vez de estimado a olho numa +barra de progresso. É um atalho real, e vale exatamente isso. + +O `provider_quota_state`, que é quem carrega o `token_limit` — o número absoluto +—, continua em 0. Se o OmniRoute chega a escrever ali, e a partir de qual +resposta do provedor, é `[A VERIFICAR: leia no fonte do gateway quem escreve em +provider_quota_state, e se depende de plano declarado em provider_plans]`. Este +sincronizador não lê nenhuma dessas tabelas: `git grep -n +'quota_snapshots\|provider_quota_state' -- src/` não devolve nada. + +Vale a mesma ressalva para `max_concurrent` e `rate_limit_protection`, que são +onde o `c` seria materializado por conta: `max_concurrent` NULL nas cinco +conexões e `rate_limit_protection` em 0 nas cinco, pelo mesmo comando, no mesmo +instante. O botão existe e ninguém girou. + +**Sobre granularidade:** o 9Router guarda travas por família como chaves +`modelLock_*` dentro do JSON da conexão. **No OmniRoute não existe `modelLock_*`.** +Aqui a *trava* é um prazo único para a conexão inteira — mas não conclua daí, +como uma versão anterior desta página concluiu, que falta granularidade por +modelo. Não falta: ela vive em `quota_snapshots.window_key`, que está preenchido, +e viveria em `provider_quota_state.model`, que não está. Duas tabelas, duas +respostas, e só uma delas vazia. Não porte o parágrafo do irmão para cá, e não +porte este de volta. + +## Meça a demanda aqui, não só no laptop do dev + +O script lê o histórico de uma máquina. O OmniRoute tem algo melhor para um +time, porque é por conta: `usage_history` registra cada chamada que passou pelo +gateway com exatamente a separação de tokens que a fórmula pede. + +```bash +# Instalação local; em contêiner, copie antes com o `-wal` junto, como na seção +# do subsistema de quota -- sem ele a contagem sai menor do que é. +sqlite3 -header -column ~/.omniroute/data/storage.sqlite " +SELECT provider, + count(*) AS requisicoes, + sum(tokens_input + tokens_cache_creation) AS entrada_que_conta, + sum(tokens_cache_read) AS leitura_de_cache, + sum(tokens_output) AS saida, + min(timestamp) AS de, + max(timestamp) AS ate +FROM usage_history GROUP BY provider ORDER BY requisicoes DESC;" +``` + +A saída real contra o banco vivo está na seção em inglês: quatro provedores, +oito requisições. **São oito linhas de teste de fumaça, não carga de trabalho** — +a forma da consulta está provada, o volume não prova nada. No mapeamento: +`T_in = tokens_input + tokens_cache_creation`, +`T_tot = T_in + tokens_cache_read`, `T_out = tokens_output`, e `R_h` sai de +agrupar por `strftime('%Y-%m-%dT%H', timestamp)` junto com `connection_id`. + +Dois limites: a tabela só enxerga o que passou **pelo** gateway — um dev +apontando o Claude Code direto para a assinatura dele é invisível — e +`call_logs` é a fonte melhor quando você precisa separar sucesso de 429, porque +guarda a linha por requisição com `status` e `connection_id`. + +## O limite que não é capacidade + +Tudo acima dimensiona **capacidade técnica**. Nada disso autoriza compartilhar +assinatura, e o texto do fornecedor é explícito `[FONTE: +https://code.claude.com/docs/en/legal-and-compliance — lido em 2026-09-12]`: os +limites anunciados de Pro e Max pressupõem *"ordinary, individual usage"*; a +autenticação OAuth é *"intended exclusively for purchasers"* dos planos; a +Anthropic não permite *"route requests through Free, Pro, or Max plan +credentials on behalf of their users"*; e *"Customers may not pay for, resell, or +intermediate Claude usage on their end users' behalf. Each end user must +authenticate with their own Anthropic API key, Claude subscription plan +credentials, or 3P inference provider credential."* + +Isso fecha a pergunta original com uma resposta que **não depende de medir nada**: + +> **Para Claude Pro/Max, `L = N`.** Doze devs, doze assinaturas, cada uma +> comprada e autenticada pelo seu titular. O gateway não reduz esse número. Ele +> serve para roteamento, fallback entre famílias, renovação de credencial e +> observabilidade sobre contas que **já são individuais** — e é exatamente aí que +> paga o próprio custo. + +O dimensionamento por tier se aplica integralmente ao **caminho de API** — chave +da organização, cobrada ao titular, distribuída internamente. O mesmo documento +ressalva isso como permitido: *"This does not restrict how customers provision +and manage their own API keys … for use by the customer's own authorized +users."* + +Para **Google AI Pro / Antigravity** e para os planos da **OpenAI**, os termos +equivalentes não foram lidos: `[A VERIFICAR: leia os termos de assinatura de cada +um e cite URL + data, como foi feito com a Anthropic]`. Não presuma simetria +entre fornecedores. + +Uma consequência operacional, porque é o formato natural de um gateway com todas +as contas cadastradas: várias sessões na mesma conta não incomodam; o que chama +atenção é o inverso, várias contas saindo pelo mesmo endereço. O OmniRoute modela +o remédio como vínculo de saída por conta, e um pool inativo ou sem endereço cai +em silêncio para o endereço do host — é disso que trata +[Egress and Multi-Session](Egress-And-Multi-Session), e +[Egress Testing](Egress-Testing) é a bancada que prova por onde o tráfego sai de +verdade. + +## Renovação de credencial não é quota + +Dois relógios diferentes; confundi-los produz o diagnóstico errado. + +| | Validade da credencial | Quota | +| :--- | :--- | :--- | +| Duração | 3.599 s ≈ 1 h, lido da conexão | 5 h / semanal; 2 h por família, observado | +| Sintoma | 401, desconexão "espontânea" | 429 / 503 | +| Campo | `expires_at` | `rate_limited_until` | +| Quem resolve | renovação automática — o que este sincronizador faz | esperar a janela, ou mais uma assinatura | +| Escala com o time? | **Não** | **Sim** | + +A hora da primeira coluna não é folclore: o gateway guarda a validade que o +provedor entregou, e na conexão OAuth daqui ela vem como `expires_in = 3599` +`[FONTE: `SELECT provider, auth_type, expires_in FROM provider_connections WHERE auth_type='oauth'`, 2026-09-13T02:20:53Z]`. +É **uma** conexão de **um** provedor: leia como "esta credencial dura uma hora", +não como "token OAuth dura uma hora". + +O "desconecta sozinho depois de uma hora" é **formato de gravação**, não falta de +cota: `expires_at` é coluna TEXT lida com `new Date(...)`, e um epoch numérico +gravado como texto vira data inválida, o gateway conclui que a conexão não tem +expiração conhecida e para de renovar preventivamente. O sincronizador grava +ISO-8601, o formato nativo do próprio gateway +(`src/omini_rtksync/database.py:176-210`). **Nenhum dos dois relógios entra na +fórmula.** O [Upstream Fixes](Upstream-Fixes) conta o lado do gateway nesse bug +de parsing. + +## Para reproduzir + +Os dois scripts estão versionados aqui, porque número sem script que o reproduza +vira, com o tempo, número inventado. Confira antes de confiar na página: o +`git ls-files tools/` tem de listar os dois. + +`tools/measure_agent_usage.py` produz o perfil de demanda — lê **apenas** os +campos numéricos de `usage`, o `message.id` e o `timestamp`, sem tocar em +conteúdo de conversa, deduplica por `message.id` e imprime o fator de inflação +que a deduplicação remove. O `--until ISO` congela o corpus num instante passado, +e é isso que torna um número publicado conferível. `tools/sizing.py` resolve as +tabelas acima e rotula cada entrada como `medido`, `publicado` ou `arbitrado` nas +primeiras linhas da saída, com o comando exato da medição: a cadeia da medição +até a tabela é para ser percorrida de trás para frente. Troque as constantes +pelas suas e recalcule. + +Os números desta página vêm de três lugares diferentes, e a diferença é o ponto +inteiro: **publicado** (página do fornecedor, com URL e data — cite, não faça +média), **medido** (script deste repositório, com o comando — reproduzível +*naquele* corpus, *naquele* corte) e **arbitrado** (escolha de operação, como `c` +e a folga — exemplo resolvido, meça o seu). O que não couber em nenhum dos três +não entra na página. + +Para achar `C_janela` de uma assinatura, o único lugar onde ela aparece é +**Settings → Usage**, com as barras da janela de 5 h e da semanal: espere o +reset, trabalhe uma jornada típica dentro da janela, leia a fração `p` consumida +na barra, rode o script restrito ao período somando o token **total** trafegado +(`D_medido`) e faça `C_janela ≈ D_medido / p`. É medição com procedimento +declarado, não palpite — e vale para *aquele* plano, *aquele* modelo e *aquele* +nível de esforço. + +Por fim, ao conferir o que foi injetado numa stack, rode sempre +`docker compose --no-interpolate config`. Sem a flag o comando despeja os +segredos do ambiente direto no seu terminal. diff --git a/docs/wiki/Remote-Access.md b/docs/wiki/Remote-Access.md index 6c20cbe..3b3f5be 100644 --- a/docs/wiki/Remote-Access.md +++ b/docs/wiki/Remote-Access.md @@ -74,10 +74,9 @@ do not control, and you accept that the address is public to whoever has it. internet and your accounts is `REQUIRE_LOGIN` and `REQUIRE_API_KEY`. - The address changes every time the tunnel is re-enabled, unless you bring your own named Cloudflare tunnel. -- The dashboard blocks the button while login is off — but that gate lives in the - screen. The gateway's own `POST /api/tunnel/enable` does not re-check it, so a script or an - extension can enable the tunnel while login is still off. Set the two flags - first and the question does not arise. +- Running `cloudflared` yourself means **no screen gates the tunnel**: there is no + dashboard check to warn you that login is still off. The command publishes the + gateway exactly as it is. Set the two flags first and the question does not arise. --- @@ -201,11 +200,18 @@ A ordem, portanto, não é preferência: 3. só então, o túnel ou o Tailscale ``` -## Opção 1 — túnel Cloudflare (nativo do 9Router) +## Opção 1 — túnel Cloudflare (sem botão nativo aqui) -A tela **API Endpoint** tem o botão `Tunnel`. Ele registra um quick tunnel da -Cloudflare e devolve um endereço público `https://…trycloudflare.com` que -alcança o gateway sem abrir porta nenhuma no seu roteador. +O OmniRoute não tem botão de túnel próprio — isso é recurso do 9Router. Para +obter o mesmo resultado, rode o `cloudflared` você mesmo contra a porta +publicada: + +```bash +cloudflared tunnel --url http://127.0.0.1:8082 +``` + +Ele imprime um endereço público `https://…trycloudflare.com` que alcança o seu +gateway sem abrir porta nenhuma no seu roteador. **Quando serve:** você precisa de uma URL alcançável de qualquer lugar, inclusive de dispositivos que você não controla, e aceita que o endereço seja @@ -213,16 +219,16 @@ público para quem o tiver. **O que saber:** a URL é pública e não há lista de permissão — entre a internet e as suas contas existem apenas `REQUIRE_LOGIN` e `REQUIRE_API_KEY`. O endereço -muda a cada reativação, a menos que você use um túnel nomeado seu. E o bloqueio -do botão enquanto o login está desligado vive **na tela**: o `POST -/api/tunnel/enable` do gateway não reavalia a condição. Ligue as duas variáveis antes e a +muda a cada reativação, a menos que você use um túnel nomeado seu. E rodar o +`cloudflared` à mão significa que **nenhuma tela segura o túnel**: não há +verificação de painel para avisar que o login continua desligado — o comando +publica o gateway exatamente como ele está. Ligue as duas variáveis antes e a questão não se coloca. -## Opção 2 — Tailscale (nativo do 9Router, e o que preferir) +## Opção 2 — Tailscale (o que preferir) -A mesma tela tem o botão `Tailscale`, que instala e conecta o daemon. A sua -máquina entra na sua tailnet e o gateway passa a ser alcançável num endereço -`100.x.y.z`, ou num nome MagicDNS como `http://seu-host:20128`. +A sua máquina entra na sua tailnet e o gateway passa a ser alcançável num +endereço `100.x.y.z`, ou num nome MagicDNS como `http://seu-host:8082`. **Quando serve:** quase sempre. Só os dispositivos que você cadastrou alcançam o gateway — o endereço não é público e não há o que um estranho descubra. diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index b4c0c38..9225497 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -10,11 +10,12 @@ Concrete symptoms, what they actually mean, and what to do. `REFRESH_MARGIN` (default 900 s = 15 min). A connection showing *24 min* remaining is correctly left alone — renewing early would burn refresh-token rotations for nothing. -The dashboard states this per connection, in the **Renewal diagnosis** column: +The dashboard states this per connection, in the details modal the **Details** button on the +connections table opens: > Outside the 15 min margin: renewal expected in ~9 min -**When it *is* a problem:** the diagnosis column says something else. +**When it *is* a problem:** the diagnosis in that modal says something else. | Diagnosis | Meaning | Action | | :--- | :--- | :--- | diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 6527ce9..933fcbe 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -8,6 +8,7 @@ - [Logging](Logging) - [Architecture](Architecture) - [Egress and Multi-Session](Egress-And-Multi-Session) +- [Licensing and Capacity](Licensing-And-Capacity) - [Remote Access](Remote-Access) - [Egress Testing](Egress-Testing) - [Troubleshooting](Troubleshooting) diff --git a/tools/measure_agent_usage.py b/tools/measure_agent_usage.py new file mode 100644 index 0000000..540f72d --- /dev/null +++ b/tools/measure_agent_usage.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Mede o perfil de consumo real de um agente a partir do historico do Claude Code. + +Le APENAS os campos numericos de `usage`, o `message.id` e o `timestamp`. +Nenhum conteudo de conversa e lido, agregado ou impresso. + +Cuidado que muda o resultado: uma unica resposta da API costuma ser gravada em +VARIAS linhas `type: "assistant"` (texto + cada bloco de ferramenta), todas com +o mesmo `message.id` e o mesmo objeto `usage`. Contar linhas infla requisicoes e +tokens em ~2x. Aqui cada `message.id` conta uma vez, e o fator de inflacao e +impresso no final, medido -- nao estimado. + +REPRODUTIBILIDADE. O historico e um corpus VIVO: ele cresce a cada sessao, entao +rodar sem corte hoje e amanha da numeros diferentes, e nenhum dos dois esta +errado. Para publicar um numero que outra pessoa consiga conferir, use +`--until`: os arquivos de sessao sao append-only, logo o recorte ate um instante +passado nao muda mais (a nao ser que alguem apague sessoes antigas do disco). + + ./.venv/bin/python tools/measure_agent_usage.py --until 2026-09-13T00:00:00Z + +Sem `--until` o script le o historico inteiro, que e o que voce quer quando esta +medindo a SUA maquina para dimensionar o SEU time. +""" + +import argparse +import glob +import json +import os +import statistics +from datetime import datetime, timedelta, timezone + +HISTORY_ROOT = os.path.expanduser("~/.claude/projects") +IDLE_GAP = timedelta(minutes=20) # lacuna que encerra uma "hora ativa" +MIN_TURNS_PER_SESSION = 10 # sessao curta demais nao diz nada sobre ritmo +MIN_ACTIVE_SECONDS = 300 + + +def parse_instant(text): + """Le um instante ISO-8601 e devolve sempre com fuso, para poder comparar.""" + moment = datetime.fromisoformat(str(text).replace("Z", "+00:00")) + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return moment + + +def read_session(path, until=None, tally=None): + """Devolve os turnos unicos de um arquivo de sessao, ordenados no tempo. + + `tally` acumula, sobre TODOS os arquivos e antes de qualquer filtro de + sessao, o numero de linhas de resposta e o numero de `message.id` distintos: + e a razao entre esses dois que mede a inflacao de contar linha por linha. + """ + turns = {} + try: + with open(path, "r", encoding="utf-8", errors="ignore") as handle: + for line in handle: + if '"usage"' not in line: + continue + try: + record = json.loads(line) + except Exception: + continue + if record.get("type") != "assistant": + continue + message = record.get("message") or {} + usage = message.get("usage") or {} + if not isinstance(usage, dict): + continue + fresh_input = usage.get("input_tokens") or 0 + cache_write = usage.get("cache_creation_input_tokens") or 0 + cache_read = usage.get("cache_read_input_tokens") or 0 + output = usage.get("output_tokens") or 0 + if fresh_input + cache_write + cache_read + output <= 0: + continue + try: + when = parse_instant(record.get("timestamp")) + except Exception: + continue + # O corte vale por TURNO, nao por arquivo: uma sessao que comecou + # antes e continuou depois entra so com a parte anterior ao corte. + if until is not None and when >= until: + continue + # Deduplicacao: uma resposta da API = um message.id, nao uma linha. + # Entre as linhas que repetem o id, fica a de maior contagem: as + # primeiras podem trazer `output_tokens` ainda parcial. + message_id = message.get("id") or f"{path}:{when.isoformat()}" + if tally is not None: + tally["lines"] += 1 + tally["ids"].add(message_id) + candidate = (when, fresh_input + cache_write, output, cache_read) + previous = turns.get(message_id) + if previous is None or sum(candidate[1:]) > sum(previous[1:]): + turns[message_id] = candidate + except Exception: + return [] + return sorted(turns.values(), key=lambda t: t[0]) + + +def active_seconds(turns): + """Tempo em que o agente esteve de fato requisitando, ignorando pausas longas.""" + total = 0.0 + for previous, current in zip(turns, turns[1:]): + delta = current[0] - previous[0] + if timedelta(0) <= delta <= IDLE_GAP: + total += delta.total_seconds() + return total + + +def percentile(values, fraction): + ordered = sorted(values) + if not ordered: + return float("nan") + index = int(round(fraction * (len(ordered) - 1))) + return ordered[max(0, min(len(ordered) - 1, index))] + + +parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) +parser.add_argument( + "--until", + metavar="ISO", + help="Ignora turnos a partir deste instante ISO-8601 (ex.: 2026-09-13T00:00:00Z). " + "E o que torna a medicao reproduzivel: o historico so cresce para a frente.", +) +args = parser.parse_args() +cutoff = parse_instant(args.until) if args.until else None + +billable_input, output_tokens, cached_input, total_input = [], [], [], [] +request_rates, session_spans = [], [] +grand_billable = grand_output = grand_cached = grand_turns = 0 +tally = {"lines": 0, "ids": set()} + +for session_path in glob.glob(os.path.join(HISTORY_ROOT, "**", "*.jsonl"), recursive=True): + turns = read_session(session_path, until=cutoff, tally=tally) + if len(turns) < MIN_TURNS_PER_SESSION: + continue + elapsed = active_seconds(turns) + if elapsed < MIN_ACTIVE_SECONDS: + continue + count = len(turns) + billable_input.append(sum(t[1] for t in turns) / count) + output_tokens.append(sum(t[2] for t in turns) / count) + cached_input.append(sum(t[3] for t in turns) / count) + total_input.append(sum(t[1] + t[3] for t in turns) / count) + request_rates.append(count / (elapsed / 3600.0)) + session_spans.append((turns[0][0], turns[-1][0])) + grand_billable += sum(t[1] for t in turns) + grand_output += sum(t[2] for t in turns) + grand_cached += sum(t[3] for t in turns) + grand_turns += count + +# Imprime o caminho SEM expandir o `~`: a saida deste script vai colada para +# dentro da wiki, e caminho de home carrega o nome de usuario da maquina. +print("historico lido : ~/.claude/projects/**/*.jsonl") +print(f"corte (--until) : {args.until or 'nenhum -- corpus vivo, nao reproduz'}") +print(f"sessoes analisadas : {len(request_rates)}") +print(f"turnos unicos (dedup por message.id) : {grand_turns}") +print(f"T_in entrada que conta p/ ITPM mediana : {statistics.median(billable_input):.0f}") +print(f"T_in entrada que conta p/ ITPM p90 : {percentile(billable_input, 0.90):.0f}") +print(f"T_out saida mediana : {statistics.median(output_tokens):.0f}") +print(f"T_out saida p90 : {percentile(output_tokens, 0.90):.0f}") +print(f"T_cache leitura de cache mediana : {statistics.median(cached_input):.0f}") +print(f"T_tot entrada total (conta+cache) mediana : {statistics.median(total_input):.0f}") +print(f"T_tot entrada total (conta+cache) p90 : {percentile(total_input, 0.90):.0f}") +print(f"R_h requisicoes por hora ativa mediana : {statistics.median(request_rates):.0f}") +print(f"R_h requisicoes por hora ativa p90 : {percentile(request_rates, 0.90):.0f}") +total_all = grand_billable + grand_output + grand_cached +print(f"fracao de leitura de cache no total : {grand_cached / total_all * 100:.1f}%") +print(f"razao entrada total / entrada que conta : " + f"{statistics.median(total_input) / statistics.median(billable_input):.1f}x") + +# Concorrencia: quantas sessoes se sobrepoem no tempo. Numa maquina de um unico +# operador isto mede paralelismo de subagentes, nao concorrencia de time. +events = [] +for start, end in session_spans: + events.append((start, 1)) + events.append((end, -1)) +events.sort() +open_sessions = peak = 0 +for _, delta in events: + open_sessions += delta + peak = max(peak, open_sessions) +print(f"pico de sessoes simultaneas : {peak}") + +# Inflacao de contar linha em vez de resposta. Contado sobre TODOS os arquivos, +# antes dos filtros de sessao: e o fator que erra por dois quem le o historico +# sem deduplicar. Aqui ele deixa de ser afirmacao e passa a ser saida de script. +distinct_ids = len(tally["ids"]) +inflation = tally["lines"] / distinct_ids if distinct_ids else float("nan") +print(f"linhas de resposta (com usage) : {tally['lines']}") +print(f"message.id distintos : {distinct_ids}") +print(f"inflacao de contar linha e nao resposta : {inflation:.2f}x") diff --git a/tools/sizing.py b/tools/sizing.py new file mode 100644 index 0000000..a31dc56 --- /dev/null +++ b/tools/sizing.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Resolve a formula de dimensionamento com os numeros medidos e publicados. + +Cada constante aqui tem uma procedencia diferente, e misturar as tres sem dizer +qual e qual e como um numero inventado nasce: + + MEDIDO -- PROFILES, copiado da saida do comando abaixo. Quem quiser + conferir roda o mesmo comando; quem quiser dimensionar o proprio + time roda sem `--until` na propria maquina e troca estes valores. + PUBLICADO -- API_TIERS, da tabela de rate limits por tier da API da Anthropic. + ARBITRADO -- CONCURRENCY e SLACK. Ninguem mediu isto: sao escolhas de + operacao, variadas de proposito para mostrar o quanto mexem no + resultado. Nao trate como medicao. +""" + +import math + +# --- MEDIDO: perfil de UMA sessao ativa de agente --- +MEASUREMENT_COMMAND = ( + "./.venv/bin/python tools/measure_agent_usage.py --until 2026-09-13T00:00:00Z" +) +PROFILES = { + "mediana": {"rate_per_hour": 194, "billable_input": 4178, "output": 706, "total_input": 88353}, + "p90": {"rate_per_hour": 343, "billable_input": 8227, "output": 1361, "total_input": 303214}, +} + +# --- PUBLICADO: teto por tier da API (Opus 5 / Sonnet 5) --- +API_TIERS = { + "Start": {"rpm": 1_000, "itpm": 2_000_000, "otpm": 400_000}, + "Build": {"rpm": 5_000, "itpm": 5_000_000, "otpm": 1_000_000}, + "Scale": {"rpm": 10_000, "itpm": 10_000_000, "otpm": 2_000_000}, +} + +TEAM_SIZES = [3, 12, 40] + +# --- ARBITRADO: nada disto foi medido --- +CONCURRENCY = [0.4, 0.6, 1.0] # fracao do time pedindo ao mesmo tempo +DEFAULT_CONCURRENCY = 0.6 # o valor usado na tabela de assinatura +SLACK = 0.30 # folga de operacao +WINDOW_HOURS = 5 # PUBLICADO: janela de reset de 5 h + + +def burst_demand(team_size, concurrency, profile, slack=SLACK): + """Demanda por minuto: e o teto que estoura primeiro.""" + simultaneous = team_size * concurrency + rpm = simultaneous * profile["rate_per_hour"] / 60 * (1 + slack) + return simultaneous, rpm, rpm * profile["billable_input"], rpm * profile["output"] + + +def smallest_tier(rpm, itpm, otpm): + for name, tier in API_TIERS.items(): + if rpm <= tier["rpm"] and itpm <= tier["itpm"] and otpm <= tier["otpm"]: + return name + return "acima de Scale" + + +print("### PROCEDENCIA DAS ENTRADAS") +print(f"medido : {MEASUREMENT_COMMAND}") +print("publicado : https://platform.claude.com/docs/en/api/rate-limits") +print(f"arbitrado : c in {CONCURRENCY} (tabela de assinatura usa c={DEFAULT_CONCURRENCY}), " + f"folga={SLACK:.0%}, W_h={WINDOW_HOURS}h") + + +print("\n\n### CAMINHO DE CHAVE DE API - teto publicado, resolve numericamente") +for label, profile in PROFILES.items(): + print(f"\nperfil {label}: R_h={profile['rate_per_hour']} req/h, " + f"T_in={profile['billable_input']} tok/req, T_out={profile['output']} tok/req, " + f"folga={SLACK:.0%}") + print(f"{'devs':>5} {'c':>5} {'U_sim':>6} {'RPM':>7} {'ITPM':>10} {'OTPM':>9} tier minimo") + for size in TEAM_SIZES: + for factor in CONCURRENCY: + simultaneous, rpm, itpm, otpm = burst_demand(size, factor, profile) + print(f"{size:>5} {factor:>5.1f} {simultaneous:>6.1f} {rpm:>7.0f} " + f"{itpm:>10,.0f} {otpm:>9,.0f} {smallest_tier(rpm, itpm, otpm)}") + +print("\n\n### CAMINHO DE ASSINATURA (OmniRoute) - C_janela e [A MEDIR]") +print("A unidade aqui e TOKEN TOTAL (entrada cacheada inclusa): o medidor da") +print("assinatura nao publica o que conta, e a calibracao de Settings > Usage") +print("so pode ser feita contra o total trafegado.\n") +print("D_total = U_sim * R_h * (T_tot + T_out) * W_h * (1 + F)") +for label, profile in PROFILES.items(): + for size in TEAM_SIZES: + factor = DEFAULT_CONCURRENCY + simultaneous = size * factor + demand = (simultaneous * profile["rate_per_hour"] + * (profile["total_input"] + profile["output"]) + * WINDOW_HOURS * (1 + SLACK)) + print(f"perfil {label:>7} | {size:>2} devs | c={factor} | U_sim={simultaneous:4.1f} | " + f"W={WINDOW_HOURS}h | D = {demand:,.0f} tokens -> L = ceil(D / C_janela)") + +print("\n\n### folga da rajada contra UMA licenca de API no tier Start (1000 rpm)") +for size in TEAM_SIZES: + _, rpm, _, _ = burst_demand(size, DEFAULT_CONCURRENCY, PROFILES["mediana"]) + print(f"{size:>2} devs, c={DEFAULT_CONCURRENCY} -> pico exigido {rpm:6.0f} rpm; " + f"Start cobre {math.floor(1000 / rpm)}x a demanda") From 42853fda5c2ee9d364aad7c6953e5dacc184a207 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sun, 13 Sep 2026 14:28:57 -0300 Subject: [PATCH 08/34] Contrato de identidade visual travado por teste "Os frontends devem ter os mesmos componentes, mas as cores mudam" so se sustenta se a diferenca entre os paineis estiver inteiramente nos VALORES de um conjunto fechado de tokens, nunca na existencia deles. A guarda fixa nove tokens de papel cromatico, que mudam por produto, e doze estruturais, identicos nos tres. Reprova token faltando, token sobrando, valor estrutural divergente, fundo trocado, dois papeis com a mesma cor e escada de profundidade fora de ordem. O espelho --brand-b = --accent fica declarado como intencional: ele existe nos tres de proposito, porque o gradiente da marca termina na cor de destaque. Colisao que existe nos tres e convencao; a que existe em um e descuido -- e foi assim que a guarda achou a marca do LiteLlm valendo o mesmo que a borda dele. --- tests/test_identidade_visual.py | 142 ++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 tests/test_identidade_visual.py diff --git a/tests/test_identidade_visual.py b/tests/test_identidade_visual.py new file mode 100644 index 0000000..82ca06f --- /dev/null +++ b/tests/test_identidade_visual.py @@ -0,0 +1,142 @@ +"""Contrato de identidade visual: mesma casca nos três painéis, paleta própria. + +A regra do produto é uma só -- "os frontends devem ter os mesmos componentes, +mas as cores mudam". Isso só se sustenta se a diferença entre os painéis estiver +inteiramente nos VALORES de um conjunto fechado de tokens, e nunca na existência +deles: um token a mais num painel é um componente que só ele sabe desenhar, e a +simetria acaba ali. + +Esta guarda existe porque a alternativa é alguém abrir as três telas lado a lado +e reparar. Isso funcionou até parar de funcionar. +""" + +import pathlib +import re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +RENDER = RAIZ / "src" / "omini_rtksync" / "render.py" + +# A cor de fundo deste produto, escolhida pelo dono. É o único token cujo valor +# este repositório tem autoridade para fixar. +FUNDO = "#240046" + +# Tokens de PAPEL: existem nos três, com valores diferentes em cada um. +PAPEIS_CROMATICOS = { + "--bg", "--surface", "--surface-2", "--line", + "--accent", "--accent-2", "--brand-a", "--brand-b", "--text-dim", +} + +# Tokens ESTRUTURAIS: existem nos três com o MESMO valor. São o que faz o +# Bootstrap obedecer à paleta em vez de trazer a dele. +ESTRUTURAIS = { + "--text": "#e6e8ee", + "--bs-btn-bg": "var(--accent)", + "--bs-btn-border-color": "var(--accent)", + "--bs-btn-color": "var(--bg)", + "--bs-btn-hover-bg": "var(--accent-2)", + "--bs-btn-hover-border-color": "var(--accent-2)", + "--bs-btn-hover-color": "var(--bg)", + "--bs-btn-active-bg": "var(--accent-2)", + "--bs-btn-active-border-color": "var(--accent-2)", + "--bs-btn-active-color": "var(--bg)", + "--bs-table-bg": "transparent", + "--bs-table-border-color": "var(--line)", +} + +CONTRATO = PAPEIS_CROMATICOS | set(ESTRUTURAIS) + + +def tokens_declarados(): + """Todos os tokens definidos no tema, com o valor de cada um.""" + fonte = RENDER.read_text(encoding="utf-8") + return { + m.group(1): m.group(2).strip() + for m in re.finditer(r"(--[a-z0-9-]+)\s*:\s*([^;{}]+);", fonte) + } + + +class ContratoDeTokens(unittest.TestCase): + def test_declara_exatamente_os_tokens_do_contrato(self): + """Nem a menos (componente sem cor), nem a mais (cor que só um painel tem).""" + declarados = set(tokens_declarados()) + faltando = CONTRATO - declarados + sobrando = declarados - CONTRATO + self.assertEqual(faltando, set(), f"tokens do contrato ausentes: {sorted(faltando)}") + self.assertEqual( + sobrando, + set(), + f"tokens fora do contrato: {sorted(sobrando)} -- se a peça é legítima, " + "acrescente o token ao contrato dos TRÊS painéis, não só deste", + ) + + def test_os_tokens_estruturais_tem_o_valor_canonico(self): + """O que não é cor tem de ser idêntico nos três, ou o componente muda de forma.""" + declarados = tokens_declarados() + for token, esperado in ESTRUTURAIS.items(): + self.assertEqual( + declarados.get(token), + esperado, + f"{token} é estrutural: os três painéis declaram {esperado!r}", + ) + + def test_o_fundo_e_a_cor_deste_produto(self): + self.assertEqual( + tokens_declarados().get("--bg", "").lower(), + FUNDO.lower(), + "o fundo é a identidade do produto e foi escolhido pelo dono", + ) + + +class CoerenciaDaPaleta(unittest.TestCase): + def test_cada_papel_cromatico_tem_a_sua_propria_cor(self): + """Dois papéis com a mesma cor é um papel que deixou de existir. + + Se a borda e a marca valem o mesmo, elas viraram a mesma coisa na tela: + a separação entre superfícies some, ou a marca deixa de se destacar. O + token continua lá, mas não cumpre papel nenhum. + """ + declarados = tokens_declarados() + + # Espelhamento deliberado, igual nos três painéis: o gradiente da marca + # termina exatamente na cor de destaque, então `--brand-b` repete + # `--accent` por decisão de design, e não por descuido. Fica declarado + # aqui para que a guarda cobre o resto sem dar falso positivo nele. + ESPELHOS_INTENCIONAIS = {frozenset({"--accent", "--brand-b"})} + + por_cor = {} + for token in PAPEIS_CROMATICOS: + valor = declarados.get(token, "").lower() + if valor.startswith("var("): # alias declarado de propósito + continue + por_cor.setdefault(valor, []).append(token) + colisoes = { + cor: sorted(ts) + for cor, ts in por_cor.items() + if len(ts) > 1 and frozenset(ts) not in ESPELHOS_INTENCIONAIS + } + self.assertEqual(colisoes, {}, "papéis diferentes com a mesma cor: " + repr(colisoes)) + + def test_a_escada_de_profundidade_sobe(self): + """bg mais escuro que surface, surface que surface-2, e a linha acima de todos.""" + declarados = tokens_declarados() + + def luz(token): + v = declarados.get(token, "").lstrip("#") + if len(v) != 6: + self.skipTest(f"{token} não é hexadecimal literal: {v!r}") + r, g, b = (int(v[i : i + 2], 16) for i in (0, 2, 4)) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + escada = ["--bg", "--surface", "--surface-2", "--line"] + valores = [luz(t) for t in escada] + self.assertEqual( + valores, + sorted(valores), + "a escada de profundidade tem de subir: " + + ", ".join(f"{t}={v:.0f}" for t, v in zip(escada, valores)), + ) + + +if __name__ == "__main__": + unittest.main() From a58344e8e38744787c9702eaabe45439571c93d8 Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sun, 13 Sep 2026 15:12:02 -0300 Subject: [PATCH 09/34] Traduzir o rotulo da saida de rede, e calar o cabecalho Server Duas coisas que so aparecem quando alguem olha a tela e a resposta HTTP. 1. "egress.title" chegava CRU ao operador, cinco vezes no mesmo painel -- uma por conexao, dentro do modal de detalhe. translate() devolve a propria chave quando nao acha o texto, entao nada quebrou e nenhum teste falhou: so o rotulo "Saida de rede" virou "egress.title" na cara de quem usa. As outras quatro chaves egress.* ja estavam la nos tres idiomas; faltava justamente a do titulo do grupo. 2. O cabecalho Server anunciava "BaseHTTP/0.6 Python/3.14.7" na primeira linha de toda resposta, inclusive no 401 que sai antes de qualquer autenticacao -- logo acima da CSP, do X-Frame-Options e do nosniff que o resto do cabecalho instala. A mesma resposta que fecha as portas dizia qual e a fechadura. O LiteLlmRTKSync ja calava esse valor; os irmaos nao, e nenhum dos tres tinha teste sobre isso. As duas guardas cobrem o caminho que deixou os defeitos passarem: uma reprova chave usada e nao declarada (provada contra o proprio defeito, que ela aponta com arquivo e linha), a outra reprova handler que anuncia a pilha. A varredura de chave ignora de proposito as montadas em tempo de execucao, como translate(f"health.{status}") -- inclui-las acusaria como orfas nove chaves que existem e sao usadas, e guarda que mente perde a autoridade de reprovar. 281 testes verdes. --- src/omini_rtksync/i18n.py | 6 +++ src/omini_rtksync/web.py | 16 ++++++++ tests/test_cabecalho_server.py | 61 ++++++++++++++++++++++++++++ tests/test_nenhuma_chave_crua.py | 70 ++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 tests/test_cabecalho_server.py create mode 100644 tests/test_nenhuma_chave_crua.py diff --git a/src/omini_rtksync/i18n.py b/src/omini_rtksync/i18n.py index 3628f5b..d4aab1c 100644 --- a/src/omini_rtksync/i18n.py +++ b/src/omini_rtksync/i18n.py @@ -70,10 +70,12 @@ "table.status": "Status", "table.remaining": "Time remaining", "table.diagnosis": "Renewal diagnosis", + "table.details": "Details", "table.models": "models", "reason.local_ok": "Local instance answered with {count} model(s)", "reason.local_unreachable": "Local instance did not answer the model catalog", "reason.local_empty": "Local instance answered, but has no model installed yet", + "egress.title": "Network egress", "egress.bound": "own egress", "egress.shared": "shares the gateway address with {count} accounts", "egress.single": "gateway address (only account)", @@ -183,10 +185,12 @@ "table.status": "Status", "table.remaining": "Validade restante", "table.diagnosis": "Diagnóstico da renovação", + "table.details": "Detalhes", "table.models": "modelos", "reason.local_ok": "Instância local respondeu com {count} modelo(s)", "reason.local_unreachable": "Instância local não respondeu ao catálogo de modelos", "reason.local_empty": "Instância local respondeu, mas ainda não tem nenhum modelo instalado", + "egress.title": "Saída de rede", "egress.bound": "saída própria", "egress.shared": "divide o endereço do gateway com {count} contas", "egress.single": "endereço do gateway (única conta)", @@ -296,10 +300,12 @@ "table.status": "Estado", "table.remaining": "Validez restante", "table.diagnosis": "Diagnóstico de la renovación", + "table.details": "Detalles", "table.models": "modelos", "reason.local_ok": "La instancia local respondió con {count} modelo(s)", "reason.local_unreachable": "La instancia local no respondió al catálogo de modelos", "reason.local_empty": "La instancia local respondió, pero aún no tiene ningún modelo instalado", + "egress.title": "Salida de red", "egress.bound": "salida propia", "egress.shared": "comparte la dirección del gateway con {count} cuentas", "egress.single": "dirección del gateway (única cuenta)", diff --git a/src/omini_rtksync/web.py b/src/omini_rtksync/web.py index b4b94ca..d8355bd 100644 --- a/src/omini_rtksync/web.py +++ b/src/omini_rtksync/web.py @@ -46,6 +46,22 @@ def handle_error(self, request, client_address): class OminiDashboardHandler(BaseHTTPRequestHandler): + + # O cabecalho Server ia na PRIMEIRA linha de toda resposta -- inclusive no + # 401, antes de qualquer autenticacao -- anunciando "BaseHTTP/0.6 + # Python/3.14.7", ou seja, a versao exata do interpretador, logo acima da + # CSP e do X-Frame-Options que o resto do cabecalho instala. Versao exata e + # o que um scanner precisa para escolher o exploit certo. + # + # version_string() tambem e sobrescrito porque o BaseHTTPRequestHandler + # concatena server_version + " " + sys_version: com sys_version vazio, a + # resposta sai com um espaco sobrando no fim do valor. + server_version = "OminiRTKSync" + sys_version = "" + + def version_string(self) -> str: + return self.server_version + settings: Optional[Settings] = None db_path: str = "" omniroute_url: str = "" diff --git a/tests/test_cabecalho_server.py b/tests/test_cabecalho_server.py new file mode 100644 index 0000000..e6d9af4 --- /dev/null +++ b/tests/test_cabecalho_server.py @@ -0,0 +1,61 @@ +"""O cabeçalho Server não pode anunciar a pilha que serve o painel. + +`BaseHTTPRequestHandler` responde, por padrão, `Server: BaseHTTP/0.6 +Python/3.14.7` — a versão exata do interpretador, na primeira linha de TODA +resposta, inclusive no 401 que sai antes de qualquer autenticação. Ela viajava +logo acima da CSP, do `X-Frame-Options: DENY` e do `nosniff` que o resto do +cabeçalho instala: a mesma resposta que fecha as portas dizia qual é a +fechadura. + +Versão exata é o que um scanner precisa para escolher o exploit certo, e nada +no produto depende de publicá-la. +""" + +import pathlib +import re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +HANDLER = RAIZ / "src" / "omini_rtksync" / "web.py" + +PROIBIDO = ("python", "basehttp", "simplehttp", "wsgi") + + +class CabecalhoServerNaoDenuncia(unittest.TestCase): + def test_o_handler_declara_nome_proprio_e_versao_vazia(self): + fonte = HANDLER.read_text(encoding="utf-8") + self.assertRegex( + fonte, + r'server_version\s*=\s*"[^"]+"', + "sem server_version o padrão do BaseHTTP anuncia a versão do Python", + ) + self.assertRegex( + fonte, + r'sys_version\s*=\s*""', + "sys_version tem de ser vazio: é ele que carrega 'Python/3.x.y'", + ) + + def test_version_string_devolve_so_o_nome(self): + """Sem sobrescrever, o valor sai com um espaço sobrando no fim.""" + fonte = HANDLER.read_text(encoding="utf-8") + self.assertIn( + "def version_string", + fonte, + "BaseHTTPRequestHandler concatena server_version + ' ' + sys_version", + ) + + def test_o_nome_anunciado_nao_cita_a_pilha(self): + fonte = HANDLER.read_text(encoding="utf-8") + achado = re.search(r'server_version\s*=\s*"([^"]+)"', fonte) + self.assertIsNotNone(achado) + nome = achado.group(1).lower() + for proibido in PROIBIDO: + self.assertNotIn( + proibido, + nome, + f"o nome anunciado ({achado.group(1)!r}) entrega a pilha", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nenhuma_chave_crua.py b/tests/test_nenhuma_chave_crua.py new file mode 100644 index 0000000..8b916a2 --- /dev/null +++ b/tests/test_nenhuma_chave_crua.py @@ -0,0 +1,70 @@ +"""Nenhuma chave de tradução pode chegar crua à tela. + +`translate()` devolve a própria chave quando não encontra o texto. O efeito é +silencioso: nada quebra, nenhum teste falha, e o operador lê `egress.title` no +lugar de "Saída de rede" — foi exatamente o que aconteceu aqui, cinco vezes no +mesmo painel, até alguém abrir a tela e reparar. + +A guarda cobre as chaves escritas literalmente. As montadas em tempo de execução +(`translate(f"health.{status}")`) ficam de fora de propósito: varrê-las por +regex acusaria como órfãs as nove `health.*` que existem e são usadas, e uma +guarda que mente perde a autoridade de reprovar. +""" + +import pathlib +import re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +FONTE = RAIZ / "src" / "omini_rtksync" +CATALOGO = FONTE / "i18n.py" + +# translate("chave.literal", ...) — só o primeiro argumento, e só quando é +# string literal. f-strings e variáveis não casam, que é o que se quer. +USO_LITERAL = re.compile(r'translate\(\s*"([a-z0-9_]+(?:\.[a-z0-9_]+)+)"') +DECLARACAO = re.compile(r'^\s*"([a-z0-9_]+(?:\.[a-z0-9_]+)+)"\s*:', re.M) + + +def chaves_declaradas(): + return set(DECLARACAO.findall(CATALOGO.read_text(encoding="utf-8"))) + + +def chaves_usadas(): + usadas = {} + for arquivo in sorted(FONTE.rglob("*.py")): + if arquivo.name == "i18n.py": + continue + texto = arquivo.read_text(encoding="utf-8") + for numero, linha in enumerate(texto.splitlines(), 1): + for chave in USO_LITERAL.findall(linha): + usadas.setdefault(chave, f"{arquivo.relative_to(RAIZ)}:{numero}") + return usadas + + +class NenhumaChaveCruaNaTela(unittest.TestCase): + def test_toda_chave_usada_existe_no_catalogo(self): + declaradas = chaves_declaradas() + orfas = {k: onde for k, onde in chaves_usadas().items() if k not in declaradas} + self.assertEqual( + orfas, + {}, + "estas chaves chegariam cruas à tela:\n " + + "\n ".join(f"{k} (usada em {onde})" for k, onde in sorted(orfas.items())), + ) + + def test_os_tres_idiomas_declaram_o_mesmo_conjunto(self): + """Faltar num idioma é o mesmo defeito, visível só para quem usa aquele idioma.""" + texto = CATALOGO.read_text(encoding="utf-8") + # Cada bloco de idioma começa numa linha do tipo `"en": {` + blocos = re.split(r'^\s*"(?:en|pt|es)"\s*:\s*\{', texto, flags=re.M)[1:] + self.assertEqual(len(blocos), 3, "esperava três blocos de idioma no catálogo") + conjuntos = [set(DECLARACAO.findall(b)) for b in blocos] + for i, outro in enumerate(conjuntos[1:], start=1): + faltando = conjuntos[0] - outro + sobrando = outro - conjuntos[0] + self.assertEqual(faltando, set(), f"bloco {i} não declara: {sorted(faltando)}") + self.assertEqual(sobrando, set(), f"bloco {i} declara a mais: {sorted(sobrando)}") + + +if __name__ == "__main__": + unittest.main() From a43a1d062c774d82950de307dec0276323a4988c Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sun, 13 Sep 2026 15:26:59 -0300 Subject: [PATCH 10/34] Dar icone a aba do painel, que era a unica pagina sem ele O SVG do produto ja estava no modulo, mas so na pagina de aviso. O DASHBOARD -- a aba que o operador deixa aberta o dia inteiro, e a unica que ele realmente olha -- nao declarava icone nenhum, entao o navegador tentava /favicon.ico, levava 401 do Basic Auth e desenhava o quadrado generico. Com os tres sincronizadores abertos lado a lado, as tres abas ficavam indistinguiveis. O icone vira constante usada pelos dois documentos que o modulo serve, em vez de literal repetido -- duas copias e como elas divergem. Continua sendo data URI de proposito: qualquer URL seria buscada, e a busca leva 401 antes de o operador autenticar. A guarda conta os do modulo e exige uma declaracao de icone para cada um, entao uma pagina nova nasce com icone ou o teste reprova. --- src/omini_rtksync/render.py | 10 +++++-- tests/test_favicon.py | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 tests/test_favicon.py diff --git a/src/omini_rtksync/render.py b/src/omini_rtksync/render.py index 537b669..dfe1bb1 100644 --- a/src/omini_rtksync/render.py +++ b/src/omini_rtksync/render.py @@ -15,6 +15,11 @@ from .i18n import DEFAULT_LANGUAGE, LANGUAGES, normalize_language, translate +# Icone da aba, embutido como data URI: /favicon.ico responde 401 atras do +# Basic Auth, entao um arquivo servido deixaria a aba sem icone ate o +# operador autenticar -- e a pagina de erro nunca teria icone nenhum. +FAVICON = "data:image/svg+xml," + BOOTSTRAP_CSS = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" BOOTSTRAP_ICONS = "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" FLAG_ICONS = "https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css" @@ -172,10 +177,8 @@ def render_notice_page(title: str, body: str, link_label: str = "") -> bytes: - - + {esc(title)} @@ -706,6 +709,7 @@ def render_dashboard( + OminiRTKSync diff --git a/tests/test_favicon.py b/tests/test_favicon.py new file mode 100644 index 0000000..ccd6b4d --- /dev/null +++ b/tests/test_favicon.py @@ -0,0 +1,56 @@ +"""Toda página servida declara o ícone da aba. + +`/favicon.ico` responde 401 atrás do Basic Auth, então o navegador não consegue +buscá-lo: sem um `` embutido, a aba fica com o quadrado +genérico. O painel é uma aba que o operador deixa aberta o dia inteiro, e três +abas genéricas lado a lado são indistinguíveis. + +O ícone vai como data URI justamente por isso — não depende de requisição, e +por isso funciona também na página de erro, que é servida antes de qualquer +autenticação. +""" + +import pathlib +import re +import unittest + +RAIZ = pathlib.Path(__file__).resolve().parent.parent +RENDER = RAIZ / "src" / "omini_rtksync" / "render.py" + + +class TodaPaginaTemIcone(unittest.TestCase): + def setUp(self): + self.fonte = RENDER.read_text(encoding="utf-8") + + def test_o_icone_e_uma_constante_unica(self): + """Duplicar o SVG em cada página é como as duas cópias divergem.""" + # re.M porque assertRegex usa re.search sem flags, e `^` sozinho só + # casaria no primeiro caractere do arquivo. + self.assertIsNotNone( + re.search(r'^FAVICON = "data:image/svg\+xml,', self.fonte, re.M), + "o ícone tem de ser uma constante no topo do módulo", + ) + + def test_todo_documento_servido_declara_o_icone(self): + """Conta os e exige um para cada um.""" + heads = self.fonte.count("") + icones = len(re.findall(r'', self.fonte)) + self.assertEqual( + icones, + heads, + f"{heads} documentos servidos e {icones} declarações de ícone: " + "a aba de algum deles fica com o ícone genérico", + ) + + def test_o_icone_nao_depende_de_requisicao(self): + """Um href para arquivo seria buscado, e /favicon.ico responde 401.""" + achado = re.search(r'^FAVICON = "([^"]+)"', self.fonte, re.M) + self.assertIsNotNone(achado) + self.assertTrue( + achado.group(1).startswith("data:"), + "o ícone precisa ser data URI: qualquer URL seria buscada e levaria 401", + ) + + +if __name__ == "__main__": + unittest.main() From 0e7d23e9630057552ca132c579d5e6c3e992309a Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Sun, 13 Sep 2026 15:47:43 -0300 Subject: [PATCH 11/34] Tela de login propria, e o grid que finalmente cabe na tela DUAS PORTAS PARA A MESMA CASA O painel nasceu so com Basic Auth, e o dialogo que o navegador abre para isso e janela DELE, nao pagina nossa: nao se traduz, nao se estiliza, nao tem logout -- a unica forma de sair era fechar o navegador -- e nao e HTML, entao qualquer ferramenta que dirija um navegador para ali, porque nao ha campo para preencher. Foi assim que a verificacao visual destes paineis travou. Agora existe um formulario, com a casca e a paleta de cada produto, que entrega um cookie assinado (HMAC, oito horas, HttpOnly, SameSite=Strict). O segredo nasce a cada processo, em memoria: reiniciar invalida as sessoes, que e a escolha certa para um painel que le credenciais. O Basic Auth continua aceito, porque e ele que faz curl e monitoramento funcionarem sem sessao -- quem pede HTML vai para o formulario, quem nao pede recebe o 401 de sempre. O nome do cookie carrega o prefixo do produto porque cookie nao se separa por porta: com nome generico, entrar num painel derrubaria a sessao dos outros dois no mesmo 127.0.0.1. O GRID QUE NAO CABIA Com table-layout:fixed a largura da coluna e lei, e text-nowrap sem overflow:hidden nao corta nem quebra: o excesso se desenha POR CIMA da coluna vizinha. Era por isso que a validade aparecia escrita sobre a data de renovacao. Tres mudancas, e so as tres juntas resolvem: - corte com reticencias, para o excesso parar na celula; - larguras redistribuidas: "Nome" tinha width:auto e engolia o espaco que sobrava, deixando "Chave de API" virar "Chave de ..." e o cabecalho "Detalhes" quebrar em "Detalhe" + "s"; - textos encurtados na celula, com a explicacao no modal: a data perde ano e segundos (13/09 18:44), e a validade perde o parentese. A coluna diz o fato; o botao (i) da linha diz a razao. No cartao do agendador, "Ultimo resultado" era a unica linha que empilhava o valor embaixo do rotulo, enquanto as duas acima punham o valor a direita; e a data completa nao cabia em col-6 e quebrava no meio de "UTC", virando "U C" na tela. O favicon tambem entra aqui: a pagina de login e a terceira que o modulo serve, e a guarda conta os para que nenhuma nasca sem icone. --- README.md | 20 ++++- docker-compose.egress-test.yml | 3 + docker-compose.test.yml | 21 ++++- src/omini_rtksync/i18n.py | 27 ++++++ src/omini_rtksync/render.py | 154 +++++++++++++++++++++++++++------ src/omini_rtksync/sessao.py | 92 ++++++++++++++++++++ src/omini_rtksync/web.py | 88 +++++++++++++++++-- tests/test_egress_panel.py | 9 +- tests/test_sessao.py | 68 +++++++++++++++ tests/test_web_render.py | 4 +- tools/valida_docs.py | 21 +++-- 11 files changed, 455 insertions(+), 52 deletions(-) create mode 100644 src/omini_rtksync/sessao.py create mode 100644 tests/test_sessao.py diff --git a/README.md b/README.md index 8c548f9..041bf4c 100644 --- a/README.md +++ b/README.md @@ -135,10 +135,15 @@ docker pull ghcr.io/pathbit/ominirtksync:latest Integre o `OminiRTKSync` ao seu `docker-compose.yml` junto ao [OmniRoute](https://github.com/diegosouzapw/OmniRoute): ```yaml +name: ominirtksync-stack + services: - omniroute: + ominirtk-router: image: diegosouzapw/omniroute:latest container_name: ominirtk-router + hostname: ominirtk-router + networks: + - ominirtksync-net restart: unless-stopped ports: # 20128 dentro do container; 8082 no host. @@ -156,9 +161,12 @@ services: volumes: - omniroute_data:/app/data - ominirtksync: + ominirtk-sync: image: ghcr.io/pathbit/ominirtksync:latest container_name: ominirtk-sync + hostname: ominirtk-sync + networks: + - ominirtksync-net restart: unless-stopped ports: - "127.0.0.1:9092:9090" @@ -168,7 +176,7 @@ services: environment: - HOST_HOME=/root/host - DB_PATH=/app/data/storage.sqlite - - OMNIROUTE_URL=${OMNIROUTE_URL:-http://omniroute:20128} + - OMNIROUTE_URL=${OMNIROUTE_URL:-http://ominirtk-router:20128} - SYNC_INTERVAL=${SYNC_INTERVAL:-300} - REFRESH_MARGIN=${REFRESH_MARGIN:-900} - ENABLE_WEB_DASHBOARD=${ENABLE_WEB_DASHBOARD:-1} @@ -176,7 +184,7 @@ services: - DASHBOARD_USER=${DASHBOARD_USER:-admin} - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD:-} depends_on: - - omniroute + - ominirtk-router healthcheck: test: ["CMD", "/opt/venv/bin/python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/healthz', timeout=3)"] interval: 15s @@ -186,6 +194,10 @@ services: volumes: omniroute_data: + +networks: + ominirtksync-net: + name: ominirtksync-net ``` --- diff --git a/docker-compose.egress-test.yml b/docker-compose.egress-test.yml index d59826f..abbb734 100644 --- a/docker-compose.egress-test.yml +++ b/docker-compose.egress-test.yml @@ -112,6 +112,9 @@ services: networks: egress: + # Nome declarado, e nao derivado do nome do projeto: sem isto a rede nasce + # como `ominirtk-egress_egress` e o endereco depende do diretorio. + name: ominirtk-egress-net ipam: config: - subnet: 172.32.0.0/24 diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 1692220..02fdcd4 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -12,9 +12,12 @@ name: ominirtksync-test services: - omniroute: + ominirtk-test-router: image: diegosouzapw/omniroute:latest container_name: ominirtk-test-router + hostname: ominirtk-test-router + networks: + - ominirtksync-test-net restart: unless-stopped ports: - "127.0.0.1:19129:20128" @@ -43,10 +46,13 @@ services: # O primeiro boot roda migracoes e cria o banco. start_period: 45s - ominirtksync: + ominirtk-test-sync: build: . image: ghcr.io/pathbit/ominirtksync:local container_name: ominirtk-test-sync + hostname: ominirtk-test-sync + networks: + - ominirtksync-test-net restart: unless-stopped ports: # Porta interna 9090 nos tres sincronizadores; publicada em 19092 aqui. @@ -54,7 +60,7 @@ services: environment: - DATA_DIR=/app/data - DB_PATH=/app/data/storage.sqlite - - OMNIROUTE_URL=http://omniroute:20128 + - OMNIROUTE_URL=http://ominirtk-test-router:20128 - WEB_PORT=9090 - SYNC_INTERVAL=60 - REFRESH_MARGIN=900 @@ -66,7 +72,7 @@ services: volumes: - gateway_data:/app/data depends_on: - omniroute: + ominirtk-test-router: condition: service_healthy healthcheck: test: @@ -83,3 +89,10 @@ services: volumes: gateway_data: + +networks: + ominirtksync-test-net: + name: ominirtksync-test-net + # Rede propria tambem na stack de teste. Sem esta secao o compose sobe em + # `ominirtksync-test_default`, a rede implicita onde duas stacks do mesmo + # daemon resolvem o mesmo nome curto. diff --git a/src/omini_rtksync/i18n.py b/src/omini_rtksync/i18n.py index d4aab1c..0efb503 100644 --- a/src/omini_rtksync/i18n.py +++ b/src/omini_rtksync/i18n.py @@ -44,8 +44,16 @@ "previous one, so sign in again to continue.", "auth.updated_link": "Back to the dashboard", "auth.required": "Authentication required.", + "auth.login_intro": "Sign in to see the panel.", + "auth.user": "User", + "auth.password": "Password", + "auth.enter": "Sign in", + "auth.login_failed": "Wrong user or password.", + "auth.logout": "Sign out", "auth.required_body": "This dashboard is private. Sign in to continue.", "gateway.title": "Gateway connection", + "gateway.db_summary": "Operational ({connections} connections, {combos} combos)", + "gateway.db_missing": "Database not found", "gateway.gateway": "Gateway", "gateway.status": "Status", "gateway.latency": "Latency", @@ -106,6 +114,7 @@ "action.validate": "Validate credentials", "duration.unknown_expiry": "Expiry unknown", "duration.no_expiry": "No expiry (static key)", + "duration.no_expiry_short": "No expiry", "table.last_refresh": "Last renewal", "table.never_refreshed": "Never renewed", "table.time_ago": "{elapsed} ago", @@ -159,8 +168,16 @@ "então autentique-se de novo para continuar.", "auth.updated_link": "Voltar ao painel", "auth.required": "Autenticação requerida.", + "auth.login_intro": "Entre para ver o painel.", + "auth.user": "Usuário", + "auth.password": "Senha", + "auth.enter": "Entrar", + "auth.login_failed": "Usuário ou senha incorretos.", + "auth.logout": "Sair", "auth.required_body": "Este painel é privado. Autentique-se para continuar.", "gateway.title": "Conexão com o gateway", + "gateway.db_summary": "Operacional ({connections} conexões, {combos} combos)", + "gateway.db_missing": "Banco não encontrado", "gateway.gateway": "Gateway", "gateway.status": "Status", "gateway.latency": "Latência", @@ -221,6 +238,7 @@ "action.validate": "Validar credenciais", "duration.unknown_expiry": "Validade desconhecida", "duration.no_expiry": "Sem expiração (chave estática)", + "duration.no_expiry_short": "Sem expiração", "table.last_refresh": "Última renovação", "table.never_refreshed": "Nunca renovada", "table.time_ago": "há {elapsed}", @@ -274,8 +292,16 @@ "anterior, así que vuelva a autenticarse para continuar.", "auth.updated_link": "Volver al panel", "auth.required": "Autenticación requerida.", + "auth.login_intro": "Entre para ver el panel.", + "auth.user": "Usuario", + "auth.password": "Contraseña", + "auth.enter": "Entrar", + "auth.login_failed": "Usuario o contraseña incorrectos.", + "auth.logout": "Salir", "auth.required_body": "Este panel es privado. Autentíquese para continuar.", "gateway.title": "Conexión con el gateway", + "gateway.db_summary": "Operativo ({connections} conexiones, {combos} combos)", + "gateway.db_missing": "Base de datos no encontrada", "gateway.gateway": "Gateway", "gateway.status": "Estado", "gateway.latency": "Latencia", @@ -336,6 +362,7 @@ "action.validate": "Validar credenciales", "duration.unknown_expiry": "Validez desconocida", "duration.no_expiry": "Sin expiración (clave estática)", + "duration.no_expiry_short": "Sin expiración", "table.last_refresh": "Última renovación", "table.never_refreshed": "Nunca renovada", "table.time_ago": "hace {elapsed}", diff --git a/src/omini_rtksync/render.py b/src/omini_rtksync/render.py index dfe1bb1..d8f0f01 100644 --- a/src/omini_rtksync/render.py +++ b/src/omini_rtksync/render.py @@ -139,10 +139,10 @@ def render_last_refresh(conn: Any, lang: str) -> str: icon = '' detail = f'
    {esc(ago)}
    ' if ago else "" - return f'{icon}{esc(format_timestamp(stamp))}{detail}' + return f'{icon}{esc(format_timestamp_curto(stamp))}{detail}' -def render_remaining(conn: Any, lang: str) -> str: +def render_remaining(conn: Any, lang: str, curto: bool = False) -> str: """Validade restante, sem chamar de ilimitado o que so esta faltando. Um token OAuth sempre expira. Quando nao ha expiresAt legivel, isso e dado @@ -160,7 +160,25 @@ def render_remaining(conn: Any, lang: str) -> str: '' f'{esc(translate("duration.unknown_expiry", lang))}' ) - return f'{esc(translate("duration.no_expiry", lang))}' + # Na celula cabe o fato; a razao ("chave estatica") fica no modal. + chave = "duration.no_expiry_short" if curto else "duration.no_expiry" + return f'{esc(translate(chave, lang))}' + + +def format_timestamp_curto(value: Optional[str]) -> str: + """Data enxuta para a celula da tabela: dia/mes e hora, sem ano nem segundos. + + A forma completa ("2026-09-13 18:40:52 UTC") nao cabe na coluna e era + cortada no meio, o que deixava a informacao pior do que util. O carimbo + inteiro continua no modal de detalhe, a um clique da linha. + """ + if not value: + return "—" + try: + momento = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except (ValueError, TypeError): + return format_timestamp(value) + return momento.strftime("%d/%m %H:%M") def render_notice_page(title: str, body: str, link_label: str = "") -> bytes: @@ -209,14 +227,14 @@ def egress_chip(conn: Any, sharing_count: int, lang: str) -> str: if estado == "bound": pool = conn.egress_binding or "?" return ( - '' + '' f'' f'{esc(translate("egress.bound", lang))}: {esc(pool)}' ) if estado == "shared": if sharing_count > 1: return ( - '' + '' f'' f'{esc(translate("egress.shared", lang, count=sharing_count))}' ) @@ -232,6 +250,73 @@ def egress_chip(conn: Any, sharing_count: int, lang: str) -> str: ) +def render_login_page(lang: str = DEFAULT_LANGUAGE, erro: str = "") -> bytes: + """Formulario de entrada, com a mesma casca e a mesma paleta do painel. + + Existe porque o dialogo do Basic Auth e uma janela do NAVEGADOR: nao se + traduz, nao se estiliza, nao oferece logout e nao e HTML -- qualquer + ferramenta que dirija um navegador para no dialogo, porque nao ha nada na + pagina para preencher. Esta pagina resolve os quatro de uma vez. + """ + lang = normalize_language(lang) + aviso = ( + f'' + if erro + else "" + ) + return f""" + + + + + + + OminiRTKSync + + + + + +
    +
    +

    + OminiRTKSync +

    +

    {esc(translate("auth.login_intro", lang))}

    + {aviso} + +
    + + +
    +
    + + +
    + + +
    +
    + +""".encode("utf-8") + + def health_badge(status: str, lang: str) -> str: """Monta o badge de saúde com ícone de fonte.""" css, icon = HEALTH_PRESENTATION.get(status, HEALTH_PRESENTATION["unknown"]) @@ -410,7 +495,7 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: {esc(kind)} {health_badge(c.health_status, lang)} - {render_remaining(c, lang)} + {render_remaining(c, lang, curto=True)} {render_last_refresh(c, lang)}
    """ + + +def estado_vazio(mensagem: str) -> str: + """O bloco de estado vazio da familia: icone bi-inbox e uma frase. + + Os quatro cartoes de tabela usam exatamente este bloco. Cada um deles existe + nos tres paineis por contrato; quando o gateway deste produto nao tem aquele + conceito, ou ainda nao tem dado nenhum, o cartao continua na tela e a frase + diz por que esta vazio AQUI. Assimetria de cartoes e pior que estado vazio. + """ + return f""" +
    + + {esc(mensagem)} +
    """ + + +def detail_button(modal_id: str, lang: str) -> str: + """Botao (i) da linha, que abre o modal de detalhe daquele item.""" + return f"""""" + + +def render_detail_modal(modal_id: str, titulo: str, linhas: List[tuple], lang: str, + extra: str = "") -> str: + """Modal de detalhe no formato que a familia usa: titulo, pares e um extra.""" + corpo = "".join( + f'
    {esc(rotulo)}
    ' + f'
    {valor}
    ' + for rotulo, valor in linhas + ) + return f""" + """ + + +def render_remaining_seconds(remaining: Optional[int], lang: str) -> str: + """Validade restante de um item que nao e conexao (chave virtual, modelo). + + Sem ``expiresAt`` a chave e estatica: vale ate ser revogada, e isso e + "sem expiracao" de verdade -- nao o dado ausente que render_remaining trata + com cautela no caso do OAuth. + """ + if remaining is not None: + return esc(format_duration(remaining, lang)) + return f'{esc(translate("duration.no_expiry_short", lang))}' + + +def render_timestamp_cell(carimbo: Optional[str], lang: str, icone: str) -> str: + """Celula de carimbo de tempo curto, com o valor inteiro guardado no modal.""" + if not carimbo: + return f'{esc(translate("table.never_refreshed", lang))}' + return (f'' + f'{esc(format_timestamp_curto(carimbo))}') + + +def key_state_label(key: Any, lang: str) -> str: + """Por qual caminho a chave foi recusada -- ou que ela segue aceita. + + A celula da tabela diz "recusada" nos tres casos, porque o efeito e o mesmo. + Qual deles foi so cabe aqui, no modal. + """ + dados = key.data + if dados.get("isBanned"): + return translate("keys.banned", lang) + if dados.get("revokedAt"): + return translate("keys.revoked", lang) + if dados.get("isActive") is False: + return translate("keys.disabled", lang) + return translate("keys.enabled", lang) + + +def render_key_details(key: Any, modal_id: str, lang: str) -> str: + """Modal com o que nao cabe na linha da chave virtual. + + Escopos, modo de acesso, tetos de requisicao e o instante exato de emissao + sao diagnostico: espremidos na tabela empurrariam as colunas uteis para fora + da tela. O TOKEN nunca entra aqui -- ele sequer e lido do banco. + """ + acesso = ( + translate("keys.access_restricted", lang) + if key.model_access_mode == "restricted" + else translate("keys.access_all", lang) + ) + escopos = ", ".join(key.scopes) if key.scopes else translate("table.not_declared", lang) + linhas = [ + (translate("table.status", lang), health_badge(key.health_status, lang)), + (translate("table.key_state", lang), esc(key_state_label(key, lang))), + (translate("table.remaining", lang), render_remaining_seconds(key.remaining_seconds, lang)), + (translate("table.issued_at", lang), + f'{esc(format_timestamp(key.issued_at))}'), + (translate("table.last_used", lang), + f'{esc(format_timestamp(key.last_used_at))}'), + (translate("table.model_access", lang), esc(acesso)), + (translate("table.scopes", lang), f'{esc(escopos)}'), + ] + extra = "" + if key.allowed_models: + itens = "".join(f'
  • {esc(m)}
  • ' for m in key.allowed_models) + extra = (f'

    {esc(translate("table.models", lang))}

    ' + f'
      {itens}
    ') + return render_detail_modal(modal_id, key.name, linhas, lang, extra) + + +def render_keys_table(keys: List[Any], lang: str) -> str: + """Chaves virtuais emitidas pelo gateway, uma por linha, nas sete colunas.""" + if not keys: + return estado_vazio(translate("keys.empty", lang)) + + rows = [] + detalhes = [] + # Id do modal pelo INDICE, nunca pelo nome: nome de chave aceita espaco, + # acento e barra, e nada disso vale como id de elemento HTML. + for indice, key in enumerate(keys): + modal_id = f"detalhe-chave-{indice}" + rows.append(f""" + + {esc(PROVEDOR_DO_GATEWAY)} + {esc(key.name)} + + {esc(translate("type.virtual_key", lang))} + + {health_badge(key.health_status, lang)} + {render_remaining_seconds(key.remaining_seconds, lang)} + {render_timestamp_cell(key.issued_at, lang, "bi-clock")} + {detail_button(modal_id, lang)} + """) + detalhes.append(render_key_details(key, modal_id, lang)) + + return cabecalho_de_dominio(rows, lang) + "".join(detalhes) + + +def render_model_details(model: Any, modal_id: str, lang: str) -> str: + """Modal com o que nao cabe na linha do modelo. + + Os limites de contexto, os endpoints e a descricao sao texto longo; o nome + da conexao dona esta aqui porque e ele que explica de onde vem o status da + linha -- um modelo nao tem saude propria. + """ + nao_declarado = f'{esc(translate("table.not_declared", lang))}' + + def numero(valor: Any) -> str: + return f'{esc(f"{valor:,}".replace(",", " "))}' \ + if isinstance(valor, int) else nao_declarado + + linhas = [ + (translate("table.provider", lang), + f'{esc(model.provider)}' if model.provider else nao_declarado), + (translate("table.connection", lang), + esc(model.connection_name) if model.connection_name else nao_declarado), + (translate("table.status", lang), health_badge(model.health_status, lang)), + (translate("table.source", lang), + f'{esc(model.source)}' if model.source else nao_declarado), + (translate("table.context_limit", lang), numero(model.input_token_limit)), + (translate("table.output_limit", lang), numero(model.output_token_limit)), + (translate("table.endpoints", lang), + f'{esc(", ".join(model.supported_endpoints))}' + if model.supported_endpoints else nao_declarado), + ] + extra = f'

    {esc(translate("models.inherited", lang))}

    ' + if model.description: + extra = (f'

    {esc(translate("table.description", lang))}

    ' + f'

    {esc(model.description)}

    ' + extra) + return render_detail_modal(modal_id, model.id, linhas, lang, extra) + + +def render_models_table(models: List[Any], lang: str) -> str: + """Modelos que o gateway conhece, nas mesmas sete colunas dos irmaos. + + O catalogo de um gateway com OpenRouter ligado passa de quinhentas entradas, + e cada linha traz um modal junto: renderizar tudo levaria a pagina a quase um + megabyte de HTML para uma tabela que ninguem le ate o fim. A tela mostra as + primeiras MAX_LINHAS_DE_MODELO, o cabecalho continua contando o total e o + rodape diz quantas ficaram de fora -- truncar em silencio seria mentir sobre + o tamanho do catalogo. + """ + if not models: + return estado_vazio(translate("models.empty", lang)) + + visiveis = models[:MAX_LINHAS_DE_MODELO] + rows = [] + detalhes = [] + for indice, model in enumerate(visiveis): + modal_id = f"detalhe-modelo-{indice}" + provedor = (f'{esc(model.provider)}' + if model.provider else '—') + rows.append(f""" + + {provedor} + {esc(model.id)} + + {esc(translate("type.synced_model", lang))} + + {health_badge(model.health_status, lang)} + {render_remaining_seconds(model.remaining_seconds, lang)} + {render_timestamp_cell(model.last_refresh_at, lang, "bi-arrow-repeat")} + {detail_button(modal_id, lang)} + """) + detalhes.append(render_model_details(model, modal_id, lang)) + + rodape = "" + if len(models) > len(visiveis): + rodape = (f'

    ' + f'{esc(translate("models.showing", lang, shown=len(visiveis), total=len(models)))}

    ') + + return cabecalho_de_dominio(rows, lang) + rodape + "".join(detalhes) def render_combos_table(combos: List[Dict[str, Any]], lang: str) -> str: if not combos: - return f""" -
    - - {esc(translate("combos.empty", lang))} -
    """ + return estado_vazio(translate("combos.empty", lang)) rows = [] for combo in combos: @@ -786,11 +1054,163 @@ def render_flash(flash: Optional[Dict[str, str]]) -> str: """ +def campo_de_texto( + nome: str, rotulo: str, valor: str, ajuda: str = "", tipo: str = "text", + travado: bool = False, marcador: str = "", +) -> str: + """Um campo do formulario de SSO, com rotulo traduzido e ajuda opcional.""" + dica = f'
    {esc(ajuda)}
    ' if ajuda else "" + return f""" +
    + + + {dica} +
    """ + + +def render_sso_modal( + config: Dict[str, str], + lang: str, + tem_segredo: bool, + segredo_do_ambiente: bool, + desligado_pelo_ambiente: bool, + endereco_de_retorno: str, +) -> str: + """Tela de configuracao da entrada federada, com as duas abas. + + O segredo do cliente NUNCA volta para ca: o campo nasce vazio, a tela diz + apenas se existe um guardado, e salvar em branco MANTEM o anterior. Um GET + de configuracao que devolvesse o valor seria o mesmo que publica-lo no HTML. + """ + ativo = config.get("enabled") or "" + aviso_ambiente = ( + f'' + if desligado_pelo_ambiente + else "" + ) + + if segredo_do_ambiente: + estado_do_segredo = translate("sso.secret_from_env", lang) + elif tem_segredo: + estado_do_segredo = translate("sso.secret_stored", lang) + else: + estado_do_segredo = translate("sso.secret_missing", lang) + + aba_oidc = "".join([ + campo_de_texto("base_url", translate("sso.base_url", lang), config.get("base_url", ""), + translate("sso.base_url_help", lang), marcador="https://painel.exemplo.com"), + f""" +
    + +
    {esc(endereco_de_retorno or "-")}
    +
    """, + campo_de_texto("oidc_issuer", translate("sso.oidc_issuer", lang), config.get("oidc_issuer", ""), + marcador="https://accounts.google.com"), + campo_de_texto("oidc_client_id", translate("sso.oidc_client_id", lang), + config.get("oidc_client_id", "")), + campo_de_texto("oidc_client_secret", translate("sso.oidc_client_secret", lang), "", + estado_do_segredo, tipo="password", + travado=segredo_do_ambiente, + marcador="••••••••" if tem_segredo else ""), + campo_de_texto("oidc_scopes", translate("sso.oidc_scopes", lang), + config.get("oidc_scopes", ""), marcador="openid email profile"), + ]) + + aba_saml = "".join([ + f""" +
    + +
    {esc(translate("sso.saml_unavailable", lang))}
    +
    """, + campo_de_texto("saml_idp_entity_id", translate("sso.saml_entity_id", lang), + config.get("saml_idp_entity_id", ""), travado=True), + campo_de_texto("saml_idp_sso_url", translate("sso.saml_sso_url", lang), + config.get("saml_idp_sso_url", ""), travado=True), + campo_de_texto("saml_idp_cert", translate("sso.saml_cert", lang), + config.get("saml_idp_cert", ""), travado=True), + ]) + + return f""" + """ + + def render_dashboard( *, connections: List[Any], combos: List[Dict[str, Any]], cron: Dict[str, Any], + # Chaves virtuais e modelos entraram depois dos outros cartoes e sao + # opcionais na assinatura: quem chama sem eles (um teste antigo, um script) + # continua desenhando a pagina, com os dois cartoes no estado vazio. + keys: Optional[List[Any]] = None, + models: Optional[List[Any]] = None, gateway: Dict[str, Any], db_path: str, router_url: str, @@ -800,9 +1220,19 @@ def render_dashboard( auth_from_env: bool = False, flash: Optional[Dict[str, str]] = None, lang: str = DEFAULT_LANGUAGE, + # Entrada federada. Opcional na assinatura pelo mesmo motivo de `keys` e + # `models`: quem chama sem eles -- um teste antigo, um script -- continua + # desenhando a pagina, com o SSO desligado. + sso_config: Optional[Dict[str, str]] = None, + sso_tem_segredo: bool = False, + sso_segredo_do_ambiente: bool = False, + sso_desligado_pelo_ambiente: bool = False, + sso_endereco_de_retorno: str = "", ) -> str: """Monta a página completa do dashboard, já com todos os dados embutidos.""" lang = normalize_language(lang) + keys = keys or [] + models = models or [] oauth_count = sum(1 for c in connections if c.is_oauth) apikey_count = sum(1 for c in connections if c.has_api_key) generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") @@ -904,6 +1334,11 @@ def render_dashboard( --bs-btn-color: var(--bg); --bs-btn-hover-color: var(--bg); --bs-btn-active-color: var(--bg); }} a {{ color: var(--accent-2); }} a:hover {{ color: var(--accent); }} + /* Abas do modal de SSO. Pintadas com os tokens que ja existem, e nunca com + tokens novos: um token a mais aqui seria um componente que so este painel + sabe desenhar, e a simetria entre os tres acabaria nele. */ + .nav-sso .nav-link {{ color: var(--text-dim); }} + .nav-sso .nav-link.active {{ background: var(--accent); color: var(--bg); }} /* Barra de acoes do cabecalho: todos os controles com a MESMA altura. O seletor de idioma carrega so a bandeira, um elemento com altura propria; sem texto ao lado para definir a linha, ele esticava o botao. */ @@ -971,6 +1406,10 @@ def render_dashboard( {esc(translate("action.sync_now", lang))} +
    - {sso_html} + {botao_sso} @@ -520,183 +475,68 @@ def render_security_banner(is_default_password: bool, lang: str) -> str: """ +def render_credentials_modal(auth_from_env: bool, lang: str) -> str: + """Corpo do modal de troca de credenciais. -def render_sso_modal( - config: Dict[str, str], - lang: str, - tem_segredo: bool, - segredo_do_ambiente: bool, - desligado_pelo_ambiente: bool, - endereco_de_retorno: str, -) -> str: - """Tela de configuracao da entrada federada, com as duas abas. - - O segredo do cliente NUNCA volta para ca: o campo nasce vazio, a tela diz - apenas se existe um guardado, e salvar em branco MANTEM o anterior. Um GET - de configuracao que devolvesse o valor seria o mesmo que publica-lo no HTML. + Nenhum valor vem preenchido: um usuário sugerido na tela é uma metade da + credencial entregue de graça a quem abrir a página. """ - ativo = config.get("enabled") or "" - aviso_ambiente = ( - f'' - if desligado_pelo_ambiente - else "" - ) - - if segredo_do_ambiente: - estado_do_segredo = translate("sso.secret_from_env", lang) - elif tem_segredo: - estado_do_segredo = translate("sso.secret_stored", lang) - else: - estado_do_segredo = translate("sso.secret_missing", lang) - - aba_oidc = "".join([ - campo_de_texto("base_url", translate("sso.base_url", lang), config.get("base_url", ""), - translate("sso.base_url_help", lang), marcador="https://painel.exemplo.com"), - f""" -
    - -
    {esc(endereco_de_retorno or "-")}
    -
    """, - campo_de_texto("oidc_issuer", translate("sso.issuer", lang), config.get("oidc_issuer", ""), - marcador="https://accounts.google.com"), - campo_de_texto("oidc_client_id", translate("sso.client_id", lang), - config.get("oidc_client_id", "")), - campo_de_texto("oidc_client_secret", translate("sso.client_secret", lang), "", - estado_do_segredo, tipo="password", - travado=segredo_do_ambiente, - marcador="••••••••" if tem_segredo else ""), - campo_de_texto("oidc_scopes", translate("sso.scopes", lang), - config.get("oidc_scopes", ""), marcador="openid email profile"), - ]) - - aba_saml = "".join([ - f""" -
    - -
    {esc(translate("sso.saml_unavailable", lang))}
    -
    """, - campo_de_texto("saml_idp_entity_id", translate("sso.idp_entity_id", lang), - config.get("saml_idp_entity_id", ""), travado=True), - campo_de_texto("saml_idp_sso_url", translate("sso.idp_sso_url", lang), - config.get("saml_idp_sso_url", ""), travado=True), - campo_de_texto("saml_idp_cert", translate("sso.idp_cert", lang), - config.get("saml_idp_cert", ""), travado=True), - ]) - + if auth_from_env: + return f""" +
    + +
    {translate("auth.env_managed", lang)}
    +
    """ return f""" - """ + """ -def cabecalho_de_dominio(rows: List[str], lang: str) -> str: - """A casca das tabelas de dominio: SEMPRE as mesmas sete colunas. - - Conexoes, chaves virtuais e modelos sao coisas diferentes lidas do mesmo - jeito -- quem serve, como se chama, de que tipo e, como esta, quanto tempo - resta, quando foi renovado, e o (i) que abre o resto. Uma casca so mantem a - largura das colunas identica entre os cartoes e entre os tres paineis. - """ +def render_flash(flash: Optional[Dict[str, str]]) -> str: + if not flash: + return "" + tone = flash.get("tone", "info") + icon = { + "success": "bi-check-circle-fill", + "danger": "bi-exclamation-octagon-fill", + "warning": "bi-exclamation-triangle-fill", + "info": "bi-info-circle-fill", + }.get(tone, "bi-info-circle-fill") return f""" -
    - - - - - - - - - - - - - - - - - - {"".join(rows)} - -
    {esc(translate("table.provider", lang))}{esc(translate("table.name", lang))}{esc(translate("table.type", lang))}{esc(translate("table.status", lang))}{esc(translate("table.remaining", lang))}{esc(translate("table.last_refresh", lang))}{esc(translate("table.details", lang))}
    -
    """ +
    + +
    {esc(flash.get("message", ""))}
    +
    """ -def estado_vazio(mensagem: str) -> str: - """O bloco de estado vazio da familia: icone bi-inbox e uma frase. +def estado_vazio(mensagem: str, dica: str = "") -> str: + """O bloco de estado vazio da familia: icone bi-inbox, uma frase e a dica. Os quatro cartoes de tabela usam exatamente este bloco. Cada um deles existe nos tres paineis por contrato; quando o gateway deste produto nao tem aquele conceito, ou ainda nao tem dado nenhum, o cartao continua na tela e a frase diz por que esta vazio AQUI. Assimetria de cartoes e pior que estado vazio. """ + complemento = f'\n
    {esc(dica)}
    ' if dica else "" return f"""
    - {esc(mensagem)} + {esc(mensagem)}{complemento}
    """ @@ -742,6 +582,59 @@ def render_detail_modal(modal_id: str, titulo: str, linhas: List[tuple], lang: s """ +def cabecalho_de_dominio(rows: List[str], lang: str) -> str: + """A casca das tabelas de dominio: SEMPRE as mesmas sete colunas. + + Conexoes, chaves virtuais e modelos sao coisas diferentes lidas do mesmo + jeito -- quem serve, como se chama, de que tipo e, como esta, quanto tempo + resta, quando foi renovado, e o (i) que abre o resto. Uma casca so mantem a + largura das colunas identica entre os cartoes e entre os tres paineis. + """ + return f""" +
    + + + + + + + + + + + + + + + + + + {"".join(rows)} + +
    {esc(translate("table.provider", lang))}{esc(translate("table.name", lang))}{esc(translate("table.type", lang))}{esc(translate("table.status", lang))}{esc(translate("table.remaining", lang))}{esc(translate("table.last_refresh", lang))}{esc(translate("table.details", lang))}
    +
    """ + + +def grid_paginado(nome: str, tabela: str, estado: Dict[str, Any], consulta: Any, + lang: str, detalhes: str = "") -> str: + """Envelope de um grid: a tabela, a barra de paginas e os modais das linhas. + + Todo grid do painel passa por aqui, e e o que garante as dez linhas por + pagina em todos eles -- um grid que nao passasse seria justamente o que + despejaria o catalogo inteiro na tela. + + O `id` do envelope e o destino do link da barra (`#grid-`): sem ele, + trocar de pagina recarrega a tela no topo e o operador perde de vista a + tabela que estava lendo. + + Os modais saem DEPOIS do envelope, e nunca de dentro da tabela: um `
    ` + em `` e HTML invalido, e o navegador o move sozinho para fora. + """ + return f""" +
    {tabela}{render_paginacao(estado, consulta, translate, lang)} +
    """ + detalhes + + def render_remaining_seconds(remaining: Optional[int], lang: str) -> str: """Validade restante de um item que nao e conexao (chave virtual, modelo). @@ -762,11 +655,133 @@ def render_timestamp_cell(carimbo: Optional[str], lang: str, icone: str) -> str: f'{esc(format_timestamp_curto(carimbo))}') +def render_refresh_reason(conn: Any, refresh_margin: int, lang: str = DEFAULT_LANGUAGE) -> str: + """Explica, em uma frase, por que a conexão foi ou não renovada. + + Sem isso o painel mostra apenas "0 renovadas" e não há como distinguir + "nada precisava ser renovado" de "a renovação falhou". + """ + if conn.is_local: + # Quem diz se a instância respondeu é a sonda, não o tamanho do + # catálogo: uma instalação nova, de pé e sem nenhum modelo baixado, + # devolve lista vazia com HTTP 200. Contar modelos aqui a anunciava + # como inalcançável, contradizendo o "ativa" que o próprio ciclo + # acabara de gravar no banco. + if conn.data.get("testStatus") == "unreachable": + return translate("reason.local_unreachable", lang) + models = conn.local_models + if models: + return translate("reason.local_ok", lang, count=len(models)) + return translate("reason.local_empty", lang) + + if not conn.is_oauth: + return translate("reason.api_key", lang) + + remaining = conn.remaining_seconds + if remaining is None: + return translate("reason.no_expiry", lang) + if remaining <= 0: + return translate("reason.expired", lang) + + margin_min = max(1, refresh_margin // 60) + if remaining <= refresh_margin: + return translate("reason.inside_margin", lang, margin=margin_min) + return translate( + "reason.outside_margin", + lang, + margin=margin_min, + eta=format_duration(remaining - refresh_margin, lang), + ) + + +def render_last_refresh(conn: Any, lang: str) -> str: + """Mostra quando a credencial foi renovada pela ultima vez, e ha quanto tempo.""" + stamp = conn.last_refresh_at + if not stamp: + return f'{esc(translate("table.never_refreshed", lang))}' + + ago = "" + try: + moment = datetime.fromisoformat(str(stamp).replace("Z", "+00:00")) + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + elapsed = int((datetime.now(timezone.utc) - moment).total_seconds()) + if elapsed >= 0: + ago = translate("table.time_ago", lang, elapsed=format_duration(elapsed, lang)) + except (ValueError, TypeError): + # Carimbo de tempo em formato desconhecido vira "sem informacao" na + # tela. Uma data ilegivel nao pode derrubar a renderizacao da pagina. + pass + + icon = '' + detail = f'
    {esc(ago)}
    ' if ago else "" + return f'{icon}{esc(format_timestamp_curto(stamp))}{detail}' + + +def render_remaining(conn: Any, lang: str, curto: bool = False) -> str: + """Validade restante, sem chamar de ilimitado o que so esta faltando. + + Um token OAuth sempre expira. Quando nao ha expiresAt legivel, isso e dado + ausente -- normalmente porque o gateway gravou a validade num formato que + nao soube reler -- e nao uma credencial eterna. So chave estatica pode ser + apresentada como sem expiracao. + """ + remaining = conn.remaining_seconds + if remaining is not None: + return esc(format_duration(remaining, lang)) + + if conn.is_oauth: + return ( + '' + '' + f'{esc(translate("duration.unknown_expiry", lang))}' + ) + # Na celula cabe o fato; a razao ("chave estatica") fica no modal. + chave = "duration.no_expiry_short" if curto else "duration.no_expiry" + return f'{esc(translate(chave, lang))}' + + +def egress_chip(conn: Any, sharing_count: int, lang: str) -> str: + """Marca de saída de rede da conexão, em modo somente leitura. + + O risco de bloqueio não vem de várias sessões na mesma conta -- isso os + provedores aceitam -- e sim de várias contas saindo pelo mesmo endereço. + Por isso "compartilhada" só vira aviso a partir da segunda conta nessa + situação: sozinha, ela é a única dona daquele IP. + """ + estado = conn.egress_status + if estado == "bound": + pool = conn.egress_binding or "?" + return ( + '' + f'' + f'{esc(translate("egress.bound", lang))}: {esc(pool)}' + ) + if estado == "shared": + if sharing_count > 1: + return ( + '' + f'' + f'{esc(translate("egress.shared", lang, count=sharing_count))}' + ) + return ( + '' + f'' + f'{esc(translate("egress.single", lang))}' + ) + return ( + '' + f'' + f'{esc(translate("egress.unknown", lang))}' + ) + + def key_state_label(key: Any, lang: str) -> str: """Por qual caminho a chave foi recusada -- ou que ela segue aceita. A celula da tabela diz "recusada" nos tres casos, porque o efeito e o mesmo. - Qual deles foi so cabe aqui, no modal. + Qual deles foi so cabe aqui, no modal. Um gateway que so tem uma bandeira + cai no ultimo caso e nunca promete os rotulos que nao guarda. """ dados = key.data if dados.get("isBanned"): @@ -810,16 +825,17 @@ def render_key_details(key: Any, modal_id: str, lang: str) -> str: return render_detail_modal(modal_id, key.name, linhas, lang, extra) -def render_keys_table(keys: List[Any], lang: str) -> str: +def render_keys_table(keys: List[Any], lang: str, consulta: Any = None) -> str: """Chaves virtuais emitidas pelo gateway, uma por linha, nas sete colunas.""" if not keys: return estado_vazio(translate("keys.empty", lang)) + visiveis, estado = recortar(keys, "chaves", consulta) rows = [] detalhes = [] # Id do modal pelo INDICE, nunca pelo nome: nome de chave aceita espaco, # acento e barra, e nada disso vale como id de elemento HTML. - for indice, key in enumerate(keys): + for indice, key in enumerate(visiveis): modal_id = f"detalhe-chave-{indice}" rows.append(f""" @@ -835,7 +851,8 @@ def render_keys_table(keys: List[Any], lang: str) -> str: """) detalhes.append(render_key_details(key, modal_id, lang)) - return cabecalho_de_dominio(rows, lang) + "".join(detalhes) + return grid_paginado("chaves", cabecalho_de_dominio(rows, lang), estado, consulta, + lang, "".join(detalhes)) def render_model_details(model: Any, modal_id: str, lang: str) -> str: @@ -872,21 +889,23 @@ def numero(valor: Any) -> str: return render_detail_modal(modal_id, model.id, linhas, lang, extra) -def render_models_table(models: List[Any], lang: str, estado: str = "ok") -> str: +def render_models_table(models: List[Any], lang: str, estado_do_catalogo: str = "ok", + consulta: Any = None) -> str: """Modelos que o gateway publica, nas mesmas sete colunas dos irmaos. - `estado` carrega POR QUE a lista veio vazia. Sem isso, "nenhum modelo" e - "nao deu para perguntar" desenham a mesma tela, e o operador vai procurar um - cadastro faltando quando o problema era o gateway nao ter respondido. + `estado_do_catalogo` carrega POR QUE a lista veio vazia. Sem isso, "nenhum + modelo" e "nao deu para perguntar" desenham a mesma tela, e o operador vai + procurar um cadastro faltando quando o problema era o gateway nao ter + respondido. """ if not models: motivo = { "no_key": "models.no_key", "unreachable": "models.unreachable", - }.get(estado, "models.empty") + }.get(estado_do_catalogo, "models.empty") return estado_vazio(translate(motivo, lang)) - visiveis = models[:MAX_LINHAS_DE_MODELO] + visiveis, estado = recortar(models, "modelos", consulta) rows = [] detalhes = [] for indice, model in enumerate(visiveis): @@ -907,13 +926,8 @@ def render_models_table(models: List[Any], lang: str, estado: str = "ok") -> str """) detalhes.append(render_model_details(model, modal_id, lang)) - # Truncar em silencio seria mentir sobre o tamanho do catalogo. - rodape = "" - if len(models) > len(visiveis): - rodape = (f'

    ' - f'{esc(translate("models.showing", lang, shown=len(visiveis), total=len(models)))}

    ') - - return cabecalho_de_dominio(rows, lang) + rodape + "".join(detalhes) + return grid_paginado("modelos", cabecalho_de_dominio(rows, lang), estado, consulta, + lang, "".join(detalhes)) def render_connection_details(conn: Any, refresh_margin: int, sharing_count: int, lang: str) -> str: @@ -938,54 +952,40 @@ def render_connection_details(conn: Any, refresh_margin: int, sharing_count: int if chip: linhas.append((translate("egress.title", lang), chip)) - corpo = "".join( - f'
    {esc(rotulo)}
    ' - f'
    {valor}
    ' - for rotulo, valor in linhas - ) modelos = "" if conn.is_local and conn.local_models: itens = "".join(f'
  • {esc(m)}
  • ' for m in conn.local_models) modelos = (f'

    {esc(translate("table.models", lang))}

    ' f'
      {itens}
    ') - return f""" - """ + extra = ( + f'

    {esc(translate("table.diagnosis", lang))}

    ' + f'

    {esc(render_refresh_reason(conn, refresh_margin, lang))}

    ' + f'{modelos}' + ) + return render_detail_modal(f"detalhe-{conn.id}", conn.name, linhas, lang, extra) -def render_connections_table(connections: List[Any], refresh_margin: int, lang: str) -> str: +def render_connections_table(connections: List[Any], refresh_margin: int, lang: str, + consulta: Any = None) -> str: if not connections: - return estado_vazio(translate("connections.empty", lang)) - - # Quantas contas de nuvem saem pelo endereço padrão do gateway. Uma conta - # sozinha compartilhando não é problema nenhum -- ela é a única a usar - # aquele IP. O alerta só faz sentido a partir da segunda, que é quando o - # provedor passa a ver identidades distintas na mesma origem. + return estado_vazio(translate("connections.empty", lang), + translate("connections.empty_hint", lang)) + + # Quantas contas de nuvem saem pelo endereço padrão do gateway. Conta-se + # sobre a lista INTEIRA, e não sobre a página: o alerta é sobre quantas + # identidades dividem o endereço, e isso não muda quando se vira a página. + # Uma conta sozinha compartilhando não é problema nenhum -- ela é a única a + # usar aquele IP. O alerta só faz sentido a partir da segunda, que é quando + # o provedor passa a ver identidades distintas na mesma origem. compartilhando = sum( 1 for c in connections if not c.is_local and c.egress_status == "shared" ) + visiveis, estado = recortar(connections, "conexoes", consulta) rows = [] detalhes = [] - for c in connections: + for c in visiveis: if c.is_local: kind, kind_icon = translate("type.local", lang), "bi-hdd-network" elif c.is_oauth: @@ -1032,28 +1032,41 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang: """) detalhes.append(render_connection_details(c, refresh_margin, compartilhando, lang)) - return cabecalho_de_dominio(rows, lang) + "".join(detalhes) + return grid_paginado("conexoes", cabecalho_de_dominio(rows, lang), estado, consulta, + lang, "".join(detalhes)) -def render_combos_table(combos: List[Dict[str, Any]], lang: str) -> str: +def render_combos_table(combos: List[Dict[str, Any]], lang: str, consulta: Any = None) -> str: + """Combos de resiliencia: o combo e a cascata que ele aciona. + + O tipo do fallback vira um chip ao lado do nome quando o gateway o + classifica. Dois combos com o mesmo modelo principal e cascatas diferentes, + sem dizer por que disparam, confundiriam; um gateway que nao classifica nao + manda a chave, e a linha sai sem chip. + """ if not combos: - return estado_vazio(translate("combos.empty", lang)) + return estado_vazio(translate("combos.empty", lang), + translate("combos.empty_hint", lang)) + visiveis, estado = recortar(combos, "combos", consulta) rows = [] - for combo in combos: + for combo in visiveis: models = combo.get("models") or [] if isinstance(models, str): models = [models] preview = ", ".join(str(m) for m in models[:4]) if len(models) > 4: preview += f" (+{len(models) - 4})" + chave_do_tipo = combo.get("kindLabelKey") or "" + chip = (f' {esc(translate(chave_do_tipo, lang))}' + if chave_do_tipo else "") rows.append(f""" - {esc(combo.get("name", "—"))} + {esc(combo.get("name", "—"))}{chip} {esc(preview) or "—"} """) - return f""" + tabela = f"""
    @@ -1066,10 +1079,16 @@ def render_combos_table(combos: List[Dict[str, Any]], lang: str) -> str:
    """ + return grid_paginado("combos", tabela, estado, consulta, lang) def render_cron_history(history: List[Dict[str, Any]], lang: str) -> str: - """Lista de execuções do cron, cada uma com o log do que realmente aconteceu.""" + """Lista de execuções do agendador, cada uma com o log do que aconteceu. + + O contador sozinho não distingue "nada a relatar" de "a inspeção falhou". + O log de cada ciclo é o que responde a essa pergunta sem obrigar ninguém a + abrir o arquivo de log do serviço. + """ if not history: return f'

    {esc(translate("cron.no_runs", lang))}

    ' @@ -1098,10 +1117,7 @@ def render_cron_history(history: List[Dict[str, Any]], lang: str) -> str: {esc(format_timestamp(entry.get("timestamp")))} - {esc(translate("cron.result_line", lang, - inspected=entry.get("totalInspected", 0), - refreshed=entry.get("refreshedCount", 0), - duration=entry.get("durationMs", 0)))} + {esc(linha_do_ciclo(entry, lang))} @@ -1114,7 +1130,29 @@ def render_cron_history(history: List[Dict[str, Any]], lang: str) -> str: return f'
    {"".join(items)}
    ' +def linha_do_ciclo(resultado: Dict[str, Any], lang: str) -> str: + """O resumo de um ciclo: quantos foram olhados e o que o ciclo produziu. + + Os dois marcadores viajam juntos porque o trabalho do agendador muda com o + gateway -- um renova credencial, outro relata achado -- e a frase traduzida + usa o marcador do seu produto. `str.format` ignora o que sobra, entao passar + os dois deixa a MESMA chamada correta nos tres paineis; escolher um faria a + contagem do outro aparecer zerada na tela. + """ + renovados = resultado.get("refreshedCount", resultado.get("findingsCount", 0)) + achados = resultado.get("findingsCount", resultado.get("refreshedCount", 0)) + return translate( + "cron.result_line", + lang, + inspected=resultado.get("totalInspected", 0), + refreshed=renovados, + findings=achados, + duration=resultado.get("durationMs", 0), + ) + + def render_cron_card(cron: Dict[str, Any], lang: str) -> str: + """Cartão do agendador: o estado do ciclo e o resultado da última passada.""" active = bool(cron.get("active")) state_icon = "bi-broadcast text-success" if active else "bi-pause-circle text-secondary" state_text = ( @@ -1125,6 +1163,15 @@ def render_cron_card(cron: Dict[str, Any], lang: str) -> str: last = cron.get("lastResult") or {} failed = bool(last) and (not last.get("success", True) or last.get("error")) + # O acumulado do agendador conta uma coisa em cada gateway: um soma + # credenciais renovadas, o outro soma achados da inspecao. Quem diz qual e o + # proprio estado, pelo contador que ele mantem -- rotular pelo produto faria + # a tela prometer um numero que aquele ciclo nunca produz. + if "totalFindings" in cron: + rotulo_do_total, total_do_ciclo = "cron.total_findings", cron.get("totalFindings", 0) + else: + rotulo_do_total, total_do_ciclo = "cron.total_renewals", cron.get("totalRenewals", 0) + return f"""
    @@ -1151,15 +1198,11 @@ def render_cron_card(cron: Dict[str, Any], lang: str) -> str:
    {esc(translate("cron.next_run", lang))}
    {esc(format_timestamp(cron.get("nextRunAt")))}
    -
    {esc(translate("cron.total_renewals", lang))}
    -
    {esc(cron.get("totalRenewals", 0))}
    +
    {esc(translate(rotulo_do_total, lang))}
    +
    {esc(total_do_ciclo)}
    {esc(translate("cron.last_result", lang))}
    - {esc(translate("cron.result_line", lang, - inspected=last.get("totalInspected", 0), - refreshed=last.get("refreshedCount", 0), - duration=last.get("durationMs", 0)) - if last else translate("cron.no_runs", lang))} + {esc(linha_do_ciclo(last, lang) if last else translate("cron.no_runs", lang))} {esc(last.get("error") or "")}
    @@ -1168,17 +1211,30 @@ def render_cron_card(cron: Dict[str, Any], lang: str) -> str: def render_gateway_card(gateway: Dict[str, Any], db_path: str, lang: str) -> str: + """Cartão de liveness do gateway. + + Existe para que "o painel está de pé" e "o gateway está de pé" nunca sejam + confundidos: são dois processos distintos, e o painel responde mesmo com o + gateway fora. + + As linhas de banco e de diagnóstico só aparecem quando o estado TRAZ a + bandeira `dbOk`. Um gateway que não guarda banco próprio não tem o que dizer + ali, e desenhar "banco não encontrado" para ele afirmaria uma falha que não + existe. + """ online = bool(gateway.get("online")) tone = "text-success" if online else "text-danger" icon = "bi-plug-fill" if online else "bi-plug" + codigo = gateway.get("statusCode") label = ( - f'ONLINE (HTTP {esc(gateway.get("statusCode", "—"))})' + (f'ONLINE (HTTP {esc(codigo)})' if codigo else "ONLINE") if online else f'{esc(translate("gateway.offline", lang))} — ' f'{esc(gateway.get("error") or translate("gateway.no_response", lang))}' ) # Le a bandeira; o resumo textual nunca serve como booleano. + tem_banco = "dbOk" in gateway db_ok = bool(gateway.get("dbOk")) if online and db_ok: diagnosis = translate("gateway.diag_ok", lang) @@ -1187,6 +1243,17 @@ def render_gateway_card(gateway: Dict[str, Any], db_path: str, lang: str) -> str else: diagnosis = translate("gateway.diag_gateway_failed", lang) + bloco_do_banco = f""" +
    {esc(translate("gateway.database", lang))}
    +
    + {esc(translate("gateway.db_summary", lang, + connections=gateway.get("dbConnections", 0), + combos=gateway.get("dbCombos", 0)) + if db_ok else translate("gateway.db_missing", lang))} +
    +
    {esc(translate("gateway.diagnostics", lang))}
    +
    {esc(diagnosis)}
    """ if tem_banco else "" + return f"""
    @@ -1208,38 +1275,12 @@ def render_gateway_card(gateway: Dict[str, Any], db_path: str, lang: str) -> str {label}
    {esc(translate("gateway.latency", lang))}
    -
    {esc(gateway.get("latencyMs", "—"))} ms
    -
    {esc(translate("gateway.database", lang))}
    -
    - {esc(translate("gateway.db_summary", lang, - connections=gateway.get("dbConnections", 0), - combos=gateway.get("dbCombos", 0)) - if db_ok else translate("gateway.db_missing", lang))} -
    -
    {esc(translate("gateway.diagnostics", lang))}
    -
    {esc(diagnosis)}
    +
    {esc(gateway.get("latencyMs", "—"))} ms
    {bloco_do_banco}
    """ -def render_flash(flash: Optional[Dict[str, str]]) -> str: - if not flash: - return "" - tone = flash.get("tone", "info") - icon = { - "success": "bi-check-circle-fill", - "danger": "bi-exclamation-octagon-fill", - "warning": "bi-exclamation-triangle-fill", - "info": "bi-info-circle-fill", - }.get(tone, "bi-info-circle-fill") - return f""" -
    - -
    {esc(flash.get("message", ""))}
    -
    """ - - def campo_de_texto( nome: str, rotulo: str, valor: str, ajuda: str = "", tipo: str = "text", travado: bool = False, marcador: str = "", @@ -1256,28 +1297,165 @@ def campo_de_texto(
    """ +def render_sso_modal( + config: Dict[str, str], + lang: str, + tem_segredo: bool = False, + segredo_do_ambiente: bool = False, + desligado_pelo_ambiente: bool = False, + endereco_de_retorno: str = "", +) -> str: + """Corpo do modal da entrada federada, com as duas abas. + + A casca do modal (titulo, botao de fechar) e comum aos tres paineis e mora + em `render_dashboard`; daqui sai so o CORPO, que e a parte presa ao + formulario que `web.py` sabe receber. + + O segredo do cliente NUNCA volta para ca: o campo nasce vazio, a tela diz + apenas se existe um guardado, e salvar em branco MANTEM o anterior. Um GET + de configuracao que devolvesse o valor seria o mesmo que publica-lo no HTML. + """ + ativo = config.get("enabled") or "" + aviso_ambiente = ( + f'' + if desligado_pelo_ambiente + else "" + ) + + if segredo_do_ambiente: + estado_do_segredo = translate("sso.secret_from_env", lang) + elif tem_segredo: + estado_do_segredo = translate("sso.secret_stored", lang) + else: + estado_do_segredo = translate("sso.secret_missing", lang) + + aba_oidc = "".join([ + campo_de_texto("base_url", translate("sso.base_url", lang), config.get("base_url", ""), + translate("sso.base_url_help", lang), marcador="https://painel.exemplo.com"), + f""" +
    + +
    {esc(endereco_de_retorno or "-")}
    +
    """, + campo_de_texto("oidc_issuer", translate("sso.issuer", lang), config.get("oidc_issuer", ""), + marcador="https://accounts.google.com"), + campo_de_texto("oidc_client_id", translate("sso.client_id", lang), + config.get("oidc_client_id", "")), + campo_de_texto("oidc_client_secret", translate("sso.client_secret", lang), "", + estado_do_segredo, tipo="password", + travado=segredo_do_ambiente, + marcador="••••••••" if tem_segredo else ""), + campo_de_texto("oidc_scopes", translate("sso.scopes", lang), + config.get("oidc_scopes", ""), marcador="openid email profile"), + ]) + + aba_saml = "".join([ + f""" +
    + +
    {esc(translate("sso.saml_unavailable", lang))}
    +
    """, + campo_de_texto("saml_idp_entity_id", translate("sso.idp_entity_id", lang), + config.get("saml_idp_entity_id", ""), travado=True), + campo_de_texto("saml_idp_sso_url", translate("sso.idp_sso_url", lang), + config.get("saml_idp_sso_url", ""), travado=True), + campo_de_texto("saml_idp_cert", translate("sso.idp_cert", lang), + config.get("saml_idp_cert", ""), travado=True), + ]) + + return f""" + {aviso_ambiente} +

    {esc(translate("sso.intro", lang))}

    +
    + +
    +
    {aba_oidc} +
    +
    {aba_saml} +
    +
    + +
    + {campo_de_texto("allowed_domains", translate("sso.allowed_domains", lang), + config.get("allowed_domains", ""), marcador="empresa.com,filial.com")} + {campo_de_texto("allowed_emails", translate("sso.allowed_emails", lang), + config.get("allowed_emails", ""), + translate("sso.allowlist_help", lang), marcador="chefe@empresa.com")} + +
    + + +
    + +
    + + +
    {esc(translate("sso.local_password_help", lang))}
    +
    + +

    {esc(translate("sso.logout_note", lang))}

    + + +
    """ + + def render_dashboard( *, - connections: List[Any], - combos: List[Dict[str, Any]], - cron: Dict[str, Any], - # Chaves virtuais e modelos entraram depois dos outros cartoes e sao - # opcionais na assinatura: quem chama sem eles (um teste antigo, um script) - # continua desenhando a pagina, com os dois cartoes no estado vazio. + # Os seis cartoes, na ordem do contrato. Todos opcionais na assinatura: quem + # chama sem um deles -- um teste, um script -- continua desenhando a pagina, + # com o cartao no estado vazio, que e o comportamento correto. + connections: Optional[List[Any]] = None, + combos: Optional[List[Dict[str, Any]]] = None, keys: Optional[List[Any]] = None, models: Optional[List[Any]] = None, - gateway: Dict[str, Any], - db_path: str, - router_url: str, - current_user: str, - is_default_password: bool, - refresh_margin: int, + cron: Optional[Dict[str, Any]] = None, + gateway: Optional[Dict[str, Any]] = None, + db_path: str = "", + router_url: str = "", + current_user: str = "", + is_default_password: bool = False, + refresh_margin: int = 900, auth_from_env: bool = False, flash: Optional[Dict[str, str]] = None, lang: str = DEFAULT_LANGUAGE, - # Entrada federada. Opcional na assinatura pelo mesmo motivo de `keys` e - # `models`: quem chama sem eles -- um teste antigo, um script -- continua - # desenhando a pagina, com o SSO desligado. + # A query da requisicao, de onde sai a pagina de cada grid (`?pag_modelos=2`). + # Ausente, todo grid abre na primeira pagina -- que e o que acontece hoje, + # enquanto `web.py` ainda nao repassa a query. + consulta: Optional[Dict[str, List[str]]] = None, + # Estado que so um dos gateways produz. Chega por nome para que a MESMA + # assinatura sirva aos tres `web.py`; o que este painel nao usa fica em + # branco e nao aparece na tela. + models_state: str = "ok", + model_states: Optional[Dict[str, str]] = None, + team_aliases: Optional[Dict[str, str]] = None, + findings: Optional[List[Dict[str, Any]]] = None, + counters: Optional[Dict[str, Any]] = None, + # `proxy` e a grafia com que o `web.py` de um dos irmaos chama o gateway. As + # duas apontam para o mesmo estado, e a segunda sai quando `web.py` convergir. + proxy: Optional[Dict[str, Any]] = None, + # Estado do SSO para a tela de configuracao, nas tres grafias que os + # `web.py` ainda usam. Todas opcionais: sem nenhuma, o modal aparece no + # estado desligado, que e o correto de um painel sem SSO configurado. + sso_view: Optional[Dict[str, Any]] = None, + sso: Any = None, sso_config: Optional[Dict[str, str]] = None, sso_tem_segredo: bool = False, sso_segredo_do_ambiente: bool = False, @@ -1286,11 +1464,19 @@ def render_dashboard( ) -> str: """Monta a página completa do dashboard, já com todos os dados embutidos.""" lang = normalize_language(lang) + connections = connections or [] + combos = combos or [] keys = keys or [] models = models or [] + cron = cron or {} + counters = counters or {} + findings = findings or [] + model_states = model_states or {} + gateway = gateway or proxy or {} + router_url = router_url or gateway.get("url") or "" + generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") oauth_count = sum(1 for c in connections if c.is_oauth) apikey_count = sum(1 for c in connections if c.has_api_key) - generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") metrics = "".join([ metric_card(translate("metric.total_connections", lang), len(connections), "bi-diagram-2", "text-info"), @@ -1299,32 +1485,13 @@ def render_dashboard( metric_card(translate("metric.combos", lang), len(combos), "bi-diagram-3", "text-success"), ]) - change_password_block = ( - f""" -
    - -
    {translate("auth.env_managed", lang)}
    -
    """ - if auth_from_env - else f""" -
    -
    - - -
    -
    - - -
    {esc(translate("password.policy", lang))}
    -
    - -
    """ - ) + tabela_de_conexoes = render_connections_table(connections, refresh_margin, lang, consulta) + tabela_de_chaves = render_keys_table(keys, lang, consulta) + tabela_de_modelos = render_models_table(models, lang, models_state, consulta) + tabela_de_combos = render_combos_table(combos, lang, consulta) + corpo_do_sso = render_sso_modal(sso_config or {}, lang, sso_tem_segredo, + sso_segredo_do_ambiente, sso_desligado_pelo_ambiente, + sso_endereco_de_retorno) return f""" @@ -1343,9 +1510,9 @@ def render_dashboard(