--!strict
--!nolint FunctionUnused
-- handlers/connection.luau — ciclo de vida da sessão: init (semeia o
-- contexto), login/logout, configurações do daemon buscadas uma vez na
-- conexão.
local State = require("../state")
local Helpers = require("../helpers")
local Api = require("../net/api")
local format = require("../format")
local Stream = require("stream")
local Settings = require("settings")
local AccessKey = require("../access_key")
local History = require("history")
local Language = require("language")
-- ── login lembrado: lista de servidor/token (via o `storage` do glacier) ────
-- O login é UM campo: o token de conexão (endereço + token, ver
-- access_key.luau). Toda conexão bem-sucedida salva/atualiza o par sozinha
-- (ver `connect()`), e quem administra mais de um daemon tem a lista inteira
-- no `<select>` (login.component.gv): cada item mostra o `host:porta`, e escolher um
-- preenche o campo com o token de conexão dele. O armazenamento continua
-- `{url, token}` (o token é montado na hora de mostrar), então servidores
-- salvos antes do login por token não se perdem. `storage` grava em
-- `~/.local/share/rustploy/.glacier-storage/app.json` (raiz definida por
-- `app(id = rustploy)` de `views/app.gv`).
local saved_servers: { { url: string, token: string } } = {}
-- Publica `{label, value}` para o `<select>` de servidores salvos: o rótulo é
-- o `host:porta` (para ver aonde vai conectar), o valor é o token de conexão,
-- que vai para o campo ao escolher. Select e campo são widgets separados de
-- propósito: num `<ComboEdit>` o campo mostra o RÓTULO da opção escolhida, e
-- não dá para ter o endereço na lista e o token no campo ao mesmo tempo.
local function publish_saved_servers(): ()
local server_options = json.array {}
for _, saved_server in ipairs(saved_servers) do
local key = AccessKey.encode(saved_server.url, saved_server.token)
table.insert(
server_options,
{ label = AccessKey.address_of(saved_server.url), value = key }
)
end
ctx.saved_servers = json.encode(server_options)
end
-- A linha "conecta em host:porta" sob o campo, e o item do select que
-- corresponde ao token digitado/colado (vazio quando não é nenhum salvo).
local function publish_access_key_target(): ()
local decoded_key = AccessKey.decode(ctx.access_key)
ctx.access_key_target = if decoded_key
then i18n.tr("conecta em ") .. decoded_key.address
else ""
ctx.saved_server_choice = ""
for _, saved_server in ipairs(saved_servers) do
local key = AccessKey.encode(saved_server.url, saved_server.token)
if key == ctx.access_key then
ctx.saved_server_choice = key
break
end
end
end
-- Salva/atualiza um par e o move pro topo da lista — o mais usado por último
-- fica em primeiro, e é também o que `init()` pré-preenche no próximo boot.
-- A igualdade é por `host:porta`, não pela URL: o token de conexão não tem
-- esquema, então `http://h:1` e `https://h:1` são o mesmo servidor (e dariam o
-- mesmo item na lista).
local function remember_server(url: string, token: string): ()
local address = AccessKey.address_of(url)
for index = #saved_servers, 1, -1 do
if AccessKey.address_of(saved_servers[index].url) == address then
table.remove(saved_servers, index)
end
end
table.insert(saved_servers, 1, { url = url, token = token })
storage.set("saved_servers", saved_servers)
publish_saved_servers()
end
-- Campo do token de conexão: dispara a cada tecla/colagem.
function access_key_input(value: string): ()
ctx.access_key = value
ctx.error_access_key = ""
publish_access_key_target()
end
-- `<select>` de servidores salvos: o valor escolhido é o token de conexão.
function choose_saved_server(value: string): ()
ctx.access_key = value
ctx.error_access_key = ""
publish_access_key_target()
end
-- Botão "Esquecer": remove da lista o servidor do token atual e limpa o
-- campo. Casa pelo token de conexão inteiro e, se ele decodificar, também
-- pelo `host:porta` — um par salvo sem token (daemon sem `[api].token`) gera
-- `host:porta::`, que antes não decodificava e por isso nunca era esquecido.
function forget_server(): ()
local decoded_key = AccessKey.decode(ctx.access_key)
local address = if decoded_key then decoded_key.address else nil
for index = #saved_servers, 1, -1 do
local saved_server = saved_servers[index]
if
AccessKey.encode(saved_server.url, saved_server.token)
== ctx.access_key
or AccessKey.address_of(saved_server.url) == address
then
table.remove(saved_servers, index)
end
end
storage.set("saved_servers", saved_servers)
publish_saved_servers()
ctx.access_key = ""
ctx.error_access_key = ""
publish_access_key_target()
end
-- ── init: semeia o contexto para a UI pintar antes de qualquer dado ─────────
function init(): ()
Language.restore()
if not ctx.screen or ctx.screen == "" then
ctx.screen = "login"
end
if not ctx.view or ctx.view == "" then
ctx.view = "deployments"
end
ctx.connected = ctx.connected or "false"
ctx.status_line = ctx.status_line or i18n.tr("pronto para conectar")
ctx.error = ctx.error or ""
ctx.search = ctx.search or ""
-- Sidebar-gaveta (<drawer> em shell.component.gv): começa aberta; o ☰ da topbar
-- alterna via `drawer::toggle:menu`.
ctx.menu = ctx.menu or "true"
-- Checkboxes do Infra as Code (Settings → aba IaC): semeados aqui pra o
-- estado ser determinístico e o `do_import` nunca ler `nil`.
ctx.data_loading = "true"
-- Coleção fixa do <radiogroup> de dia da semana (aba Manutenção). Semeada
-- aqui, e não no docker_cleanup_apply_config, porque não depende da config
-- carregada: se o load falhasse, a lista sumiria e o grupo ficaria vazio.
ctx.weekdays = format.weekdays()
-- Itens das <tabbar> de rótulo fixo ({id,label}); a barra grava o id na
-- chave `:value` sozinha, sem handler.
ctx.settings_tabs = {
{ id = "web", label = "Web Server" },
{ id = "git", label = "Git" },
{ id = "maintenance", label = i18n.tr("Manutenção") },
{ id = "gitsync", label = i18n.tr("Sincronização") },
{ id = "backups", label = i18n.tr("Backups") },
{ id = "recover", label = i18n.tr("Recuperar") },
{ id = "updates", label = i18n.tr("Atualizações") },
{ id = "language", label = i18n.tr("Idioma") },
}
-- Aba Sincronização (estado de infra ↔ git). Os valores reais vêm no snapshot
-- (handlers/stream.luau); aqui só o formulário e os defaults, para o markup
-- nunca ler `nil`. Ver format/state_git.luau e handlers/state_git.luau.
ctx.state_git_form_modes = {
{ id = "off", label = i18n.tr("Desligado") },
{ id = "commit", label = i18n.tr("Só commit (local)") },
{ id = "sync", label = i18n.tr("Sincronizar") },
}
-- Aba Dados → Backup no S3: a lista nasce vazia (o markup nunca lê `nil`); handlers/service_backup.luau.
ctx.data_backups = ctx.data_backups or json.array {}
ctx.data_backups_count = ctx.data_backups_count or "0"
ctx.data_backup_blocked = ctx.data_backup_blocked or ""
ctx.data_backup_list_error = ctx.data_backup_list_error or ""
ctx.data_backup_run_label = ctx.data_backup_run_label or ""
ctx.data_backup_checks = ctx.data_backup_checks or ""
ctx.data_backup_running = ctx.data_backup_running or "false"
ctx.data_backup_ready = ctx.data_backup_ready or "false"
ctx.data_backup_enabled = ctx.data_backup_enabled or "false"
ctx.data_backup_kind = ctx.data_backup_kind or "daily"
ctx.data_backup_hours = ctx.data_backup_hours or "6"
ctx.data_backup_time = ctx.data_backup_time or "03:00"
ctx.data_backup_weekday = ctx.data_backup_weekday or "0"
ctx.data_backup_keep = ctx.data_backup_keep or "7"
ctx.data_backup_stop = ctx.data_backup_stop or "true"
ctx.data_backup_next = ctx.data_backup_next or ""
-- Aba Recuperar (docs/plano-backup-de-dados-para-s3.md, Fase 4); handlers/recover.luau. A chave e o token
-- só moram no campo: o handler os apaga ao enviar.
ctx.recover_remote = ctx.recover_remote or ""
ctx.recover_token = ctx.recover_token or ""
ctx.recover_branch = ctx.recover_branch or ""
ctx.recover_key = ctx.recover_key or ""
ctx.recover_phase = ctx.recover_phase or "idle"
ctx.recover_projects = ctx.recover_projects or "0"
ctx.recover_destination_error = ctx.recover_destination_error or ""
ctx.recover_message = ctx.recover_message or ""
ctx.recover_services = ctx.recover_services or json.array {}
ctx.recover_services_count = ctx.recover_services_count or "0"
-- Aba Backups (destino S3 dos backups de dados): o formulário; os valores reais vêm de
-- `BackupDestinationGet` ao abrir Settings (handlers/data_backup.luau). A chave secreta nunca volta.
ctx.backup_destination_endpoint = ctx.backup_destination_endpoint or ""
ctx.backup_destination_region = ctx.backup_destination_region or "us-east-1"
ctx.backup_destination_bucket = ctx.backup_destination_bucket or ""
ctx.backup_destination_prefix = ctx.backup_destination_prefix or ""
ctx.backup_destination_path_style = ctx.backup_destination_path_style or "true"
ctx.backup_destination_access_key = ctx.backup_destination_access_key or ""
ctx.backup_destination_secret_key = ctx.backup_destination_secret_key or ""
ctx.backup_destination_configured = ctx.backup_destination_configured or "false"
ctx.backup_destination_secret_set = ctx.backup_destination_secret_set or "false"
ctx.backup_destination_recovery_missing = ctx.backup_destination_recovery_missing or "false"
ctx.backup_destination_message = ctx.backup_destination_message or ""
ctx.service_dependencies = ctx.service_dependencies or "[]"
ctx.service_dependency_options = ctx.service_dependency_options or "[]"
ctx.service_dependencies_count = ctx.service_dependencies_count or "0"
ctx.service_form_dependency_add_id = ctx.service_form_dependency_add_id or ""
-- Valor nulo dos selects: os botões ligados a eles (`if = @chave, empty`)
-- ficam travados até haver escolha; a chave precisa existir desde o início.
ctx.service_form_pre_deploy_check_add_job_id = ctx.service_form_pre_deploy_check_add_job_id or ""
ctx.service_form_shared_database_project = ctx.service_form_shared_database_project or ""
ctx.service_form_migration_destination = ctx.service_form_migration_destination or ""
-- Aba Logs: lista "Execuções anteriores" (handlers/services.luau).
ctx.log_executions = ctx.log_executions or "[]"
ctx.log_executions_visible = ctx.log_executions_visible or "false"
ctx.log_selected_none = ctx.log_selected_none or "true"
ctx.log_selected_count = ctx.log_selected_count or "0"
ctx.log_selected_label = ctx.log_selected_label or ""
ctx.service_form_autostart = ctx.service_form_autostart or "true"
ctx.service_form_registry_auto_deploy = ctx.service_form_registry_auto_deploy
or "false"
ctx.project_service_options = ctx.project_service_options or "[]"
ctx.move_target_service_id = ctx.move_target_service_id or ""
ctx.state_git_form_mode = ctx.state_git_form_mode or "off"
ctx.state_git_form_remote = ctx.state_git_form_remote or ""
ctx.state_git_form_branch = ctx.state_git_form_branch or "main"
ctx.state_git_form_provider_id = ctx.state_git_form_provider_id or ""
ctx.state_git_form_loaded = ctx.state_git_form_loaded or "false"
ctx.state_git_message = ctx.state_git_message or ""
ctx.service_pending_deploy_visible = ctx.service_pending_deploy_visible or "false"
for key, value in pairs(format.state_git_context(nil, nil)) do
(ctx :: any)[key] = (ctx :: any)[key] or value
end
-- Aba Atualizações (Command::CheckUpdate). Semeadas aqui para o markup
-- nunca ler `nil`; `update_check` as preenche.
ctx.update_current_version = ctx.update_current_version or ""
ctx.update_latest_version = ctx.update_latest_version or ""
ctx.update_checked_at_label = ctx.update_checked_at_label or ""
ctx.update_is_available = ctx.update_is_available or "false"
ctx.update_release_url = ctx.update_release_url or ""
ctx.update_message = ctx.update_message or ""
ctx.update_progress_message = ctx.update_progress_message or ""
-- Atualização do APP (esta janela), ver handlers/settings.luau::update_app_check.
ctx.update_app_current_version = ctx.update_app_current_version or ""
ctx.update_app_latest_version = ctx.update_app_latest_version or ""
ctx.update_app_is_available = ctx.update_app_is_available or "false"
ctx.update_app_can_update = ctx.update_app_can_update or "false"
ctx.update_app_release_url = ctx.update_app_release_url or ""
ctx.update_app_message = ctx.update_app_message or ""
ctx.update_app_deb_url = ctx.update_app_deb_url or ""
ctx.update_app_deb_name = ctx.update_app_deb_name or ""
ctx.update_app_signature_url = ctx.update_app_signature_url or ""
ctx.update_app_install_kind = ctx.update_app_install_kind or ""
ctx.update_app_manual_command = ctx.update_app_manual_command
or "curl -fsSL https://raw.githubusercontent.com/antoniofernandodj/rustploy/main/install.sh | sudo sh -s -- gui"
ctx.update_app_apply_hint = ctx.update_app_apply_hint
or i18n.tr("O app baixa a versão nova, pede a senha de administrador do sistema e a instala; só um pacote assinado pelo projeto é aceito. Depois ele reabre sozinho.")
ctx.update_is_helper_available = ctx.update_is_helper_available or "false"
ctx.update_install_command =
"curl -fsSL https://raw.githubusercontent.com/antoniofernandodj/rustploy/main/install.sh | sudo sh"
ctx.docker_cleanup_kinds = {
{ id = "interval", label = i18n.tr("A cada N horas") },
{ id = "daily", label = i18n.tr("Todo dia") },
{ id = "weekly", label = i18n.tr("Toda semana") },
}
ctx.git_provider_modes = {
{ id = "oauth", label = "OAuth2" },
{ id = "pat", label = "Token (PAT)" },
}
ctx.healthcheck_kinds = {
{ id = "none", label = "None" },
{ id = "tcp", label = "Tcp" },
{ id = "http", label = "Http" },
{ id = "docker", label = "Docker" },
}
-- Listas vazias (não nil): declaram que a chave existe, e `tela:missing()`
-- acusa a que o markup lê e ninguém semeou.
for _, key in ipairs {
"deployments",
"projects",
"project_rows",
"services",
"service_rows",
"docker_rows",
"docker_images",
"docker_volumes",
"docker_networks",
"ingress",
"host_ports",
"jobs_summary",
"project_jobs",
"monitoring",
"running",
"deploy_engine_active",
"deploy_engine_recent",
"project_services",
} do
if not ctx[key] or ctx[key] == "" then
ctx[key] = json.array({})
end
end
-- Contadores nascem "…", não "0": antes do primeiro snapshot não sabemos
-- quantos são, e "0" AFIRMA vazio — a UI piscava "Projects (0)" →
-- "Projects (2)", e mensagens como "Nenhum job cadastrado ainda" apareciam
-- durante o carregamento. O snapshot troca todos por número
-- (apply_snapshot em handlers/stream.luau, junto de `data_loading =
-- "false"`). Os `equals="0"` dos templates continuam valendo: com "…" não
-- casam, então o estado vazio só pinta quando o dado realmente chegou e é
-- zero.
for _, key in ipairs {
"deployments_count",
"projects_count",
"services_count",
"ingress_count",
"host_ports_count",
"jobs_count",
"project_env_count",
"project_jobs_count",
"project_secrets_count",
"docker_images_count",
"docker_volumes_count",
"docker_networks_count",
"deploy_engine_active_count",
"deploy_engine_recent_count",
"deploy_engine_success_24h",
"deploy_engine_failed_24h",
"deploy_engine_total_24h",
"services_running",
"services_total",
} do
if not ctx[key] or ctx[key] == "" then
ctx[key] = "…"
end
end
-- Semeia o formulário de login com a lista de servidores salvos, e
-- pré-preenche o token de conexão do mais usado por último (topo da lista)
-- — para nascer pronto pra conectar de novo sem escolher nada.
local stored = storage.get("saved_servers")
if type(stored) == "table" then
saved_servers = stored
end
publish_saved_servers()
if #saved_servers > 0 and (not ctx.access_key or ctx.access_key == "") then
ctx.access_key =
AccessKey.encode(saved_servers[1].url, saved_servers[1].token)
end
publish_access_key_target()
end
-- Configurações do daemon + provedores Git, buscados uma vez na conexão (não
-- entram no snapshot para não sobrescrever campos que o usuário edita).
local function load_settings(): ()
local api = State.api
if not api then
return
end
local get_daemon_settings_ok, get_daemon_settings_response =
api:rpc("GetDaemonSettings")
if
get_daemon_settings_ok and get_daemon_settings_response.DaemonSettings
then
-- Base pública derivada pelo daemon a partir de [api] domain/port — é
-- só-leitura (não há setter no protocolo), serve para exibir a base do
-- webhook e montar a redirect URI do OAuth.
local daemon_settings = get_daemon_settings_response.DaemonSettings
local base = daemon_settings.public_base_url or ""
ctx.server_settings_public_base = base
ctx.server_settings_email = daemon_settings.acme_email or ""
ctx.server_settings_registry_domain = daemon_settings.registry_domain
or ""
ctx.git_provider_redirect = Helpers.oauth_redirect_uri(base)
end
-- O token de conexão deste servidor, para Settings → Web Server → copiar.
-- Montado aqui (já temos url + token da sessão) em vez de pedida ao
-- daemon: assim ele é o que FUNCIONOU desta máquina, não o do
-- `public_base_url`.
ctx.server_access_key = if api.token
then AccessKey.encode(api.base_url, api.token)
else ""
-- O comando que liga o Claude Code ao MCP deste servidor, pronto para
-- colar num terminal: o token já resolvido, sem `curl /api/login` no meio.
ctx.server_mcp_command = "claude mcp add --transport http --scope user "
.. "rustploy "
.. api.base_url
.. "/mcp"
.. (
if api.token
then ' --header "Authorization: Bearer ' .. api.token .. '"'
else ""
)
-- Codex lê MCP HTTP do ~/.codex/config.toml: o cabeçalho vai num bloco
-- TOML.
ctx.server_mcp_command_codex = '[mcp_servers.rustploy]\nurl = "'
.. api.base_url
.. '/mcp"'
.. (
if api.token
then '\nhttp_headers = { Authorization = "Bearer '
.. api.token
.. '" }'
else ""
)
ctx.server_mcp_command_gemini = "gemini mcp add --transport http --scope "
.. "user "
.. (if api.token
then '--header "Authorization: Bearer ' .. api.token .. '" '
else "")
.. "rustploy "
.. api.base_url
.. "/mcp"
ctx.server_access_key_short = if api.token
then string.sub(ctx.server_access_key, 1, 12) .. "…" .. string.sub(
ctx.server_access_key,
-6
)
else i18n.tr("— (daemon sem token)")
local git_provider_list_ok, git_provider_list_response =
api:rpc("GitProviderList")
if git_provider_list_ok and git_provider_list_response.GitProviders then
ctx.gitea_count = tostring(#git_provider_list_response.GitProviders)
end
Settings.docker_cleanup_load()
end
-- O esquema de um `host:porta`: o token de conexão não o carrega. Sonda `GET
-- /api/version` (pública, sem token) primeiro com o esquema de um servidor já
-- salvo com esse endereço, depois https, depois http — qualquer resposta HTTP,
-- até um 404, prova que o esquema é aquele; erro de transporte (TLS recusado,
-- conexão fechada) passa para o próximo.
local function resolve_base(address: string): (string?, string?)
local schemes: { string } = {}
for _, saved_server in ipairs(saved_servers) do
if AccessKey.address_of(saved_server.url) == address then
table.insert(
schemes,
string.lower(
string.match(saved_server.url, "^(%a[%w+.-]*)://")
or "https"
)
)
break
end
end
for _, scheme in ipairs({ "https", "http" }) do
if not table.find(schemes, scheme) then
table.insert(schemes, scheme)
end
end
local last_error = ""
for _, scheme in ipairs(schemes) do
local base = scheme .. "://" .. address
local http_response = Api.probe(base)
if http_response.status ~= nil and http_response.status > 0 then
return base, nil
end
last_error = if http_response.error ~= nil
and http_response.error ~= ""
then http_response.error
else i18n.tr("sem resposta")
end
return nil,
i18n.tr("não deu para falar com ")
.. address
.. i18n.tr(" nem por https nem por http (")
.. last_error
.. ")"
end
-- on_submit/on_click do formulário de login: decodifica o token de conexão
-- (base64 de `host:porta::token`), descobre o esquema e usa o token da API
-- direto como Bearer — sem `/api/login`, o daemon não precisa de nada novo.
-- Valida a sessão com um DaemonStatus antes de abrir o stream. `fetch`
-- suspende a corrotina.
function connect(): ()
local decoded_key, key_error = AccessKey.decode(ctx.access_key)
if not decoded_key then
ctx.error_access_key = key_error or i18n.tr("token de conexão inválido")
return
end
ctx.error_access_key = ""
ctx.status_line = i18n.tr("conectando…")
ctx.error = ""
local resolved_base, resolve_error = resolve_base(decoded_key.address)
local base = if resolved_base
then Helpers.normalize_url(resolved_base)
else nil
if not base then
ctx.error = resolve_error
or (
i18n.tr("endereço inválido no token de conexão: ")
.. decoded_key.address
)
ctx.status_line = i18n.tr("falha na conexão")
ctx.connected = "false"
return
end
local token = decoded_key.token
ctx.api_url = base
ctx.api_token = token
-- Topbar: a qual servidor esta janela está ligada (host:porta, sem
-- esquema).
ctx.connected_server = string.match(base, "^%a[%w+.-]*://(.+)$") or base
local client = Api.new(base, token)
State.api = client
local daemon_status_ok, daemon_status_response = client:rpc("DaemonStatus")
if not daemon_status_ok then
State.api = nil
ctx.error = daemon_status_response
ctx.status_line = i18n.tr("falha na conexão")
ctx.connected = "false"
return
end
-- Só salva na lista quando a conexão realmente deu certo — uma tentativa
-- com URL/token errados não deve virar entrada salva.
remember_server(base, token)
publish_access_key_target()
History.clear()
ctx.connected = "true"
ctx.status_line = i18n.tr("conectado")
ctx.screen = "shell"
if not ctx.view or ctx.view == "" then
ctx.view = "deployments"
end
load_settings()
Stream.open_stream()
-- Depois do stream: se o daemon não alcançar o GitHub, a consulta leva até
-- ~15 s, e o painel ao vivo não pode esperar por isso.
Settings.update_check(false)
Settings.update_app_check()
end
-- Logout: nada da sessão anterior pode sobreviver em RAM — nem no `ctx` (o
-- espelho do contexto do motor, de onde a UI pinta tudo: nomes de projeto,
-- linhas de log, api_token…) nem no `State` (cache do snapshot/métricas).
--
-- Em vez de enumerar as chaves a limpar — a lista cresce a cada tela nova e
-- sempre esquece alguma — apagamos o `ctx` INTEIRO e deixamos `init()` semear
-- os defaults como no boot. O login lembrado volta do `storage` (disco), não
-- da memória, então conectar de novo continua nascendo preenchido.
--
-- `ctx[k] = nil` é remoção de verdade, não só sumiço do lado Luau: o glacier
-- espelha o contexto na tabela no início da execução e, no fim, remove do
-- motor toda chave ausente nela (sync_to_luau/sync_from_luau).
function disconnect(): ()
if State.state_conn then
pcall(function()
State.state_conn:close()
end)
State.state_conn = nil
end
History.clear()
State.api = nil
State.snap = nil
State.revealed_secrets = {}
State.last_pairs = {}
State.metrics_by_id = {}
State.jobs_by_id = {}
State.new_service_templates_all = {}
State.search_term = ""
State.restart_notice = ""
State.deploy_track =
{ service_id = "", deployment_id = "", running = false, name = "" }
-- Coleta as chaves antes de apagar: mutar a tabela durante o próprio
-- `pairs` sobre ela é comportamento indefinido.
local data = ctx :: { [string]: any }
local keys: { string } = {}
for key in pairs(data) do
table.insert(keys, key)
end
for _, key in ipairs(keys) do
data[key] = nil
end
init()
ctx.connected = "false"
ctx.screen = "login"
ctx.status_line = i18n.tr("desconectado")
end