Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/wiki/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,17 @@ environment:
- LOG_DIR=/app/data/logs
- LOG_RETENTION_DAYS=90
- LOG_TO_STDOUT=0
# The image ships a HEALTHCHECK that probes /healthz, which only the dashboard
# serves. With the dashboard off, that probe can never succeed and the container
# is reported unhealthy forever -- which also stops any `depends_on:
# service_healthy` from ever being satisfied. Disable the check along with the
# dashboard:
healthcheck:
disable: true
```

> **Portugues:** a imagem traz um HEALTHCHECK que consulta `/healthz`, servido
> apenas pelo painel. Com o painel desligado a sonda nunca passa e o container
> fica eternamente marcado como *unhealthy*, travando qualquer
> `depends_on: service_healthy`. Desligue a verificacao junto com o painel, como
> no exemplo acima.
9 changes: 9 additions & 0 deletions src/nine_rtksync/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ def _sync_all_locked(self) -> Dict[str, Any]:
# linha velha, ou o ciclo anunciava renovacao que nao
# houve.
if refreshed_data:
if renewed:
# Carimbar aqui, e nao dentro de cada provider,
# faz qualquer handler futuro ja nascer certo. O
# painel lia `lastRefreshAt` e nenhum provider o
# escrevia, entao "ultima renovacao" mostrava na
# verdade o horario da ultima verificacao.
refreshed_data["lastRefreshAt"] = datetime.now(
timezone.utc
).isoformat(timespec="milliseconds").replace("+00:00", "Z")
update_connection_data(self.settings.db_path, conn.id, refreshed_data)
if renewed:
summary["refreshed"] += 1
Expand Down
15 changes: 15 additions & 0 deletions src/nine_rtksync/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@
"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.bound": "own egress",
"egress.shared": "shares the gateway address with {count} accounts",
"egress.single": "gateway address (only account)",
"egress.unknown": "egress unknown",
"table.combo": "Combo",
"table.cascade": "Model cascade",
"combos.title": "Resilience combos",
Expand Down Expand Up @@ -182,6 +187,11 @@
"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.bound": "saída própria",
"egress.shared": "divide o endereço do gateway com {count} contas",
"egress.single": "endereço do gateway (única conta)",
"egress.unknown": "saída desconhecida",
"table.combo": "Combo",
"table.cascade": "Cascata de modelos",
"combos.title": "Combos de resiliência",
Expand Down Expand Up @@ -291,6 +301,11 @@
"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.bound": "salida propia",
"egress.shared": "comparte la dirección del gateway con {count} cuentas",
"egress.single": "dirección del gateway (única cuenta)",
"egress.unknown": "salida desconocida",
"table.combo": "Combo",
"table.cascade": "Cascada de modelos",
"combos.title": "Combos de resiliencia",
Expand Down
25 changes: 23 additions & 2 deletions src/nine_rtksync/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,22 @@ def credential_state(self) -> Optional[str]:
state = self.data.get("credentialState")
return str(state) if state else None

@property
def rate_limit_active(self) -> bool:
"""Whether the rate-limit hold is still in force right now.

`rateLimitedUntil` guarda o instante em que a janela do provedor se
reabre -- é um prazo, não uma bandeira. Tratar a mera presença do campo
como "limitada" deixava a conexão amarela para sempre depois do primeiro
429, já que nada apaga a marca quando o prazo vence.
"""
from .normalizer import parse_iso_or_str_to_ms

until = parse_iso_or_str_to_ms(self.data.get("rateLimitedUntil"))
if until is None:
return False
return until > int(time.time() * 1000)

@property
def health_status(self) -> str:
"""Semantic classification of connection health.
Expand All @@ -184,7 +200,12 @@ def health_status(self) -> str:

if self.is_local:
# A local instance is only healthy when its model catalog answered.
return "unknown" if self.data.get("testStatus") == "unreachable" else "active"
estado = self.data.get("testStatus")
if estado == "unreachable":
return "unknown"
# Conexao recem-criada nunca foi sondada, e com CRON_ENABLED=0 pode
# nunca ser: dizer "ativa" e alegar uma saude que ninguem verificou.
return "active" if estado in ("active", "ok", "success") else "not_checked"

if self.is_oauth:
rem = self.remaining_seconds
Expand All @@ -197,7 +218,7 @@ def health_status(self) -> str:
return "active"

if self.has_api_key:
if self.data.get("rateLimitedUntil"):
if self.rate_limit_active:
return "rate_limited"
# Never probed yet: say so instead of claiming health nobody verified.
return "active" if probed == "valid" else "not_checked"
Expand Down
13 changes: 11 additions & 2 deletions src/nine_rtksync/providers/api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,24 @@ def check_and_refresh(

if result.state == STATE_VALID:
data["testStatus"] = "active"
messages.append(f"API key accepted by the provider ({result.detail})")
# A 4xx other than 401/403 still proves authentication went
# through -- the probe sends an empty body on purpose, and the
# provider only gets to complain about the body after it has
# accepted the key. Saying just "accepted (HTTP 400)" made the
# screen look wrong; the sentence now explains the number.
messages.append(
f"Authentication accepted by the provider ({result.detail})"
if result.detail and "200" in str(result.detail)
else f"Authentication accepted; the probe request itself was refused ({result.detail})"
)
elif result.state == STATE_INVALID:
# Do not claim health the provider just denied.
data["testStatus"] = "invalid"
messages.append(f"API key REJECTED by the provider ({result.detail})")
elif result.state == STATE_RATE_LIMITED:
messages.append(f"Provider rate limited the validation ({result.detail})")
elif result.state == STATE_UNREACHABLE:
messages.append(f"Provider unreachable, key not verified: {result.detail}")
messages.append(f"Key not verified: {result.detail}")
else:
messages.append(result.detail or "Credential not verifiable")

Expand Down
59 changes: 58 additions & 1 deletion src/nine_rtksync/web/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,17 @@ def render_refresh_reason(conn: Any, refresh_margin: int, lang: str = DEFAULT_LA
"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_unreachable", lang)
return translate("reason.local_empty", lang)

if not conn.is_oauth:
return translate("reason.api_key", lang)
Expand Down Expand Up @@ -184,6 +191,41 @@ def render_notice_page(title: str, body: str, link_label: str = "") -> bytes:
</html>""".encode("utf-8")


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 (
'<span class="badge text-bg-success-subtle text-success-emphasis">'
f'<i class="bi bi-shield-check me-1" aria-hidden="true"></i>'
f'{esc(translate("egress.bound", lang))}: {esc(pool)}</span>'
)
if estado == "shared":
if sharing_count > 1:
return (
'<span class="badge text-bg-warning-subtle text-warning-emphasis">'
f'<i class="bi bi-diagram-3 me-1" aria-hidden="true"></i>'
f'{esc(translate("egress.shared", lang, count=sharing_count))}</span>'
)
return (
'<span class="text-secondary">'
f'<i class="bi bi-diagram-3 me-1" aria-hidden="true"></i>'
f'{esc(translate("egress.single", lang))}</span>'
)
return (
'<span class="text-secondary">'
f'<i class="bi bi-question-circle me-1" aria-hidden="true"></i>'
f'{esc(translate("egress.unknown", lang))}</span>'
)


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"])
Expand Down Expand Up @@ -256,6 +298,14 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang:
{esc(translate("connections.empty", lang))}
</div>"""

# 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.
compartilhando = sum(
1 for c in connections if not c.is_local and c.egress_status == "shared"
)

rows = []
for c in connections:
if c.is_local:
Expand All @@ -282,6 +332,13 @@ def render_connections_table(connections: List[Any], refresh_margin: int, lang:
)
if parts:
detail = f'<div class="small text-secondary mt-1">{" · ".join(parts)}</div>'
else:
# Saída de rede: somente leitura. Quem roteia a requisição é o
# gateway; o painel existe para que o operador veja quais contas
# dividem endereço antes que o provedor veja primeiro.
chip = egress_chip(c, compartilhando, lang)
if chip:
detail = f'<div class="small mt-1">{chip}</div>'

rows.append(f"""
<tr>
Expand Down
1 change: 0 additions & 1 deletion tests/test_auth_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import stat
import tempfile
import logging
import unittest
import unittest.mock

from nine_rtksync import cli as nine_rtksync_cli
Expand Down
99 changes: 99 additions & 0 deletions tests/test_egress_panel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""A saída de rede de cada conta precisa aparecer na tela.

O risco de bloqueio não vem de várias sessões na mesma conta — os provedores
convivem com isso — e sim de **várias contas saindo pelo mesmo endereço**, que
é o estado natural de um gateway com todas elas cadastradas. O modelo já sabia
calcular esse vínculo e nada no painel o mostrava, então o operador só
descobriria o problema depois do provedor.

Estes testes fixam duas decisões:

- compartilhar o endereço só vira aviso a partir da **segunda** conta: sozinha,
ela é a única dona daquele IP e não há o que sinalizar;
- o painel é somente leitura — quem roteia a requisição é o gateway.
"""

import json
import unittest

from nine_rtksync.models import ConnectionRecord
from nine_rtksync.web import render


def conexao(nome: str, **dados) -> ConnectionRecord:
return ConnectionRecord(
id=nome,
provider=dados.pop("provider", "groq"),
name=nome,
created_at="2026-01-01",
updated_at="2026-01-01",
data_raw=json.dumps(dados),
)


def vinculada(nome: str, pool: str) -> ConnectionRecord:
return conexao(
nome,
apiKey="gsk_x",
providerSpecificData={"proxyPoolId": pool, "connectionProxyEnabled": True},
)


def compartilhada(nome: str) -> ConnectionRecord:
return conexao(nome, apiKey="gsk_x", providerSpecificData={})


class TestVinculoAparece(unittest.TestCase):
def html(self, conexoes):
return render.render_connections_table(conexoes, refresh_margin=900, lang="pt")

def test_a_bound_account_shows_its_own_pool(self):
h = self.html([vinculada("Conta A", "pool-a")])
self.assertIn("pool-a", h)
self.assertIn("saída própria", h)

def test_two_accounts_on_the_gateway_address_raise_a_warning(self):
h = self.html([compartilhada("Conta A"), compartilhada("Conta B")])
self.assertIn("divide o endereço do gateway", h)
self.assertIn("text-bg-warning-subtle", h, "o estado que importa precisa se destacar")

def test_a_single_account_sharing_is_not_a_warning(self):
# Uma conta sozinha e a unica dona daquele IP: nao ha nada a alertar.
h = self.html([compartilhada("Conta unica")])
self.assertIn("única conta", h)
self.assertNotIn("text-bg-warning-subtle", h)

def test_a_bound_account_does_not_count_toward_the_warning(self):
# Duas contas, mas so uma compartilha: ainda nao ha duas no mesmo IP.
h = self.html([vinculada("Conta A", "pool-a"), compartilhada("Conta B")])
self.assertNotIn("divide o endereço do gateway", h)

def test_three_sharing_accounts_report_the_real_count(self):
h = self.html([compartilhada("A"), compartilhada("B"), compartilhada("C")])
self.assertIn("3", h)

def test_a_local_instance_has_no_egress_mark(self):
# Instancia local nao sai para o provedor: a marca nao se aplica.
local = conexao(
"Ollama",
provider="openai-compatible-chat-ollama-local",
baseUrl="http://127.0.0.1:11434/v1",
apiKey="fachada",
)
h = self.html([local])
self.assertNotIn("saída própria", h)
self.assertNotIn("divide o endereço", h)


class TestPainelNaoEscreve(unittest.TestCase):
def test_the_pool_name_is_escaped(self):
# O nome vem do banco do gateway: tratar como texto, nunca como HTML.
h = render.render_connections_table(
[vinculada("Conta", '<img src=x onerror=alert(1)>')], refresh_margin=900, lang="pt"
)
self.assertNotIn("<img src=x", h)
self.assertIn("&lt;img", h)


if __name__ == "__main__":
unittest.main()
1 change: 0 additions & 1 deletion tests/test_env_documentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
defasagem aparece no CI em vez de aparecer em producao.
"""

import os
import pathlib
import re
import unittest
Expand Down
Loading
Loading