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/**/*.gv). 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 .gv, 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.gv) 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 (saiu do app)
- Os testes e o que cada um trava
- Os templates
.gvpor dentro
Documentos relacionados: docs/plano-chave-de-acesso-e-mcp.md (login por chave
e a API de agente no daemon) 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/**/*.gv) 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.gv, 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:
O .main_template("views/app.gv") 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:
- 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. - 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).
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.gv, assets/blueprint-logos/<id>/<logo>)
quanto de dentro dos próprios templates (import … from="views/service.gv",
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.gv (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 — .gv, 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. - 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 pacote dos templates, que não os carrega mais); o nome de cada um vem do catálogo do daemon (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.gv). 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 (saiu do app)
Até 2026-10-13 este crate subia um servidor HTTP local (src/agent/, porta
9800) que emprestava a sessão da janela a um agente de IA na mesma máquina. Ele
foi removido: o acesso de agente agora é servido pelo próprio daemon —
POST /api/login com a chave de acesso, o servidor MCP em POST /mcp e o
manual em GET /api/agent/manual (ver rustploy-daemon/crates/daemon/src/api/agent/
e docs/plano-chave-de-acesso-e-mcp.md no agregador). Um agente não depende
mais de a GUI estar aberta, nem de estar na mesma máquina.
O que sobrou aqui do assunto é a tela de login: um campo só, a chave de acesso,
decodificada em Luau (views/scripts/access_key.luau) para saber onde fazer o
POST /api/login.
5. Os testes e o que cada um trava
5.1 Testes da API de agente
Saíram junto com src/agent/ (ver seção 4).
Os equivalentes vivem no daemon, ao lado de api/agent/.
5.2 Os testes de format em Luau (tests/format_*.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("format/…") 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/format.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/tela/render.test.luau e tests/app_manifest.rs)
Onde cada coisa mora. O que antes era um único templates_render.rs se dividiu
conforme o que cada runner enxerga:
tests/tela/render.test.luau(glacier test, sem compilar testes Rust): as telas sobem, mostram o texto e o botão certos, escondem o que deve esconder, e o estilo resolvido na largura da janela. Usa a árvore avaliada (tela:render(),texts(),buttons(),find,set_viewport,style;assert_renders,assert_has_text,assert_has_button, glacier-ui 0.133). Cobre as visões do shell, as abas, as janelas auxiliares, os jobs agendados, a sidebar, as ações do serviço por largura, o item "Projects" aceso e a trava de ação em andamento. Uma view quebrada, mesmo uma que nenhum outro teste carrega, falha emtest_o_app_todo_carrega. O logout zerando a sessão é detests/handlers/connection.test.luau.tests/app_manifest.rs(cargo test --test app_manifest): o que o Luau não alcança — título e tamanho das janelas e as fontes (app(...)), a casca de cada.gv, a avaliação escopada, as regras do<form>de "Novo projeto", oopendo<Reveal>da gaveta e a construção dos widgets do iced de cada visão e janela. O Luau avalia o markup mas não desenha: um widget que o iced recusa só aparece aqui ou nomake run.
O resto desta seção descreve o que é conferido; os nomes de teste citados são os do arquivo antigo, hoje distribuídos como acima.
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.gv 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.gv,
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 new_job_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 new_project_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
new_project_show_validation_errors 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.gvimporta 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.gve 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.gvcomparava{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-item--activeé 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.gv), 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
.gvabre 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.gvque 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.gvnã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 oformat/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.
5b. Idiomas: locales/, i18n.tr e o porquê de cada escolha
A GUI fala inglês (padrão) e pt-BR. Onde mora o quê: locales/en.json e locales/pt-BR.json
são embutidos por include_str! em src/app/mod.rs (locale_json), então valem no binário de
release e no debug do mesmo jeito. views/scripts/handlers/language.luau restaura o idioma
gravado (storage, chave language) na subida da janela principal e troca ao vivo;
i18n.set_locale espalha a locale para todas as janelas abertas (cada janela é um motor isolado).
Duas famílias de chave. No markup, chave semântica ({t('docker_images.no_images')}): é o que
o tests/locales.rs confere nos dois idiomas. No Luau, o texto de origem é a chave
(i18n.tr("Remover tag?")): um literal de toast ou confirmação não ganha um nome inventado, e
sem entrada o texto aparece como foi escrito. Por que não chave semântica no Luau também:
são centenas de literais montados por concatenação ("Projeto \"" .. nome .. "\" criado"), e
um nome para cada pedaço seria ruído; cada pedaço é traduzido à parte, então a ordem das
palavras deve ser a mesma nos dois idiomas.
A armadilha: uma tabela de rótulos no topo de um módulo é avaliada uma vez, na carga, antes do
idioma ser restaurado. Guarde o texto de origem e chame i18n.tr no ponto de uso. O título de
janela (screen(title = "{t(…)}")) é resolvido a cada leitura (glacier-ui 0.147); os rótulos da
bandeja (tray) não são traduzidos (o menu nativo é montado uma vez na subida).
Os testes comparam o texto em inglês. Para um texto novo: ponha a chave nos dois .json
(ou o i18n.tr no Luau), rode glacier test e cargo test --test locales.
6. Os templates .gv 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 .gv, 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.gv 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("format") 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.
A rede (net/api.luau) é o cliente http() do glacier (glacier-ui 0.145+). O Client guarda
base_url, o Authorization e o teto de tempo num http(); rpc faz POST /api/rpc com o
Command já em JSON (um comando unitário é a string "DaemonStatus", que o cliente mandaria
sem aspas se o corpo fosse a tabela). O cliente nunca repete um POST sozinho — repetir um
ServiceDelete ou um deploy seria perigoso —, então só as leituras puras (a sonda
Api.probe, um GET /api/version) usam retries. Api.failure_text é a única tradução de
"falhou" para texto. O envio do zip usa body_file (o motor lê o arquivo do disco ao enviar:
sem base64 e sem o zip dentro do Lua) com on_progress (a barra da aba Zip) e um
abort_controller (o botão Cancelar).
Reconexão do stream (handlers/stream.luau). Quando o SSE cai com a sessão aberta, o app
tenta de novo 5 vezes (1, 2, 4, 8 e 16 s) antes de voltar ao login; o snapshot que o daemon manda
ao reconectar ressincroniza a tela. Um aviso de reinício (restart_notice, de "Atualizar
daemon") não reconecta.
"Atualizar app" não congela a janela. O download e o pkexec rodam numa thread
(app_update.install_start); o Luau acompanha por install_status a cada 250 ms (barra de
progresso) e pode cancelar o download (install_cancel). O download usa glacier_ui::net
(max_redirects(5), max_bytes, só https://github.com/ na origem), sem depender do curl.
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
(deployment_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 = @project_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.gv)
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 tinha overlay/z-stack quando a moldura foi escrita (hoje tem<popup>,<popover>e<dialog>, verjob_actionsem 6.7), 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.gv 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.gv.
- 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.gv), disparada poropen_new_service_window. - Projects. Criar projeto abre uma janela à parte (
new_project_form.gv, 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.gv). 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.gv). 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.gv 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 (deploy_engine_tab: fila, executando, histórico; handlerdeploy_engine_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 (format.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
git_provider_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. Vermanifest_export/manifest_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 handlerdocker_cleanup_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 deformat.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 chavedocker_cleanup_timeé"HH:MM";format.hour_minute_join/format.hour_minute_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.gv 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 emservice_action_message, 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 (provider_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.gv. - 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 (service_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 = 1doadvanced_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.gv), 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.gv). 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,log_window_title,log_window_stream_url(endpoint SSE dedicado) elog_window_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.gv). 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 emnew_job_create. - Recorrência diária: os pares HORA/MINUTO de texto livre viraram um
<timeedit>só (chavenew_job_time,"HH:MM") — a mesma troca e o mesmo motivo da limpeza automática (ver 6.4).
- Novo projeto (
new_project_form.gv). 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.gv). 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.gv+new_service.gv). 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.gv, 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{new_service_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 ({new_service_busy}), e o texto acompanha{new_service_message}("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.job_actions— o botão "⋯" de uma linha de job e o painel sobreposto com todas as ações dele (rodar ou cancelar, aquecer, ver logs, pausar ou ativar, editar, remover). A linha de job tinha seis botões e não cabia; agora ela mostra o estado de aquecimento e este botão. É um<popup>do glacier (um painel centrado sobre a janela, com Esc e clique fora fechando) dentro do componente, instanciado uma vez por linha emshell/project_jobs.gve emhome/schedules.gv. O estado de aberto/fechado fica numa chave por linha,job_actions_open_<job_id>: ovaluedo popup é interpolado com o laço (process_tplno eval), e fechar é zerar a chave (close_job_actionsemhandlers/jobs.luau, que zera todas porquejob_run_canceleopen_job_logs_windowsó recebem o id da execução). Descartados: uma janela do SO (open_window, como os outros diálogos: o pedido era um painel sobreposto) e o<dialog>(modal de verdade, mas só abre pela açãodialog:nome, e o Luau só abre os diálogos de corpo fixo, comoconfirm{}, então não há como levar o job da linha para dentro dele). O<popup>não é modal: a tela de trás continua clicável. Não existe componente chamadoPainel: é o apelido de<popover>no motor. O texto "quente/frio" vem pronto do daemon (JobWarmStatus,docs/plano-aquecer-job.md), viaformat.warm_status.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).