rustploy-gui por dentro
Este documento explica o código do rustploy-gui: o Rust (src/, build.rs,
tests/) e os templates de markup (views/**/*.gvb). Ele existe porque o código
foi deliberadamente enxugado. No Rust, cada item tem uma frase de documentação (a que
o make index usa como descrição) e nenhum comentário //. Nos .gvb, cada arquivo
guarda só a primeira frase do cabeçalho e comentários discretos de uma linha (divisores,
rótulos). Tudo o que é porquê, contexto e decisão descartada mora aqui. Os nomes
entre crases (arquivo.rs::função, views/x.gvb) apontam para onde cada assunto vive.
Os assuntos, na ordem em que fazem sentido para quem chega agora:
- O que é este crate e o que ele não é
- Onde estão os assets
- A ponte do zip do Infra as Code
- A API de agente
- Os testes e o que cada um trava
- Os templates
.gvbpor dentro
Documentos relacionados: docs/api-agente-no-gui.md (desenho da API de agente),
docs/plano-erro-de-deploy-invisivel.md (de onde veio a motivação da rota de
deploy) e docs/plano-reforma-gui-glacier-0.102.md (a reforma da UI).
1. O que é este crate e o que ele não é
O Rustploy GUI é um cliente desktop cuja interface é descrita em templates de
markup (views/**/*.gvb) e renderizada pelo motor glacier-ui publicado. Toda
a lógica de rede vive em Luau (views/scripts/app.luau e os módulos que ele
carrega), falando HTTP/JSON e SSE com o daemon. O Rust é só a casca da janela:
src/main.rs e src/app/mod.rs.
O manifesto app(...)
A configuração de janela, de aplicativo e as telas moram no markup de
views/app.gvb, cuja raiz é o app(...) (glacier-ui 0.117+):
id,single_instanceeremember_geometry— instância única, geometria lembrada e o diretório de dados dostoragedo Luau;- a janela principal:
size,min_size,decorations,icon; - a
tray, com o menu da bandeja; - as
screens: a principal e as cinco janelas auxiliares, abertas poropen_window{ component = "…", size = "…" }.
O runner GlacierDaemon lê isso antes de abrir qualquer janela. A partir do
glacier-ui 0.119 o mesmo app(...) também declara as fontes (font(src, family)
e font = …), o antialiasing, o toast_period e o application_id do Linux.
Sobra para o Rust só o que o markup não expressa:
- a extensão Luau do
.zip(ver seção 3); - o espelho da sessão para a API de agente (ver seção 4);
- a fonte de assets embutida em release (ver seção 2).
O .main_template("views/app.gvb") carrega o manifesto. O caminho é relativo ao
workspace, onde assets::locate_and_chdir deixa o diretório corrente. Um
.main(|motor| …) só serviria para registrar um impl Component escrito em
Rust, e este app não tem nenhum.
O ciclo de vida em app::run
app::run sobe o daemon multi-janela e roda o loop do iced até a última
janela fechar. Fechar a janela apenas recolhe o app para a bandeja; run só
volta quando o app encerra de verdade. A ordem das peças importa:
- Sessão compartilhada. Cria-se o
SharedSessionda API de agente, o servidor HTTP local (hyper) que empresta a sessão desta janela a um agente rodando na mesma máquina para operar o rustploy remoto. - Canal de mão contrária.
glacier_ui::external::sender()(glacier-ui 0.58.6+) deixa a thread do servidor injetar ações no motor desta janela — é o que torna login, navegação e qualquer botão alcançáveis por HTTP. Ele é criado antes derun(), porque é nesse momento que o daemon decide registrar a subscription que o drena. - Extensão Luau.
lua_extension(manifest_zip::install)registramanifest_zip_read/manifest_zip_write, usadas pelo Infra as Code (Settings). O motor temzip_dir, mas não o inverso, e a camada Lua não abre um.zip. - Gancho
on_message. Roda depois de cada dispatch da janela principal. Ele espelha a sessão da GUI (api_url/api_token/connected, escritos no contexto porhandlers/connection.luau) para a API de agente, e assim cobre login, logout e troca de servidor sem conhecer nenhum dos três; a escrita em disco só acontece quando algo de fato mudou. É também aqui que a API de agente sobe: o.main()que a subia saiu. O gancho só roda na instância primária — a instância única encerra a segunda antes de qualquer dispatch, então um segundo lançamento não reescreve o handoff da instância viva — eagent::spawné idempotente (umAtomicBool), de modo que chamá-lo a cada dispatch custa uma troca atômica. - Release. Injeta-se a fonte de assets embutida: o motor passa a ler
templates, estilos, scripts e binários de dentro do executável, e nada do
disco. Em dev o daemon fica com o
DiskAssetspadrão (disco com hot-reload). - Encerramento. Quando
runvolta, o handoff da API de agente deixa de valer: o token morre com o processo, e um arquivo sobrevivente só confundiria quem o lesse depois.
main.rs
#![cfg_attr(…, windows_subsystem = "windows")]vale só em release no Windows: abrir a GUI não deve mostrar (e manter aberta) uma janela de console atrás dela. É ignorado nos outros alvos e em debug, onde o console é útil para logs.mod manifest_zipé a ponte Lua ↔ Rust do zip do Infra as Code (ler/gravar o.zipde manifesto), registrada como extensão da camada Luau no builder do daemon.assets::locate_and_chdir()só existe em debug: lá os assets são lidos do disco, com hot-reload, por caminho relativo ao diretório corrente, então é preciso entrar na pasta-base antes de qualquer carga. Em release eles estão embutidos no binário (embedded+app::run); nem o localizador nem ochdirexistem, e o executável roda de qualquer diretório, sozinho.- A casca customizada (fontes, janela borderless, ícone, persistência de
geometria) é multi-janela sobre
iced::daemone vive emapp::run.
Instalação por cargo install e o menu de aplicativos (build.rs + desktop.rs)
O cargo não tem hook de pós-instalação, mas o build.rs roda na máquina do
usuário. Num cargo install rustploy-gui (Linux), o build.rs grava
rustploy-gui.desktop e os ícones hicolor em $XDG_DATA_HOME (padrão
~/.local/share), com Exec= apontando para $CARGO_INSTALL_ROOT/bin,
$CARGO_HOME/bin ou ~/.cargo/bin.
Como o build.rs sabe que é um install: o cargo compila instalações do
registro/git num diretório temporário cargo-install*, e é isso que ele procura
no OUT_DIR. Detalhe interno do cargo — se mudar, só deixa de instalar sozinho.
cargo install --path . e cargo build não instalam (compilam no target/
do projeto). Nunca falha o build: erro vira cargo:warning.
RUSTPLOY_INSTALL_DESKTOP=0 desliga; =1 força.
Reparo manual (binário movido, --root fora do padrão, build local): rode
rustploy-gui --install-desktop — grava os mesmos arquivos com o Exec= do
executável que rodou o comando (os arquivos vão embutidos no binário).
2. Onde estão os assets
Todo template, folha de estilo, ícone e logo de blueprint é referenciado por um
caminho relativo ao diretório corrente do processo — tanto do Rust
(views/styles/app.gss, views/app.gvb, assets/blueprint-logos/<id>/<logo>)
quanto de dentro dos próprios templates (import … from="views/service.gvb",
theme "views/styles/theme.json", Svg "assets/icons/…").
Em vez de reescrever cada literal, a solução tem duas metades, uma por modo de compilação.
2.1 Debug: achar a pasta-base e entrar nela (assets.rs)
assets::locate_and_chdir localiza uma vez, na partida, o diretório que
contém as árvores views/ e assets/ e faz chdir para ele. Depois disso todo
caminho relativo resolve, não importa como o app foi lançado: cargo run na raiz
do workspace, o .zip do Windows (assets ao lado do .exe) ou o pacote Debian
(assets em /usr/share/rustploy).
A ordem de resolução:
$RUSTPLOY_UI_ASSETS— sobrescrita explícita;- o diretório do próprio executável — layout portátil /
.zipdo Windows; - o diretório corrente, se ele já contém os assets — o
cargo runna raiz do workspace em desenvolvimento, semchdir. Vem antes deSYSTEM_PREFIXde propósito: um.debinstalado em/usr/share/rustploynão pode esconder a árvore de trabalho durante umcargo run; SYSTEM_PREFIX(/usr/share/rustploy) — o layout do pacote Debian (ver a metadatadebnoCargo.toml).
O teste de validade é a existência de views/app.gvb (MARKER), o arquivo que
precisa existir em qualquer base válida. A função é best-effort: se nenhum
candidato servir, o diretório corrente fica como está e o app mostra um erro de
"stylesheet/template não encontrado", que é o sinal mais claro possível.
2.2 Release: tudo dentro do binário (embedded.rs)
Em release (cfg(not(debug_assertions))) toda a árvore de assets que o motor
lê em runtime é embutida no executável com include_dir!, e uma
EmbeddedAssets é injetada no GlacierDaemon (em app::run). O binário fica
100% desacoplado dos arquivos: pode ser copiado sozinho para qualquer lugar e
rodar, sem as árvores views/ e assets/ ao lado e sem o chdir de
assets.rs. Em debug o módulo nem é compilado.
Os caminhos que chegam são os mesmos que o motor resolve hoje (relativos ao diretório corrente antes do modo standalone), roteados por prefixo:
| prefixo de runtime | árvore embutida |
|---|---|
views/… |
VIEWS — .gvb, styles/*.gss, styles/theme.json, scripts/**/*.luau (resolvidos por require/<script src>) |
assets/icons/… |
ICONS — ícones SVG referenciados por <svg src="assets/icons/…"> |
assets/blueprint-logos/… |
BLUEPRINTS — logos dos templates, via o {logo} data-driven do catálogo do daemon (<id>/<arquivo>) |
assets/fonts/… |
FONTS — as fontes declaradas com font(src = …) no app(...) (JetBrains Mono) |
Detalhes que valem saber:
- Só as imagens dos blueprints entram. O
build.rsas espelha em<id>/<arquivo>e filtra odocker-compose.yml/template.toml(ver 2.3). routenormaliza os separadores (\vira/) e um eventual./inicial: as chaves doinclude_dirsão sempre relativas e com/.- Como o embutido não muda sob o processo,
EmbeddedAssetsdevolveNoneemmodified(desliga o hot-reload) e respondefalseà pergunta "há algo para vigiar?". Sem esse segundo ponto o daemon manteria o ticker de hot-reload rodando para sempre — redesenhando a tela inteira a cada tick — sem nunca ter trabalho de verdade a fazer. luau_sourcesdevolve os scripts Luau embutidos como(caminho, conteúdo). Serve ao índice de ações da API de agente (agent::actions): as ações dispatcháveis da UI são as funções globais desses arquivos, e um binário de release não tem a árvoreviews/no disco para varrer. Em debug o índice lê do disco e nem passa por aqui. O caminho embutido vem relativo aviews/(scripts/x.luau);actions.rso normaliza para o mesmo formato do modo debug.- Os testes deste módulo só existem em release (o módulo inteiro é
cfg(not(debug_assertions))): rode comcargo test --release -p rustploy-gui. Um confere que cada caminho realmente referenciado em runtime resolve na árvore embutida — as três convenções de prefixo, texto e binário, e que um caminho ausente dáNotFound/exists = false. O outro é a prova de ponta a ponta, headless, sem janela e semchdir: o motor carrega o app inteiro só da árvore embutida (template,<link>de estilo/tema,<script src>Luau, a cadeia derequiredos handlers) e renderiza. Se qualquer asset — inclusive um módulorequired — faltasse,register_component/renderfalharia.
2.3 O build.rs
O build script faz duas coisas.
1. Prepara os logos dos blueprints (só imagens) em $OUT_DIR; é de lá que o
embedded.rs os embute no binário de release.
- Origem. Os logos moram em
assets/blueprint-logos/(saíram do craterustploy-shared, que não os carrega mais); o nome de cada um vem do catálogo do shared (templates/logos.txt). Só as extensões de imagem entram (LOGO_EXTS); o resto da pasta —docker-compose.yml,template.toml,.md— é do daemon e a GUI nunca o lê. - Por que reduzir. Os logos raster originais têm ~512×512 e aparecem a ~30px
(
template_row.gvb). Sem redução, a GPU faria um downscale de ~17× por quadro (serrilhado) e o binário carregaria ~12 MB de logo. Por isso os raster são reduzidos com Lanczos3 para no máximoLOGO_MAX_DIM= 96px na maior dimensão, preservando a proporção, e re-codificados como PNG. 96px cobre telas HiDPI (até ~3×) e ainda corta os originais em ordens de grandeza. O processo só reduz: imagem que já é menor, e todo vetor, passa intacta — não se borra o que já é pequeno. - Vetor e formatos raros. SVG é copiado intacto (nítido em qualquer tamanho). O
mesmo vale para
.ico/.avif, que o crateimagenão cobre: continuam sendo logos válidos e caem no ramo de cópia, sem passar pelo resize.is_logodecide o que entra no staging;is_rasteré o subconjunto que oimagedecodifica (as features habilitadas noCargo.toml). - Degradação.
stage_rasternunca derruba o build: se não consegue decodificar (ou a imagem já é pequena), copia os bytes originais.downscale_pngdevolveNonenesses casos — re-codificar não valeria o custo e o risco de mexer no que já está bom. - Nomes. O nome do arquivo no staging tem de ser idêntico ao original: o
daemon devolve o caminho
<id>/<arquivo>e oEmbeddedAssetso serve por essa chave. Re-codificar um raster para PNG mantendo, digamos, um nome.jpgé inofensivo — oiced/imagedecodifica por conteúdo (magic bytes), não pela extensão. - Staging. A pasta é limpa a cada build (para não reter logos de blueprints
removidos) e o build é refeito quando a árvore de blueprints muda
(
cargo:rerun-if-changed).
2. Embute no .exe do Windows o ícone da aplicação, o manifesto e os
metadados de versão. Build scripts rodam no host, então o alvo é detectado
pela variável CARGO_CFG_TARGET_OS que o Cargo define — cfg!(windows) refletiria
o host e ficaria falso durante o cross build Linux→Windows. O recurso é compilado
pela ferramenta RC que o embed-resource achar; no cross build é o llvm-rc
(o assets/rustploy.rc não tem #include, então nenhum header do Windows SDK é
necessário). As macros do VERSIONINFO são derivadas de CARGO_PKG_VERSION
(ex.: "0.1.0" → vírgulas 0,1,0,0 e string "0.1.0") e passadas como defines
ao RC, de modo que a versão do .exe nunca sai do lugar em relação ao
Cargo.toml. O llvm-rc recebe os defines via macros do embed-resource, e a
string precisa de aspas escapadas para chegar como literal entre aspas no .rc.
3. A ponte do zip do Infra as Code
manifest_zip.rs existe porque o motor glacier-ui traz zip_dir (compacta um
diretório) mas não o inverso, e a camada Lua não tem como abrir um .zip. O
Infra as Code precisa dos dois sentidos:
- exportar grava um
.zipcom exatamente dois arquivos,rustploy.ymlerustploy.vars.toml, na raiz — sem diretório de staging: os dois são escritos direto no arquivo final (write_manifest_zip); - importar lê um
.zipe exige exatamente um*.yml/*.yamle um*.toml, na raiz, e mais nada (read_manifest_zip). Diretórios e qualquer subpasta contam como "extra" e invalidam o arquivo. O retorno é(yaml, toml)ou uma mensagem de erro pronta para exibir.
As duas funções entram como globais de todo <script> via
GlacierDaemon::lua_extension (app::run), implementadas com o mesmo crate zip
que o glacier já usa — então o Cargo deduplica e não há uma segunda cópia no
binário. install registra:
manifest_zip_write(zip_path, yaml, toml) -> (ok: boolean, err: string?)manifest_zip_read(zip_path) -> { ok: boolean, yaml: string?, toml: string?, error: string? }
4. A API de agente
O módulo src/agent/ é um servidor HTTP local que empresta a sessão desta janela
a quem opera por fora.
4.1 O problema
O daemon do rustploy já expõe tudo por HTTP (POST /api/rpc): dá para conduzir
um deploy inteiro por fora da GUI. O que faltava era o caminho até lá. Quem
quer operar um rustploy remoto por agente precisa da URL pública do daemon e do
bearer token dele em mãos, na máquina do agente, fora do lugar onde essas
credenciais já vivem — que é este app.
4.2 O desenho: a direção se inverte
O app já está logado no daemon remoto (o usuário digitou URL + token na tela de login). O módulo sobe um servidor HTTP em loopback que aceita comandos de um agente na mesma máquina e os encaminha ao daemon usando a sessão da GUI. O agente nunca vê o token do daemon, não precisa saber o endereço do servidor remoto, e o que ele alcança é exatamente o que a janela alcança: trocar de servidor na GUI troca o alvo do agente junto.
agente local ──HTTP──> 127.0.0.1:9800 (este módulo) ──HTTPS──> rustploy remoto
▲ (POST /api/rpc)
└── sessão (url + token) lida do contexto da GUI
4.3 Descoberta: o arquivo de handoff (handoff.rs)
Ao subir, o app grava um JSON (agent-api.json) com a URL local, o token de acesso
e o PID. É o único passo de descoberta: o agente lê o arquivo e já sabe tudo
para a primeira requisição; GET /agent/schema conta o resto.
- Onde. No data dir do usuário — o mesmo que a GUI já usa para o
storagedo Luau e a geometria da janela (shared::fallback_data_dir()) —, então não inventa diretório novo nem depende do diretório corrente. - É um segredo. Quem lê o token opera o rustploy remoto inteiro. O arquivo
nasce
0600no unix. No Windows, onde não há modo POSIX, o diretório de dados do usuário é a fronteira — o mesmo nível de proteção do resto da persistência local do app. - Token efêmero.
generate_tokenproduz 32 bytes de CSPRNG em hex, novo a cada boot de propósito: não há motivo para ele sobreviver ao processo que o serve, e assim um handoff velho esquecido no disco não vale nada. - O campo
remote. É o daemon ao qual a janela está conectada agora, ouNonena tela de login. Expor isso poupa ao agente uma requisição só para descobrir que ainda não há sessão. - O campo
note. O arquivo pode ter sido escrito por um processo que morreu sem limpar; por isso o leitor deve conferir opid(ou simplesmente tentar/agent/health) antes de concluir que a API está no ar. - Escrita atômica. Grava-se num arquivo temporário (
.json.tmp) e faz-serename. Sem isso, um agente que lesse exatamente durante a regravação — que acontece a cada login/logout — poderia pegar um JSON truncado. - Remoção.
removeapaga o handoff ao encerrar o servidor. Um handoff órfão não é perigoso (o token morre com o processo), mas confunde quem for ler depois.
4.4 Limites deliberados
- Só loopback. O bind é sempre
127.0.0.1; não há opção de expor na rede. Isto é uma ponte para processos da mesma máquina, não um segundo daemon. - Token mesmo assim. Loopback não é fronteira de segurança num desktop
multiusuário, e o que está do outro lado da ponte derruba produção. O arquivo de
handoff nasce
0600. - Sem escopo. Quem tem o token do handoff tem o mesmo poder que a janela — o
poder do bearer do daemon, hoje sem escopo nenhum. Enquanto o daemon não tiver
tokens com escopo (ver a nota de segurança em
docs/plano-erro-de-deploy-invisivel.md), esta ponte não tem como inventar um.
4.5 Subida, endereço e ciclo de vida (agent/mod.rs)
- Thread própria, runtime próprio.
spawnsobe o servidor numa thread separada com um runtime tokiocurrent_threadpróprio. É deliberado: o loop doicedé dono da thread principal e o executor dele não é lugar onde se possatokio::spawnantes derun(). Uma thread assim custa quase nada e mantém a API viva independente do que a UI esteja fazendo — inclusive com a janela fechada, quando o app fica recolhido na bandeja (o motor headless continua vivo e a sessão junto). - Não devolve erro. Falhar ali não pode impedir o app de abrir; qualquer problema vira aviso no stderr e a GUI segue como sempre foi.
- Idempotência (
STARTED). O.on_message()doGlacierDaemon, de ondespawné chamado, roda a cada dispatch da principal. Uma segunda thread tentaria bind na mesma porta e reescreveria o handoff com um token novo — invalidando o que o agente já tem. OAtomicBooltambém responde "esta execução chegou a subir o servidor?". cleanupsó de quem subiu. Apaga o handoff ao encerrar (o token é desta execução e não vale depois dela), mas só seSTARTEDestá ligado. Sem esse guard, um segundo lançamento do app — que osingle_instanceencerra em silêncio sem abrir janela — apagaria o arquivo da instância que continua no ar, e o agente perderia o caminho de volta sem nada ter acontecido de fato.- Endereço.
DEFAULT_ADDRé127.0.0.1:9800: porta alta e fixa para o handoff ser previsível. Se estiver ocupada,bindcai para uma porta efêmera no mesmo IP (outro app, ou uma instância anterior ainda encerrando) e o handoff diz qual foi — por isso o agente lê o arquivo em vez de assumir a porta. - Variável de ambiente
RUSTPLOY_AGENT_API.offdesliga a API;127.0.0.1:9910troca o endereço. A regra de resolução (resolve_addr) é separada da leitura da variável (configured_addr) para poder ser testada. Um valor não-loopback é recusado e cai no padrão: o desenho todo supõe que só processos da mesma máquina alcançam esta porta, e uma variável de ambiente num.desktopnão é lugar de furar isso sem querer.::1também é loopback e é aceito.
4.6 A sessão (session.rs)
A thread da UI escreve a sessão; a thread do servidor lê, a cada requisição.
Quem escreve é o gancho on_message do GlacierDaemon: toda mensagem
despachada na janela principal passa por lá com o motor no estado resultante, e o
contexto do motor é onde a camada Luau guarda api_url/api_token/connected
ao conectar (handlers/connection.luau). Não há um "evento de login" no glacier
para assinar, e nem faz falta: o on_message roda depois de cada dispatch, então
observar o contexto ali é equivalente — e cobre de graça o logout (que apaga o
contexto inteiro) e a troca de servidor, sem que o módulo conheça nenhum dos dois
fluxos.
Sessioné a conexão viva com um daemon, do ponto de vista da API:base_urlsem barra final (https://rustploy.exemplo.com) etoken, que éNonequando o daemon roda sem token.- O espelho do contexto. Além da sessão, guarda-se uma cópia do contexto do motor, refeita a cada dispatch. Ela existe porque o contexto só é legível de dentro do gancho, na thread do iced, e a thread da API precisa dele para responder "em que tela a GUI está?", "qual serviço está selecionado?", "qual foi o erro do último login?". Copiar em vez de referenciar permite que as duas threads sigam sem trava compartilhada. O custo é um clone do mapa por dispatch (~120 chaves, algumas com JSON de dezenas de KB), a cada 2s no ritmo do snapshot do SSE; numa aplicação de desktop isso é ruído, e a alternativa (comparar campo a campo para copiar só o que mudou) custaria a mesma ordem de trabalho.
sync_from_contextrelê o contexto, atualiza o espelho (sempre) e a sessão (se algo mudou). Devolvetruequando a sessão mudou — o chamador usa isso para regravar o handoff sem escrever em disco a cada tick do snapshot do SSE (que dispara um dispatch a cada 2s e, portanto, uma chamada aqui).- O gate
connected.from_contextsó devolve sessão quando a camada Luau marcouconnected, o que acontece quando oDaemonStatusde validação passou (handlers/connection.luau::connect). Sem isso a API aceitaria requisições enquanto a tela de login ainda mostra credenciais que o usuário está digitando: o usuário já digitou a URL, mas o Connect não validou nada, e isso a API de agente não pode usar. SharedSessioné o handle compartilhado. SessãoNonesignifica que a janela não está conectada a daemon nenhum (tela de login, ou logout); aí a API responde 503 dizendo exatamente isso, em vez de falhar de um jeito que pareça bug de rede.
4.7 O cliente do daemon remoto (client.rs)
- hyper cru, via o
Clientlegado dohyper-util, e nãoreqwest: é a mesma regra do daemon — um cliente HTTP só no workspace, e ele é o hyper. O que se precisa é pequeno e conhecido: umPOSTde JSON com bearer. - Sem gzip, de propósito. O daemon comprime a resposta do
/api/rpcquando o cliente pede (Accept-Encoding: gzip). Este cliente não pede: o ganho é de link remoto lento e o custo seria carregar um descompressor aqui para nada. - Provider de cripto explícito. O glacier-ui já instala o provider
ringcomo padrão do processo, mas depender dessa ordem seria frágil; passa-se o provider explicitamente (rustls::crypto::ring::default_provider), e assim tanto faz quem instalou o quê antes.newsó falharia se o provider não pudesse ser configurado, o que na prática não acontece. https_or_http, nãohttps_only. Um rustploy de laboratório na rede local roda em HTTP puro, e é a GUI que decide isso ao conectar — não cabe a esta ponte recusar o que a janela aceitou.TIMEOUTde 60 s por requisição. UmCommandpesado (oSnapshotfaz várias idas ao Docker) leva segundos; o que o limite protege é o daemon inalcançável, para o agente receber um erro em vez de ficar pendurado.RemoteErrorjá separa o que o agente precisa distinguir: problema de transporte (Transport: não chegou lá — DNS, TLS, conexão recusada, timeout) × resposta de erro do próprio daemon (Status: chegou e ele recusou — 401 de token vencido, 404…) ×Decode(a resposta chegou mas não é o JSON esperado).rpcexecuta umCommandcomPOST /api/rpce devolve aResponsecomo JSON cru. Quem chama decide o que fazer com{"Err":{…}}, que é resposta 200 do ponto de vista HTTP.upload_archivesobe um zip paraPOST /api/services/<id>/archive. Merece método próprio porque não é umCommand: é rota HTTP com corpo binário, e um agente que só leuprotocol.rsnão descobre que ela existe — era o último caminho do fluxo "criar serviço Archive → deployar" que a ponte não alcançava. O nome original do arquivo vai emX-Rustploy-Filename(o daemon o lê dali; não há multipart) e é o que aparece depois na aba do serviço. O timeout é de 600 s, sem o curto dorpc: um zip de projeto leva bem mais que uma chamada de protocolo, e o custo é de rede, não de espera por um daemon travado.- Ler uma
Response. O protocolo é serde externally-tagged, então "que resposta é essa?" é sempre a única chave do objeto ({"Projects":[…]}→"Projects") — menos quando a variante não tem campos, que o serde serializa como string pura ("Ok"). Esquecer o segundo caso é o erro clássico de quem escreve cliente para esta API.response_kind,response_payload(o conteúdo de uma variante com campos, ouNonepara a unitária) eresponse_error(Some((code, message))quando éResponse::Err) encapsulam isso.
4.8 O servidor e o roteamento (routes.rs)
Servidor e cliente são hyper: o daemon já serve a própria API assim, e não faz
sentido carregar um segundo framework HTTP num app de desktop só para servir sete
rotas em loopback. Todas as rotas devolvem JSON, inclusive os erros — um agente
não deveria precisar distinguir "corpo de erro em texto" de "corpo de resposta em
JSON" no meio de um fluxo. O formato de erro é sempre
{"error": {"code": "...", "message": "..."}} (ApiFail).
Constantes. MAX_BODY = 32 MiB: generoso porque um ManifestApply ou um
ServiceUpdate de fonte Compose carrega YAML de verdade (dezenas de KB) e
mesquinho o bastante para um cliente maluco não comer a RAM do app.
POLL_INTERVAL = 2 s é o intervalo com que o wait de um deploy reconsulta o
daemon. MAX_WAIT = 3600 s é o teto do timeout_s aceito em
POST /agent/deploys.
Estado (Ctx). Compartilhado por todas as conexões: o canal ui para injetar
ações no motor (glacier-ui 0.58.6+) — sem ele, login e navegação só por clique, é o
que torna a GUI dirigível —, o token exigido no Authorization (o desta API,
não o do daemon) e a sessão.
Códigos de erro.
ApiFail::desconectado→ 503, com uma mensagem que diz o que fazer, porque é o erro que um agente mais vai encontrar: o app abriu, mas ninguém logou ainda.- Falha de transporte para o daemon → 502: quem falhou foi o salto daqui para o
daemon, não o pedido do agente, e a distinção importa para ele saber se adianta
repetir. Pelo mesmo motivo, um
connectrecusado pelo daemon (token errado, host inalcançável) é 502. Response::Errdo daemon vira 400 com o código e a mensagem dele — não um 200 que o agente teria de inspecionar para descobrir que falhou.
Servir. serve sobe o listener e atende até o processo morrer. Se não
conseguir gravar o handoff, a API sobe do mesmo jeito mas ninguém a descobre — vale
um aviso alto, não um encerramento. accept_loop é separado de serve para os
testes exercitarem o roteamento sem gerar token nem escrever o handoff no data dir
do usuário; um cliente que desiste no meio é rotina e nada se reporta. bind tenta
o endereço pedido e cai para uma porta efêmera (ver 4.5).
Acompanhar a sessão (watch_session). Mantém remote_url/connected do
handoff em dia. A sessão muda na thread da UI (login, logout, troca de servidor) e
o handoff é escrito aqui, na thread da API: um poll curto é o acoplamento mais
barato entre as duas, e não há nada a perder em detectar a mudança 2 s depois.
Roteamento (handle). Liveness sem token, depois o gate de token, as rotas de
dados (repassadas ao daemon) e as de controle da janela.
GET /agent/healthfica fora do gate: serve para o agente saber se o app está no ar antes de ter lido o handoff, e não revela nada — nem o endereço do daemon remoto, que é dado do usuário.check_tokencompara o bearer da API de agente em tempo constante: o token é curto e local, mas comparar segredo com==é o tipo de detalhe que não vale economizar.
Rotas de dados.
GET /agent/status— a janela, o daemon e a fila de deploys num lugar só.GET /agent/services— índice achatado projeto→serviço. Existe porque o caminho cru para "qual é o id do serviço chamado X?" éProjectListseguido de umServiceListpor projeto, e a alternativa de uma chamada só (Snapshot) devolve o dashboard inteiro — Docker, jobs, registry, métricas. Aqui vai só o que identifica um serviço. (snapshotdesembrulha as duas camadas deResponse::Snapshot(String), que é JSON dentro de uma string.)GET /agent/deploys— últimos deploys com o desfecho já resolvido.POST /agent/deploys— dispara um deploy e, comwait, só responde quando ele terminou. É a rota que motivou este módulo. Sem ela, "o deploy funcionou?" custa ao agente umDeployStart, um laço deDeployHistoryfiltrando por id, umGetBuildLogsinteiro e o conhecimento de que a causa da falha mora nostates_loge não no estado — conhecimento que ninguém tem na primeira vez.resolve_serviceaceita oservice_iddireto ou o nome do serviço: o nome é o que o usuário diz ("sobe o stand-imob"), o ULID não. O nome é único por projeto, não globalmente: havendo ambiguidade, a rota não escolhe por conta própria e responde erro.wait_for_outcomefaz poll até um estado terminal (ou o prazo acabar). É poll e não SSE porque manter uma conexão de eventos aberta significaria consumir e reemitir o firehose do daemon só para observar um id; o deploy mais rápido leva segundos, e 2 s de granularidade não custam nada. Se expirar, devolve o último estado conhecido em vez de um erro seco — "ainda emBuildingImagedepois de 15 min" é informação útil, e o agente decide se espera mais ou investiga.
GET /agent/deploys/<id>/logs— build log com cursor. O daemon só sabe devolver o log inteiro (GetBuildLogsnão tem cursor), e um build de verdade passa de mil linhas. A fatia acontece aqui: o tráfego caro é o desta ponte para o agente, não o da ponte para o daemon na mesma sessão.afteré o índice da última linha já vista — a tabela do daemon só recebe append e é ordenada por timestamp, então o índice é um cursor estável.build_log_linesextrai o texto de cada linha, na ordem de gravação.POST /agent/rpc— qualquerCommanddo protocolo, sem tradução. É a válvula de escape que mantém as rotas de conveniência honestas: elas existem para os caminhos frequentes, não para virarem a única porta. Tudo o que a GUI faz, um agente faz por aqui. O passthrough encaminha o comando cru e devolve aResponseverbatim.GET /agent/ingress— a tabela de rotas viva do proxy reverso. Responde à pergunta de um domínio que devolve 502: existe rota para ele, e para qualip:portaela aponta? Umbackendsvazio, ou apontando para a porta errada, é a resposta.POST /agent/ingress/reconcile— recalcula as rotas a partir dos containers que existem de fato, sem redeployar, e devolve a tabela já corrigida. O corpo é opcional ({"service_id":"svc_…"}); corpo vazio é válido e reconcilia tudo.GET /agent/servers— os servidores já usados nesta máquina (ver 4.10).POST /agent/services/<id>/archive— sobe um zip local para o serviço. Recebe o caminho do arquivo, não os bytes: quem chama está na mesma máquina (a ponte é loopback), e mandar dezenas de MB em base64 por HTTP para a ponte remontar seria custo puro. O corpo é{"path": "/caminho/app.zip"}.
Controle da janela (ver 4.9).
POST /agent/connect— entra na sessão pela própria tela de login. Era o último ponto cego de verdade: sem isso a ponte só servia depois que um humano tivesse clicado Connect, e um agente numa máquina sem ninguém na frente ficava preso no 503. Otokené opcional: omitido, a ponte busca o salvo para aquela URL (servers.rs), de modo que o segredo nunca precisa atravessar a rede.POST /agent/disconnect— sai da sessão. Não espera:disconnect()na Luau é síncrono e não tem como falhar, e a consequência (a sessão sumindo) fica visível emGET /agent/ui.GET /agent/ui— o que a janela está mostrando agora.POST /agent/ui/action— dispara qualquer ação da UI pelo nome. É a chave-mestra: todo botão, aba e formulário da GUI é uma função Luau global (views/scripts/handlers/*.luau), e este endpoint chama qualquer uma pelo mesmo caminho de um clique. Cobre as ~154 ações existentes e as futuras sem uma lista aqui, que envelheceria na primeira tela nova. Comvalueo efeito é o de umonChangede campo; semvalue, o de um clique de botão — a mesma distinção que os templates fazem, então a ação recebe exatamente o que receberia da UI. A resposta é 202, não 200: a ação foi entregue ao motor e o efeito dela é assíncrono (várias fazem RPC); prometer "deu certo" ali seria mentira.POST /agent/ui/context— escreve chaves no contexto da janela. É o par de baixo nível doui/action: preenche campo de formulário, marca seleção, muda de aba; útil quando a ação que se quer disparar espera algo já escrito no contexto. Número e booleano viram texto, porque o contexto do motor é sempre chave→string e recusar por tipo seria pedantismo inútil.
Moldando as respostas do daemon. O objetivo é devolver ao agente o que ele precisa, não o que o daemon tem.
outcome_okresolve o desfecho de um deploy:true= no ar,false= falhou,null= ainda não decidiu. SóLiveeFaileddecidem.StoppedePruningsão terminais sem serem desfecho: o primeiro é o serviço derrubado de propósito, o segundo é um deployment antigo que outro mais novo substituiu. Chamar qualquer um dos dois de "falha" seria mentira.failure_reasontira a causa da falha dostates_log, que é onde o daemon a grava: a transição que entrou emRollingBackcarrega a mensagem do step que quebrou (o texto dodocker build, o healthcheck que não passou…). Mensagens de outras transições são ignoradas de propósito —Pruningtraz "superseded by newer deployment", que não é erro nenhum.compact_summary/compact_deploymentreduzemDeploymentSummary/Deploymentao que interessa a quem só quer saber como acabou.service_indexachata o snapshot no índice que um agente precisa para agir: id, nome, projeto, status e de onde a imagem vem.status_labeltransforma oServiceStatus(externally-tagged, só a varianteErrortem campo) em"Running"ou"Error: <causa>"; depois da correção do log de deploy essa causa é o motivo real da falha, não mais a string fixa "deploy failed".source_labelachata oServiceSourceno que identifica a origem, sem arrastar ocompose.contentinteiro (dezenas de KB) para dentro de uma listagem — e a credencial de um repositório git nunca vaza para uma listagem.
Parâmetros da query. num_param e str_param não fazem percent-decoding:
nenhum parâmetro numérico precisa, e os campos de texto livre (nome de serviço)
vão no corpo, justamente para não depender disso. Os únicos usos textuais são
listas de nomes de chave (keys=screen,view) e flags (all=1).
4.9 Controlando a própria janela (ui.rs)
A ponte de routes.rs encaminha Commands ao daemon remoto, e isso cobre tudo o
que é dado. Sobrava o que é janela: entrar na sessão, sair dela, navegar
entre telas, abrir uma janela-filha, marcar um serviço como selecionado. Nada disso
é um Command — mora na camada Luau, e só era disparado por um evento do loop do
iced.
O canal external do glacier-ui (0.58.6+) fecha a lacuna: a thread do servidor
injeta no motor da janela principal o mesmo tipo de mensagem que um clique
produz. Como o vocabulário é o dos templates, toda ação declarada na UI já é
alcançável — as 154 de hoje e as que vierem, sem lista para manter em dia. O par de
leitura é o espelho do contexto em session.rs: escrever é pelo canal, ler é
pelo espelho.
connect. Preenche o formulário de login e aciona o botão Connect, exatamente como um usuário faria, e espera o desfecho. Preencher e clicar (em vez de só escrever a sessão na ponte) é deliberado: assim a GUI acompanha. Oconnect()da Luau valida com umDaemonStatus, abre o SSE, carrega as configurações do daemon, troca para a telashelle salva o servidor na lista de conhecidos; uma sessão escrita só do lado da ponte teria o agente operando um servidor que a janela do usuário nem sabe que existe. Limpa-se o erro anterior antes, para não confundir uma falha velha com esta. O motivo de uma recusa vem deerror(falha de transporte/401) ou deerro_url(URL malformada) — oconnect()escreve ali e volta sem conectar.TIMEOUT_CONNECT(30 s) é o quanto se espera: ele faz umDaemonStatusde validação contra o daemon remoto, então o teto é de rede, não de UI.POLL(100 ms) é de quanto em quanto tempo se reconsulta o espelho.disconnect. Odisconnect()da Luau fecha o SSE, apaga o contexto inteiro e volta para a tela de login; a ponte perde a sessão junto, por construção.state. O estado da janela que interessa a quem a dirige de fora. É curado, não o contexto cru: o contexto tem ~120 chaves, várias com o JSON inteiro de uma tela (todos os serviços, todas as imagens Docker), e devolver tudo por padrão faria a resposta custar mais que a informação. Para as demais há o parâmetrokeys.screené a janela toda (login|shell);viewé a seção da sidebar.keyseall_keys. Chaves avulsas do contexto, para o que o resumo curado não cobre;all_keysdespeja todas, só sob pedido explícito (?all=1), com os segredos redigidos. Uma chave pedida e inexistente volta comnull: isso diz "perguntei e não tem"; omiti-la deixaria o chamador sem saber se errou o nome.REDIGIDASsão as chaves que nunca saem pela API:api_token(o bearer do daemon remoto — o desenho inteiro da ponte é o agente operar sem nunca vê-lo) etoken(o campo do formulário de login, que carrega o mesmo segredo enquanto o usuário digita). O resumo curado nunca carrega o bearer do daemon.
4.10 Os servidores conhecidos (servers.rs)
A camada Luau salva todo login bem-sucedido no storage do glacier-ui — um JSON no
data dir do usuário (handlers/connection.luau, remember_server). É de lá que o
formulário de login nasce preenchido. Para a API de agente isso resolve um problema
concreto: conectar sem precisar do token. O agente pede a URL, a ponte encontra o
token salvo e preenche o formulário — o segredo nunca atravessa a rede em nenhum
sentido, nem na ida (o agente não o manda) nem na volta (a listagem só diz se
existe).
listlê a lista salva. Fica vazia quando o arquivo não existe (nenhum login ainda), está corrompido ou mudou de formato — nunca é erro fatal: a consequência é só o agente ter de informar o token, que é o caminho normal mesmo.token_forcompara a URL de forma tolerante à barra final — a mesma normalização que a sessão faz — parahttps://x.dev/ehttps://x.devnão virarem servidores diferentes.as_jsoné a listagem que sai pela API: URL e se há token guardado, nunca o token.
4.11 O índice de ações (actions.rs)
POST /agent/ui/action dispara qualquer ação da GUI pelo nome. Mas uma chave-mestra
sem chaveiro só serve a quem já sabe os nomes, e saber os nomes exigia ter o
repositório aberto (grep "^function " views/scripts/handlers/*.luau) — o que
deixaria a rota mais poderosa da ponte fora do alcance de um agente que só tem a
máquina do usuário e o arquivo de handoff.
Este módulo devolve a lista em runtime, lida da mesma árvore de scripts que o motor executa: do disco em debug, da árvore embutida no binário em release — nunca de uma lista escrita à mão, que envelheceria na primeira tela nova.
- O que conta como ação: uma função global do Luau, escrita na coluna 1
(
function nome(...)), que é exatamente o que os templates referenciam emon_click/onChange/on_submit.local function(auxiliar do módulo), métodos (function M:algo) e umfunctionindentado (dentro de outro bloco) ficam de fora, e o teste correspondente existe porque anunciar auxiliares daria ao agente nomes que respondem 202 e não fazem nada. - Debug × release. Em debug os assets são lidos do disco pelo motor (com
hot-reload) e o diretório corrente já é a base dos assets
(
assets::locate_and_chdir), então a varredura segue o mesmo caminho. Em release não há árvore no disco: vem do binário (embedded::luau_sources). - A lista sai ordenada por nome: é para ser lida e procurada, não para preservar
a ordem de declaração de cada arquivo.
Acao.origemé o arquivo relativo aviews/scripts/, ex.:handlers/services.luau.
4.12 O catálogo de descoberta (catalog.rs)
GET /agent/schema é o documento de descoberta. Sem ele, montar a primeira chamada
exige ler src/protocol.rs do rustploy-shared e models.rs e deduzir a codificação
serde na mão — viável para quem tem o repositório aberto, inviável para um agente
diante de um daemon remoto. É o atrito mais caro relatado em
docs/plano-erro-de-deploy-invisivel.md (2.1).
O catálogo é curado, não gerado. As rotas desta API estão descritas por inteiro;
a lista de Command cobre o que aparece em runbook, não as ~90 variantes do enum. A
fonte da verdade continua sendo protocol.rs, e o passthrough (POST /agent/rpc)
aceita qualquer comando, esteja ele listado ou não — inclusive os adicionados depois
deste arquivo.
Por ser escrito à mão, um json! malformado só apareceria em runtime; os testes
garantem que o catálogo ao menos existe e cita cada rota que o roteador realmente
serve, e que cada exemplo de comando é JSON de verdade — copiar um exemplo quebrado
é pior do que não ter exemplo.
5. Os testes e o que cada um trava
5.1 Testes da API de agente
São testes unitários pequenos, cada um ligado a uma decisão de desenho:
resolve_addr—::1também é loopback; e o que importa de verdade: pedir bind público não abre a ponte para a rede, cai no padrão de loopback.session— repetir o mesmo contexto não pode contar como mudança (o tick do SSE dispara um dispatch a cada 2 s; senão o handoff seria reescrito em disco o tempo todo); logout apaga o contexto inteiro e a sessão cai junto (impede o agente de continuar operando um servidor do qual o usuário acabou de sair); trocar de servidor na GUI troca o alvo do agente sem ele saber.ui— o token do daemon é o segredo que a ponte existe para não expor: nem por chave avulsa, nem no despejo completo.client—Response::Okvira a string nua"Ok", não um objeto: o caso que quebra cliente ingênuo.routes(unitários) — o desfecho: sóLiveeFaileddecidem, e um deployment substituído por outro mais novo (Pruning) não é falha; e a regressão que o módulo inteiro serve: a causa da falha sai dostates_loge não se deixa confundir pela mensagem de "superseded".actions— auxiliares de módulo e métodos não são dispatcháveis.
O teste ponta a ponta da ponte (routes::tests) é o que separa "compila" de
"funciona". Ele monta um daemon rustploy de mentira de um lado, a API de agente
no meio e um cliente HTTP cru do outro (hyper direto sobre TCP, sem TLS — tudo é
loopback), e exercita o roteamento, o gate de token, o gate de sessão e — o
principal — o POST /agent/deploys com wait.
- O daemon de mentira responde o subconjunto do protocolo que as rotas usam. Uma
variante unitária chega como string nua e a com campos como objeto de uma chave,
igualzinho ao daemon de verdade.
HISTORY_HITSconta quantas vezes ele já respondeu umDeployHistory: o deploy só "termina" na segunda consulta, para o teste passar mesmo pelo caminho de espera em vez de acertar terminal de primeira (e a asserção>= 2prova que passou por ele — o primeiroDeployHistoryainda estava emBuildingImage). - O canal
externaldos testes não tem motor do outro lado: as mensagens caem num receptor que ninguém drena, o que é exatamente o certo aqui — os testes exercitam o HTTP e a conversa com o daemon, não o efeito na janela. - Com o token certo, o que barra agora é a falta de sessão — o que prova que a requisição passou do gate de token.
- O caso do plano, ponta a ponta: dispara o deploy, espera, e a resposta já traz
ok=falsecom a causa que o Docker deu, sem o agente precisar saber que ela mora nostates_log. O serviço é pedido por nome, não por id (o daemon de mentira resolve viaSnapshot), elog_tailtraz só o fim, com o cursor dizendo onde continuar. - O passthrough encaminha o comando cru e devolve a
Responseverbatim;Response::Errvira 400 com código e mensagem; daemon inalcançável (porta fechada de propósito) é 502, não 500 — a distinção diz ao agente se adianta repetir. Credencial de repositório não vaza para uma listagem de serviços.
5.2 Os testes de fmt em Luau (tests/fmt_*.rs)
Rodam módulos Luau de formatação no motor de verdade, com uma fixture — uma tela
mínima que carrega o módulo e exibe o resultado. A fixture mora fora da árvore de
scripts do app (para não virar script do app), então o require("fmt/…") dela
precisa de uma raiz extra, adicionada por variável de ambiente dentro de um bloco
unsafe.
fmt_service_detail(compose_hosteinternal_url) existe desde o rename de serviço: o hostname interno de um serviço Compose é a chave do YAML, que não muda quando o serviço é renomeado, e o card "Internal URL" da aba Connection depende disso. O mesmo cálculo existe em JS (webui/fmt.js::composeHost), coberto pelo testerenomear_servico_na_aba_generaldo daemon; aqui é a metade Luau. Num Compose o nome do serviço (meu_banco) não aparece — vale a chave do YAML; numa Application érp_<nome>, e semdb_kinda GUI põehttp://(a webui, hoje, não põe esquema nenhum — divergência anterior ao rename, não mexida aqui).fmt_timeexiste desde que o arquivo deixou de fazer a aritmética de fuso à mão e passou a chamar o globaldateda glacier-ui 0.73. A conversão UTC → hora local é o tipo de coisa que quebra em silêncio — um timestamp errado por três horas ainda parece um timestamp —, então vale um teste próprio. Toda asserção é independente do fuso da máquina que roda. A que carrega o peso é a primeira: o mesmo instante escrito como…Ze como…-03:00tem de renderizar igual, seja qual for o fuso local. As demais: fração de segundo é aceita e descartada; a conversão realmente aconteceu (o horário exibido é o UTC deslocado pelo offset local, a menos que a máquina esteja em UTC); o prefixo de data e hora é o mesmo nas duas formas; ausente ou malformado vira"", que é o que os templates esperam; semfinished_atconta até agora — o valor varia, o formato não. O offset local é perguntado ao sistema comdate +%z(a mesma fonte que olocaltimedo Luau consulta), para o teste não precisar de crate de data só para conferir uma subtração;hora_deslocadasoma o offset descartando a virada de dia (só as horas importam).
5.3 A validação headless das telas (tests/templates_render.rs)
Todo template parseia, toda tela/aba avalia e monta uma árvore de elementos iced
sem erro. Pega KDL malformado e propriedade .gss desconhecida — que derrubaria
uma folha de estilo inteira — sem precisar de display. boot sobe o motor do jeito
que o main.rs faria, mas da raiz do workspace para os caminhos de template
resolverem; o app.gvb já linka o app.gss (<link rel="stylesheet">, global
desde o glacier-ui 0.23), então register_component o pega sem um
load_stylesheet à parte.
Janelas à parte. "Novo projeto", "Novo job", "Logs" e o wizard "Novo serviço"
são, cada uma, um motor isolado aberto por open_window; não passam pelo app.gvb,
então cada teste as registra e renderiza por conta própria, semeando a conexão
(e projetos/serviços já buscados) como open_window({ data = … }) faria. O init do
script real tenta o catálogo, mas o fetch suspende sem executor — por isso os
dados que os passos esperam são semeados à mão. Chaves como njob_time ("HH:MM" do
<timeedit>) e a coleção do <radiogroup> de dia da semana são semeadas pelo
init() da janela real; o teste faz o papel dele.
Onda 1 da reforma (glacier-ui 0.102+): o campo NOME de "Novo projeto" usa a
validação declarada no <form>. Na árvore avaliada, o form_control np_name deve
carregar rules com required e ter o on_validation_error do <form> propagado
(form_error_action) — é o que garante que um envio vazio roteie para o
np_apontar em vez do submit_project.
O teste que passa por todas as telas (all_screens_and_service_tabs_render)
percorre: as views do shell; o Deploy Engine com a fila "NA FILA" (itens
arrastáveis, estado pausado, botão de retomar); o Ingress com a tabela de portas TCP
de host (separada das rotas de domínio); as cinco sub-abas do Docker (o laço de
views só renderiza a padrão, containers; cada sub-aba tem painel escopado por
docker_tab, antes nunca exercitado) e, no Registry, também o ramo "repo
selecionado" (lista de tags); Schedules; o projeto aberto (grade de serviços e a aba
de variáveis de ambiente de projeto, com um nome de variável absurdamente longo para
exercitar o truncamento de key_display sem quebrar o key completo usado por
delete/reorder/.env); a aba Variáveis no modo "usar secret" e a lista de secrets
vazia; Settings (Git com os dois métodos, Web Server — cuja URL pública é derivada
pelo daemon e exibida só-leitura —, Infra as Code com os ramos de variáveis
faltando e de relatório aplicado, e Manutenção com as três recorrências, cada uma
mostrando campos diferentes, o toggle geral e os seis sub-toggles); e as abas do
detalhe de serviço (editor de env, painel de build log, sub-aba Gitea, os estados de
webhook — com URL emitida, sem token por nunca ter sido deployado, e serviço Compose
sem webhook — e as sub-abas Git/Zip do provider e o editor de Compose).
Contratos travados por testes próprios:
- A sidebar é uma
<drawer>(Onda 2 da reforma,docs/plano-reforma-gui-glacier-0.102.md), não mais um trilho de ícones colapsável por@media. O teste trava três coisas: (1) o<drawer>(um<Reveal axis="x">após o eval) reflete{menu}no seuopen—"true"abre, vazio fecha; (2) o gatilho ☰ da topbar disparadrawer::toggle:menu, a ação que o builtinDrawerconsome de qualquer lugar da tela; (3) abaixo de 900 px os rótulos dosNavItemnão são forçados ahiddenpor um@media— era o bug antigo (o trilho sumia o rótulo; agora a gaveta inteira fecha pelo ☰ e, aberta, cabe em 264 px). O teste usa uma largura estreita, a que reproduziu o bug de layout original. - Ações do serviço por largura. As ações Deploy/Reload/Rebuild/Stop têm duas
fileiras que se alternam: acima de 1080 px vale a de texto; abaixo, a compacta
(ícone + tooltip) — senão os quatro rótulos por extenso não cabem e o título de
30 px transborda por baixo deles ("Deploy por cima do nome"). Ambas existem sempre
no AST; o que muda é qual está
hidden. O rótulo de um botão éButton { text }, não um nóTextfilho; as quatro ações são as únicas a usar esses textos, então não há colisão com os ícones da sidebar (que são nós<text>, não botões). - Avaliação escopada (glacier 0.38+): só a tela ativa é construída, não todo
template registrado. Importa mais aqui que na média dos apps: o
app.gvbimporta a árvore inteira de views, e avaliar um template inlina recursivamente tudo o que ele usa — então a versão antiga reconstruía a UI completa uma vez por template importado, a cada tecla digitada e a cada linha de log vinda do SSE. O teste trava o ganho: registrar oapp.gvbe ativá-lo deve deixar exatamente uma árvore avaliada (as views importadas ficam registradas, mas só a tela ativa é avaliada; as demais são inlinadas dentro dela). - Logout zera a RAM da sessão. Nada do daemon anterior pode continuar no contexto
(nomes de projeto, linhas de log, o próprio
api_token). Foi um bug real: odisconnectantigo limpava só quatro chaves à mão. Hoje ele apaga octxinteiro e deixa oinit()semear os defaults; o teste dispara a ação de verdade (UiClick, o mesmo caminho do botão Disconnect) com um estado de sessão conectada "do trivial ao sensível" e inspeciona o contexto — que deve voltar ao estado de boot, não ao da sessão. - O item "Projects" não apaga nas sub-telas. Regressão: ele perdia o fundo azul
ao entrar num projeto ou serviço, porque o
nav_item.gvbcomparava{view}contra umtargetde uma view só (equals) eproject_services/servicenão são"projects". A correção usaone_of(glacier-ui 0.57.8):target="projects project_services service"casa com qualquer das três.nav_row_oné a classe do fundo azul, mas o widget<button>lê a propriedadecolor:do GSS para o fundo (background:é ignorada em botões), então o campo que importa é ocolordenode.kind, não onode.backgroundgenérico (esse é para containers/rows). Oon_clickchega namespaceado pelo componente que o hospeda (namespace_action, emeval.rsdo glacier-ui): mesmo com o valor vindo de uma prop (action="nav_projects"emshell.gvb), o botão vive dentro do template doNavItem, então o dispatch final éNavItem::nav_projects. - Janela, título e tamanho (glacier-ui 0.117). A janela principal é do
app(...)(tamanho, mínimo, moldura, ícone); o título é dascreen(acompanha a navegação); e o tamanho de cada janela filha vai na chamadaopen_window{ component = "…", size = "…" }dos handlers, porque ascreené só conteúdo. O teste garante que nada disso sumiu no caminho — um atributo apagado por engano não quebra render nenhum, só faz a janela nascer com o padrão do iced. A janela de logs é a exceção proposital: o título é dinâmico ("Logs — nginx", "Build — abc123") e vem de quem a abre. - Os ajustes do daemon no
app(...)(glacier-ui 0.119): fontes, fonte padrão,antialiasing, período dos toasts eapplication_id. Um atributo apagado por engano não quebra render nenhum — o app só passa a abrir sem a fonte e com MSAA ligado, em silêncio. - Todo
.gvbabre com a casca certa:appno manifesto,screennas telas abertas em outra janela ecomponentno resto. O teste precisa existir porque o glacier-ui aceita a forma sem cabeçalho (declarações soltas na raiz) por compatibilidade: nada no build reclamaria de um arquivo que voltasse a ela, e o que se perde é silencioso — numa tela, título e tamanho caem no padrão; num componente, some a fronteira entre declaração e layout. É textual de propósito (umcomponentnão declara nada por desenho, então só o texto diz em que forma o arquivo está) e varre o diretório em vez de listar arquivos: o alvo é o.gvbque ainda não foi escrito.comentarios_foraremove//e/* … */para que "a primeira tag" seja a primeira de verdade (todo template abre com um comentário de cabeçalho).views/mistura manifesto, telas e views internas (component); sóviews/components/é homogêneo — nada ali é tela. Uma guarda impede o teste de passar vazio (um caminho errado tornaria tudo um no-op), com comparação frouxa de propósito: acrescentar um.gvbnão deve obrigar a editar o teste. - As grades de cards passam o item inteiro ao componente via
spread="{c}"(glacier-ui 0.62). Isso troca um atributo por campo por um só — e move a checagem do contrato para o dado: um campo que ofmt/dashboard.luaunão emitir viraMissingPrope derruba a tela inteira, não um{placeholder}vazio como antes. O caso perigoso é o filler (o card vazio que completa a fileira): ele nasce de um únicoFILLERcompartilhado pelas duas grades, e em Lua um campo= nilsimplesmente não existe — por isso ele carrega a união dos dois contratos como string vazia, e é isso que o teste tranca. Ele fixadata_loading = "false"porque oconnection.luausemeia"true"noinite o<scrollable>da grade fica escondido atrás disso — sem a correção ofor-eachnunca roda e o teste passaria sem ter avaliado um card sequer. A fileira de teste é um card real mais um filler, exatamente a forma queM.project_rowsproduz com 1 projeto numa grade de 2 colunas.
6. Os templates .gvb por dentro
Os templates descrevem a interface; o comportamento mora em Luau (views/scripts/).
Esta seção reúne o que antes estava nos comentários dos .gvb, agrupado por assunto.
A estrutura de arquivos (um roteador fino por tela, um arquivo por view ou aba) e as
regras de divisão estão no AGENTS.md, em "Dividir um template grande em arquivos".
6.1 Regras que valem para todos os templates
Um só script, um só contexto. Toda a lógica de rede e de estado (login, navegação,
busca, stream SSE e ações) vive em Luau, no script que o app.gvb carrega
(scripts/app.luau, sucessor de app/root.rs + app/net/*). É o único script ativo.
Como o contexto do glacier é global, os templates importados (login, shell, home,
service) só têm markup, e os handlers deles resolvem para as funções desse script.
require("net/api") e require("fmt") resolvem em scripts/, relativo ao diretório do
script. As janelas auxiliares são a exceção: cada uma tem o próprio script e o próprio
motor.
Janelas isoladas para o que é caro. O glacier reavalia todos os templates
registrados a cada reavaliação. Na janela principal (shell + home + service + wizard, com
dezenas de for-each) isso é caro, e sob um stream de logs verborrágico satura a UI,
mesmo coalescido a 30 fps. Por isso os logs (runtime e build) abrem em janela própria:
lá o motor só tem um template minúsculo, cada reavaliação é trivial e o log rola liso
independente da taxa de chegada. O mesmo vale para a aba Logs do serviço, que é só um
launcher (ver 6.5), e para o build log de um deployment
(dep_logs → open_window): renderizar milhares de linhas por reavaliação travava a
principal.
A condição vai na chamada, não na raiz do componente. Num componente extraído, o if
do nó raiz não é aplicado: o conteúdo apareceria sempre. Por isso as abas se escrevem
ServiceLogsTab(if = @tab, equals = logs) e as abas de projeto
ProjectEnvTab(if = @proj_loading, not_equals = true), com o if no ponto de uso. O
mesmo raciocínio vale para o LoadingRow (ver 6.7).
Vários filhos condicionais pedem if @x { … }, não if= como atributo. O atributo
if= num elemento condiciona só aquele elemento. Um bloco com vários filhos
(o editor .env, o modo secret, os blocos de cada view) usa o bloco if/<template>.
No editor .env, por exemplo, o atributo só esconderia o Text e deixaria o
TextArea e os botões sempre visíveis.
scrollable só renderiza o primeiro filho (NodeType::Scrollable usa
node.children.first(), em widget.rs do glacier-ui). Por isso a lista e o editor .env
vivem dentro de uma única column, nunca como irmãos diretos do scrollable. Foi
também o que corrigiu os botões Salvar/Cancelar do editor, que antes eram irmãos fixos
depois de um scrollable com height: fill e podiam ficar cortados em janelas baixas.
Listas vazias são fallback. Cada lista tem um componente Sem… que o fallback do
for-each (e o foreach_fallback do <template>) desenha no lugar dela. Eles moram no
arquivo que os usa.
Janelas sem background: var(--bg). As telas das janelas auxiliares (e o login) não
repetem o fundo no CSS: var(--bg) é a cor do tema, já pintada na janela inteira, e
repeti-la era uma camada redobrada em cada pixel (ver PRIMITIVAS.md, "O que custa num
quadro").
Ícone + tooltip em vez de rótulo por extenso onde a coluna é estreita. As linhas de job (Schedules), de repositório (Registry) e de deployment (aba do serviço) trocaram "Rodar agora", "Ver logs", "Pausar", "Build log ↗" e "Remover" por ícones com tooltip: os rótulos quebravam em duas linhas e estouravam a coluna quando a janela estreitava, e uma linha de 360 px pensada para rótulos por extenso empurrava o botão de remover para fora da largura visível (sem scroll horizontal).
Validação declarada no <form> (glacier-ui 0.102+). As regras moram no campo
(rules=); o motor valida ao enviar e roteia on_submit (tudo certo) ou
on_validation_error (algo falhou). O <button type="submit"> dispara sem on_click, e
o .np_input:invalid (em app.gss) acende sozinho. form não aninha: um formulário
próprio tem de ficar fora de outro.
Entradas numéricas são widgets, não texto livre. O <spinbox> e o <timeedit>
validam por construção; ver "Healthcheck" e "Manutenção" abaixo para o que deu errado
antes.
Estilo e responsividade. O estilo inline fica no <style> de cada arquivo; o que é
compartilhado e responsivo (as regras de @media) vive em app.gss, não nos
templates: cruzar arquivo com o inline deixaria a precedência ambígua. A cor de um estado
vem da camada GSS, nunca de hex vindos do Rust.
6.2 O manifesto (views/app.gvb)
A raiz do arquivo é o app(...) e as telas — a janela principal e as cinco auxiliares —
são filhas dele (glacier-ui 0.117+).
idnomeia o diretório de dados (~/.local/share/rustploy, onde moram ostoragedo Luau — o login lembrado — e a geometria da janela) e é a chave da instância única. Com a bandeja declarada, fechar a janela recolhe o app em vez de encerrá-lo, e uma segunda execução foca a primeira.size,min_size,icon,decorationssão da janela principal. O ícone as filhas herdam; o tamanho de cada uma vai noopen_window("tela", { size = … })dos handlers.- Fonte e daemon. A fonte padrão vale para todas as janelas (declarada no próprio
app(...)); oapplication_iddo Linux casa a janela com orustploy-gui.desktop. O MSAA x4 padrão do iced custa caro num fallback 100% por software (sem GPU compatível: owgpurecusa adapters não-Vulkan-compliant e cai para a CPU), e telas de formulário e de lista não perdem legibilidade sem ele — por isso oantialiasingé declarado desligado. - Janela borderless. Com
decorations = false, uma moldura de 6 px de handles de resize envolve o conteúdo. Cada handle define o cursor de hover e inicia um resize interativo ao pressionar (window:resize:<dir>), tratado no runtime contra o id da janela em cache. O glacier não tem overlay/z-stack, então a moldura é montada como linha de cima / faixa do meio (bordas esquerda e direita + corpo) / linha de baixo. - Titlebar custom.
titlebar_drag(width: fill) é a alça de arraste e empurra os controles para a borda direita; as açõeson_press/on_clickwindow:*são tratadas no runtime contra o id da janela. - As janelas auxiliares (
log,new_job,new_service,new_project,new_registry_token): cada uma é um motor isolado que abre só a tela pedida (open_window("log", …)), com o próprio script — oinitdelas não roda na principal. Os tamanhos estão nos handlers.
6.3 O shell (views/shell.gvb e views/shell/)
O shell é a casca do app conectado: sidebar, topbar e as views de projeto (Deployments,
Projects, serviços de um projeto, detalhe de serviço); delega as telas globais ao
home.gvb.
- A sidebar é uma
<drawer>(glacier-ui 0.92+, Onda 2 da reforma,docs/plano-reforma-gui-glacier-0.102.md). Largura, padding e spacing vêm das props do<drawer>no markup; o CSS só dá a cor (o painel carregadrawer-panel+sidebar). A gaveta empurra o conteúdo (não cobre) e não desenha gatilho: o ☰ mora na topbar edrawer::toggle:menufunciona de qualquer lugar. Oinit()(connection.luau) semeiamenu = "true"para começar aberta. - Responsivo (glacier-ui 0.9.0+): em janela estreita a sidebar-gaveta fecha pelo ☰ (o motor anima a largura 264→0), em vez do antigo trilho de ícones. Sobra só o aperto da própria topbar; abaixo de 560 px somem a busca e o status do daemon (baixa prioridade) para o ☰, o Stop All e o Disconnect não serem cortados.
app:no ☰. O prefixoapp:tira o prefixo de dono (Shell::…) que o motor poria numa ação escrita dentro de um<component>; sem ele a ação não chegaria ao builtindrawer. Saidrawer::toggle:menu, que o dispatch roteia para o<drawer>(o alias minúsculo é um componente registrado).- Detalhe de serviço e o wizard. O wizard "Novo serviço" saiu do shell
(
view = new_service): agora abre numa janela à parte (new_service_window.gvb), disparada poropen_new_service_window. - Projects. Criar projeto abre uma janela à parte (
new_project_form.gvb, motor Glacier próprio); ao concluir, ela envia umbroadcast("project_created")que oon_broadcast(handlers/projects.luau) recebe para atualizar a grade. Veropen_new_project_window. - Projeto aberto (
project_services.gvb). Barra superior com voltar à esquerda e as ações do projeto empurradas para a extremidade direita (hspacer=width: fill); em modo de edição as ações somem (o form tem Salvar/Cancelar). A grade de serviços usavirtualize= a altura de uma fileira de cartões: só as fileiras visíveis são montadas e as de fora viram um vão do tamanho exato (glacier-ui 0.77, verPRIMITIVAS.md). O valor é a altura do card; ospacing: 10da.grido motor soma sozinho. Errar desalinha a barra de rolagem, não quebra. - Aba Variáveis (
project_env.gvb). No modo secret o campo valor passa a receber o nome de um secret do projeto (a referência, não o conteúdo), que o daemon decifra no deploy. Os comentários (# …) do.envsão arrastáveis como as variáveis (ao soltar, reancoram na variável seguinte da nova ordem), mas sem delete — removem-se editando o.env. O editor.envé um bloco condicional com vários filhos. - Aba Secrets. É write-only: o daemon só devolve os nomes (o valor é cifrado com a
chave mestre do servidor), então não há coluna de valor nem edição — sobrescrever é salvar
de novo com o mesmo nome. Ver
handlers/secrets.luauedocs/secrets.md.
6.4 As telas globais (views/home.gvb e views/home/)
Cada tela é uma seção por valor de view: Monitoring, Ingress, Deploy Engine, Docker (e
Registry), Settings, Schedules e Suporte.
- Monitoring. Bloco com vários filhos condicionais: precisa de bloco
if/<template>, não do atributoif=. - Deploy Engine. KPIs + deploys em andamento + histórico das últimas 24 h. Tudo vem do
Command::DeployEngineStatus(ver oseng_*); sem backend novo. A fila é global (um deploy por vez): o primeiro é o próximo a rodar. Arrasta-se pela alça (⋮) para reordenar, ↑ fura a fila, ✕ cancela, e pausar/retomar interrompe o worker de puxar o próximo (o que já está rodando segue). Tudo emhandlers/deploy_queue.luau. São três abas (eng_tab: fila, executando, histórico; handlereng_tabemhandlers/nav.luau), cada uma num arquivo deviews/home/deploy_engine/. Separadas, cada lista tem a tela toda: quando eram três seções empilhadas, uma fila grande empurrava as outras para fora da janela. - Docker. Sub-abas Containers / Images / Volumes / Networks / Registry. As três do meio
listam todo o host Docker (não só os recursos geridos pelo Rustploy; ver
docker_inventoryno daemon), com indicação de uso e botão de limpar as que estão sem uso.- Containers: o container nasce de um
Service, então a ligação com projeto/serviço é direta. A lista mostra todos do host (rodando + parados); remover só é oferecido nos parados (o Docker recusarmde um rodando semforce). - Images: a lista já vem ordenada por tag (
docker_images_json); o checkbox "Somente em uso" só esconde as linhas sem uso, sem round-trip. - Volumes: o Rustploy só usa bind mounts, então os volumes nomeados aqui são sempre
externos a ele (criados à mão ou por
VOLUMEda imagem). - Registry: repositórios/tags do registry OCI embutido (Fase 1: só push/pull via docker
CLI; sem auth ainda). Leitura + delete (metadados) + GC (libera do disco blobs/manifests
órfãos). As tags de um repositório são buscadas sob demanda, ao clicar "Ver tags" —
não vêm no snapshot periódico, diferente das outras sub-abas.
registry_reposchega filtrado pela busca (fmt.registry_repos(snap.registry_repos, term)emstream.luau), enquanto oregistry_repos_counté da lista crua; por isso a mensagem de "Façadocker push…" usa o count (trocar peloemptyderegistry_repos, como nos outros pares lista/count, a faria aparecer quando a busca só não achou nada, não quando o registry está genuinamente vazio), e ofallbackdistingue "registry vazio" de "busca sem resultado".
- Containers: o container nasce de um
- CSS compartilhado das colunas de ação.
align-xsó afeta como um container de linha/coluna posiciona seus filhos; não alinha o texto de um elemento dentro da própria caixa (isso étext-align). Como a classe.col_acté reaproveitada no texto do cabeçalho e no container dos botões, semtext-aligno rótulo "AÇÃO" ficava colado à esquerda enquanto os botões (align-x: end) ficavam colados à direita..col_act_2é a linha de repositório do Registry (2 ícones: ver tags/remover, com tooltip),.col_act_4a de job em Schedules (4 controles: rodar/logs/pausar-ativar/remover) e.col_act_5a mesma com um controle a mais (editar). - Ingress. Os estados vazios das listas usam o
fallback. As portas TCP de host são exposição direta de porta, fora do proxy HTTP por domínio: um serviço pode ter as duas, só uma, ou nenhuma. - Settings → Git. OAuth: o Luau não abre o navegador; guarda a URL em
gp_oauth_urle o built-inopen:<chave>do glacier abre no navegador padrão. - Settings → Infra as Code. O manifesto é um
.zipcom exatamenterustploy.yml(projetos/serviços; variáveis de ambiente sempre como${VAR}) +rustploy.vars.toml(os valores reais, aninhados por escopo). Exportar/importar usam os diálogos nativos do SO, e o import rejeita um zip que tenha qualquer outra coisa. Veriac_export/iac_importemhandlers/settings.luaue a ponte do zip na seção 3. - Settings → Manutenção. Limpeza automática de recursos Docker sem uso (ver
docs/plano-limpeza-automatica-docker.md): as mesmas funções dos botões manuais da aba Docker, disparadas por agendamento em vez de clique.- O dia da semana eram sete
<TabButton>escritos à mão mais o handlerdc_weekdaysó para gravar a escolha. O<radiogroup>(glacier-ui 0.66) grava a chave sozinho, no update dele em Rust, então o handler foi embora junto. As opções vêm deweekdays, semeada emhandlers/connection.luaua partir defmt.WEEKDAYS_JSON; a aparência agora é de radio buttons redondos, não da fileira de pílulas. - O horário era um par de campos de texto livre ("HORA (0-23)" e "MINUTO (0-59)"), e a
faixa no próprio rótulo era a confissão de que nada impedia digitar 99: o handler fazia
tonumber(...) or 0e agendava a limpeza para a meia-noite sem avisar ninguém. O<timeedit>(glacier-ui 0.68) é um campo só, editado por seções (clique na hora, setas ▴▾ mexem nela), onde não dá para digitar e portanto não dá para digitar errado. A chavedc_timeé"HH:MM";fmt.hm_join/fmt.hm_splitfazem a ponte com o{hour, minute}que o daemon espera — o contrato HTTP não mudou.
- O dia da semana eram sete
6.5 O detalhe do serviço (views/service.gvb e views/service/)
O cabeçalho tem as ações (deploy, stop, reload), e as abas são General (fonte), Connection, Domains, Environment, Deployments, Logs e Advanced (mais Databases, Migrar e Healthcheck conforme o serviço).
- Status numa sub-linha. Status + timer ficam numa sub-linha abaixo do nome, nunca na
mesma linha do título. Antes o badge ficava inline e, quando o nome quebrava em duas linhas,
o "Running" era desenhado por cima da segunda: um elemento de texto do iced não corta ao
ultrapassar a caixa
fill, e o badge (posicionado logo após a caixa estreita) caía sobre o texto vazado. Descê-lo elimina qualquer disputa horizontal com o nome. - Timer de deploy. O "1s, 2s, 3s…" só aparece enquanto um deploy iniciado por este painel
(Deploy/Rebuild) está em andamento. É incrementado uma vez por segundo pela subscription de
poll (
sec_tick, emnet::poll_stream) e some quando o deploy termina; o desfecho e o tempo total aparecem então emsvc_action_msg, logo abaixo. - Ações responsivas. As ações compactas (ícones) ficam empilhadas abaixo do título e
só valem até 1240 px; ficam dentro da coluna do título de propósito: quando aperta, a
fileira de rótulos à direita some (
@media) eheader_titlespassa a ocupar a largura toda, então os ícones descem para cá — título e ações nunca dividem a mesma linha em janela estreita, e por isso nada sobrepõe o nome. A fileira de rótulos por extenso fica à direita do título, só acima de 1240 px; abaixo disso ela some (@mediaemapp.gss) e entram os ícones. O.svc_actions_full/.svc_actions_compacte o swap por@mediavivem emapp.gss, junto das outras regras responsivas. - O nome é o elemento flexível da linha do título (
.svc_title { width: fill }): ele absorve a folga e quebra dentro da coluna quando aperta. Sem isso, o botão de voltar, o nome e o badge são todosshrink(largura natural) e, quando a soma passa da largura da coluna, o iced não encolhe — transborda para a direita e o nome/badge invadem os botões de ação (o "Deploy/▶ por cima do nome"). - Abas com scroll horizontal. São 8 abas e, em janela estreita, as últimas
("Healthcheck"/"Logs"/"Advanced") saíam da tela sem como alcançá-las. A viewport
(
.tabs_scroll) éwidth: fill; a fileira interna (.tabs_main) éwidth: shrink(largura natural), então quando as abas não cabem ela transborda a viewport e a barra de rolagem aparece. - Painel lateral. Mostra o status. O log ao vivo saiu dali: era um
for-eachsempre renderizado que a reavaliação processava a cada tick; agora é janela. - CSS.
.btn_export/.btn_export_on/.env_editorvivem emapp.gss(compartilhados com o shell)..dep_actionssão 2 botões-ícone (ver build log / remover) com o rótulo no tooltip; o padding era7 15, dimensionado para "Build log ↗" e "Remover" por extenso, que estouravam a coluna em janela estreita. - General. Enquanto o fetch do detalhe (spec + listas conta/repo/branch do Gitea) não
completa, mostra-se um loading — os
Selectsó aparecem depois, já populados, em vez de piscarem vazios. Renomear é um form próprio, fora do formgeneral(form não aninha); o nome é só de exibição para Compose (stack e volumes ficam gravados), e em Application o alias de rede acompanha o nome no próximo deploy — o aviso abaixo do campo diz qual dos dois vale. A aba "Gitea/GitHub" vale para qualquer conta conectada em Settings → Git; otarget/actionseguemgiteapor ser a chave de estado (prov_tab), não o nome do provedor. Oplaceholderdo editor Compose é uml"""…"""(glacier-ui 0.119+): um texto em que a quebra de linha é conteúdo."…"não interpreta\ne só aceitaria a quebra literal, com as linhas de continuação coladas na margem;"""dobra as linhas em espaços. Ol"""(de linhas, não de raw) mantém as quebras, tira o recuo comum e continua interpolando@nome; o mesmo vale para o placeholder do compose emnew_job_window.gvb. - Environment. Valem as regras de
scrollablee do editor.envde 6.1. Os comentários (# …) do.envsão arrastáveis como as variáveis (ao soltar, reancoram na seguinte), mas sem delete; a classe da linha muda parakv_row_dragno item agarrado ({e.__dragging}vem do glacier-ui durante o arrasto). - Deployments. A URL do webhook e os botões ficam em linhas separadas: a URL é longa
(~100 caracteres) e, com
width: fill, empurraria os botões para fora do painel. Ela é exibida truncada (svc_webhook_url_short), porque inteira estoura o card em janelas estreitas; o valor completo vai no tooltip e no clipboard. As ações da linha são ícone + tooltip (ver 6.1), e o build log abre em janela isolada. - Domains. Lista de rotas HTTP (domínio → porta de container, TLS por rota), mais o form de adição e a porta TCP crua do host.
- Advanced. O teto de 20 réplicas é arbitrário mas deliberado: um número acima disso num
single-node é quase sempre engano de digitação, e o widget não deixa mais chegar lá. O piso de
1 substitui o
if r < 1 then r = 1doadv_save, que fica como rede redundante. A fila de pré-deploy é editável com efeito imediato (como Domains, não passa pelo Save do form acima): cada item roda em ordem antes do deploy, e a primeira falha para a fila inteira. - Healthcheck. O expected status era um
<input>de texto livre com uma linha de erro abaixo,<text if="{erro_f_hc_status}" …>. Só queerro_f_hc_statusnunca foi escrita por ninguém (nem Luau nem Rust): a validação que ela anunciava não existia, e o handler engolia lixo em silêncio (tonumber(...) or 200). Quem valida agora é o próprio widget, que satura em mínimo/máximo; o mesmo vale para os outros cinco campos numéricos da tela, e as linhaserro_f_hc_*saíram junto. A faixa dos quatro números do health check sai do que o daemon aceita: intervalo/timeout/start em segundos (1 h de teto é folgado para qualquer check razoável) e retries na faixa do Docker. - Logs. A aba é só o launcher: os logs de runtime vivem numa janela isolada (motor leve), não inline, porque renderizar o stream aqui reavaliaria a árvore inteira da janela principal por linha e a travaria. A condição de visibilidade vai na chamada (ver 6.1).
6.6 As janelas auxiliares
Cada uma é um motor Glacier isolado e abre só a própria tela. O tema e o app.gss são
declarações globais do app (app.gvb), que cada janela carrega. Todas são borderless
(decorations = false, como a principal): a titlebar é custom e as ações window:*
(drag/minimize/close) são tratadas no runtime contra o id da janela; as de diálogo não têm
maximize.
O que elas têm em comum. O app principal as abre com open_window e semeia a conexão
(api_url/api_token) e o que já tem à mão via open_window({ data = … }). Nada é buscado ali
dentro: um fetch rodado no init() é descartado pelo motor (só ações despachadas chegam ao
executor), a mesma razão de new_service_window.luau. A screen é só conteúdo — o tamanho vai
no open_window{ size = … }.
- Logs (
log_window.gvb). Janela de logs ao vivo (runtime ou build), aberta porhandlers/services.luau(open_logs_window/open_build_logs_window). É genérica: o script decide o tipo pelos dados semeados —api_url/api_token,lw_title,lw_stream_url(endpoint SSE dedicado) elw_seed(histórico inicial); verscripts/log_window.luau. O motivo da janela está em 6.1. Não temtitleno cabeçalho de propósito: o título é dinâmico ("Logs — nginx", "Build — abc123", "Job — …") e quem o sabe é quem a abre, noopen_window{ title = … }; o tamanho (900×560) vai noopen_window{ size = … }. O corpo é um<textarea readonly>(text_editordo iced): texto selecionável e copiável (arrastar + Ctrl+C), mas não editável (readonlyignora digitação, apagar e colar; seleção e scroll seguem funcionando). Cada linha nova é appendada no fim viaappend_textarea(o motor insere sem recriar o buffer, preservando o scroll). O botão ↓ rola até o fundo (textarea_end:) sem precisar arrastar a roda. Otextareado glacier-ui desenha a própria barra de rolagem (arraste o thumb, clique na trilha) e rola também por roda, teclado e pelos botões ↑/↓. - Novo job (
new_job_window.gvb). Aberta porhandlers/jobs.luau(open_new_job_window), que semeia a conexão + os catálogos de projetos/serviços já buscados pela janela principal. Passos: escolher projeto → escolher serviço gatilho (rede + env vars de base) → formulário (nome, compose,main_service, recorrência). Ao concluir emitebroadcast("job_created")e fecha.- Passo 2: o serviço gatilho é opcional — só traz env vars de base; a rede Docker já vem do projeto escolhido no passo 1.
- Passo 3: o formulário também é o único passo em modo edição (
open_edit_job_window, sem passos 1/2: projeto e serviço gatilho não são editáveis viaJobUpdate). - Aba Git: o mesmo picker conta→repo→branch da aba Gitea do serviço; o repo escolhido
precisa já ter um
docker-compose.ymlno caminho informado — o job clona e roda esse arquivo a cada execução, em vez do colado acima. - Env vars: as próprias do job têm a maior precedência na resolução (por cima de projeto +
serviço gatilho); formato
.envcolado, igual ao editor de projeto/serviço, parseado emnjob_create. - Recorrência diária: os pares HORA/MINUTO de texto livre viraram um
<timeedit>só (chavenjob_time,"HH:MM") — a mesma troca e o mesmo motivo da limpeza automática (ver 6.4).
- Novo projeto (
new_project_form.gvb). Aberta porhandlers/projects.luau(open_new_project_window). Ao concluir, o script (scripts/new_project_window.luau) emitebroadcast("project_created")e chamaclose_window(). A validação é declarada no<form>(ver 6.1). - Novo token do registry (
new_registry_token_window.gvb). Aberta porhandlers/registry.luau(registry_open_token_window), que semeia a conexão + o host do registry. Fluxo: formulário (nome + escopo pull/push) →RegistryTokenCreate→ o segredo aparece em texto uma vez, com botão copiar. A janela não fecha sozinha (diferente da de job), porque o usuário precisa copiar antes de perder o valor. Obroadcast("registry_token_created")dispara na hora da criação (não só ao fechar), para a lista da janela principal atualizar mesmo se o usuário nunca clicar em "Fechar". - Novo serviço (
new_service_window.gvb+new_service.gvb). A janela é aberta porhandlers/wizard.luau(open_new_service_window), que semeia a conexão + o projeto-alvo, e reaproveita o corpo do wizard (new_service.gvb, importado como<NewServiceWizard/>); a lógica vem dehandlers/wizard.luau, carregada pelo script de entrada. Ao criar, o wizard emitebroadcast("service_created")e chamaclose_window(). O wizard espelha o fluxo do antigo remote-client (Application / Database / Compose / Template): tipo → formulário por tipo; o passo corrente vive em{ns_step}e os campos são chaves de contextons_*(os handlersns_*). O status do wizard mostra um spinner enquanto o RPC de criação está no ar ({ns_busy}), e o texto acompanha{ns_msg}("criando…" / "erro: …").
6.7 Os componentes
Os componentes de views/components/ são pequenos e sem estado próprio; o que mudam vem por
props.
badge— variante da célula de estado (mesmo ponto + rótulo, com o espaçamento e o estilo de crachá,badge_lbl). Usada no card de serviço e no cabeçalho/aside do detalhe do serviço.loading_row— linha "Carregando dados…" com spinner. A condição de exibição fica no chamador (if cond="{flag}" equals="true" { LoadingRow }), não aqui dentro: um componente instanciado direto (fora defor-each) tem a prop resolvida uma vez e não re-avalia quando o flag muda — umifinterno à prop ficava preso em"true"e o spinner nunca sumia. Oifinline do chamador é re-avaliado ao vivo (mesmo padrão doifque esconde/mostra a grid).notesobrescreve o texto padrão quando fornecido (prop ausente → vazio) e é a única prop, opcional; odefault = ""reproduz exatamente o comportamento antigo, só que agora declarado. O indicador é o<spinner>do motor (glacier-ui 0.66+): um anel girando de verdade, não o glifo⟳estático de antes; o diâmetro sai dowidth/heightdo nó (osize:do GSS é corpo de fonte e não vale aqui) e a cor cai nocolorda classe.nav_item— item de navegação da sidebar: ícone + rótulo. Fica "ligado" (pílula azul) quando a view atual ({view}) casa comtargetviaone_of(glacier-ui 0.57.8), nãoequals:targetpode ser mais de uma view separada por espaço (ex.:"projects project_services service"), para o item continuar aceso nas sub-telas dele; umtargetde uma view só funciona igual (one_ofcom um token é comparação de igualdade). Antes da 0.57.8 isso não dava para fazer sem inventar gramática de expressão, e o item "Projects" apagava assim que se entrava num projeto. Histórico: era um botão-texto puro (Buttoncom o rótulo emtext), mas até a 0.57.5 o botão não suportava filhos, então não dava para pôr ícone + texto lado a lado; tinha virado umarowcomon_press(o mesmo mecanismo de clique dos handles de resize/drag — qualquer nó comon_press/cursor vira ummouse_area), hack removido na 0.57.6 (<Button>com filhos: um filho vira o conteúdo direto, mais de um vira umaRowimplícita respeitandospacing/align-ydo próprio nó). Otooltip="{label}"(glacier-ui 0.37.5) mostra o rótulo completo ao pairar o mouse; o wrap acontece depois domouse_areaemwidget.rs, então o hover funciona na linha inteira, não só no ícone. A propviewnão entra emprops: ela vem do contexto global, não de quem instancia o item, e declarar só o que é prop é o que mantém a leitura do contexto funcionando. Não há@mediade colapso: a sidebar virou<drawer>(ver 6.3); em janela estreita a gaveta fecha pelo ☰ e, aberta, tem sempre 264 px e o rótulo cabe.picker_row— linha de escolha do wizard "Novo serviço": título + subtítulo à esquerda e um botão de ação à direita, usada nos passos de tipo, de banco e de template.project_cardeservice_card— são templates fragment com dois nós de topo (o slot vazio e o card): o glacier-ui (0.4.12+) embrulha múltiplas raízes numFragmente aplica os filhos no pai, então o parif/elsevira dois irmãos do grid, sem nó wrapper. Todas as props são obrigatórias, inclusive no modofiller(o card vazio que completa a fileira): quem chama passa o conjunto inteiro e o próprio template decide o que renderizar pelo{filler}. A descrição é sempre renderizada (mesmo vazia) para reservar a linha, mantendo os cards da fileira mais próximos em altura mesmo quando só um tem descrição. Oservice_cardé o análogo doproject_cardna aba "Serviços" de um projeto, e o crachá de estado reaproveita orp-badge. As regras.card_ctr_id/.card_ctr_extra(cor--text-3) ficaram desativadas.stat_card— tile de KPI do cabeçalho (STATUS/UPTIME/SERVICES/CPU/…). Comaccent = "1"o número usa o realce verde (stat_num_g); semaccent, o neutro (stat_num). A prop ausente vira string vazia, então!= "1"→ neutro; por issoaccenté opcional comdefault = "".state_cell— a célula de estado das tabelas: o ponto colorido "●" + o rótulo, ambos na mesma cor. Usada em Deployments, Docker (containers/images/volumes/networks) e na lista de deployments do serviço. A cor vem da camada GSS:kindé um token semântico (ok/bad/info/warn/muted) e a classestate_<kind>define a cor (ver.state_*emapp.gss), de modo que a paleta fica centralizada no estilo. A largura vem de.col_state(app.gss), junto com a coluna ESTADO dothead— inclusive os ajustes de@media(170/130/110); antes era 170 fixo aqui e a célula desalinhava do cabeçalho em janela estreita.tab_button— botão de aba genérico: "ligado" (tab_on) quando o valor atual da aba ({current}, passado pelo chamador, ex.:"{docker_tab}") é igual aotargetdesta aba; caso contrário neutro (tab). Oequalsé interpolado, então compara os dois valores dinâmicos.template_row— linha do catálogo de templates de aplicação: logo à esquerda (vetor ou raster conformelogo_kind), nome + descrição e o botão "Escolher" à direita. Os logos vêm deassets/blueprint-logos/<id>/<arquivo>, pelo{logo}data-driven do catálogo do daemon (ver a seção 2). A caixa do logo é quadrada, fixa e centralizada: os logos têm proporções diferentes (quadrado, largo, estreito), então reserva-se sempre o mesmo espaço e centraliza-se o logo dentro dele, o que mantém a coluna de texto alinhada. A origem da imagem vem no atributosrc(interpolado do{logo}): o parser lê o source de<svg>/<image>só de atributo, nunca do conteúdo entre as tags. A descrição só é renderizada quando existe — uma linha vazia empurraria o nome para cima e o desalinharia do logo (align-y: centerda linha).