rustploy-gui 0.1.49

Rustploy — desktop client (glacier-ui) for the Rustploy PaaS daemon
rustploy-gui-0.1.49 is not a library.

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:

  1. O que é este crate e o que ele não é
  2. Onde estão os assets
  3. A ponte do zip do Infra as Code
  4. A API de agente (saiu do app)
  5. Os testes e o que cada um trava
  6. Os templates .gv por 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_instance e remember_geometry — instância única, geometria lembrada e o diretório de dados do storage do 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 por open_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:

  1. a extensão Luau do .zip (ver seção 3);
  2. a fonte de assets embutida em release (ver seção 2).

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:

  1. Extensão Luau. (Sem extensão de zip própria: o "Baixar infra" do Settings → Sincronização usa zip_write do motor.)
  2. 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 DiskAssets padrã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 .zip de 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 o chdir existem, 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::daemon e vive em app::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.component.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:

  1. $RUSTPLOY_UI_ASSETS — sobrescrita explícita;
  2. o diretório do próprio executável — layout portátil / .zip do Windows;
  3. o diretório corrente, se ele já contém os assets — o cargo run na raiz do workspace em desenvolvimento, sem chdir. Vem antes de SYSTEM_PREFIX de propósito: um .deb instalado em /usr/share/rustploy não pode esconder a árvore de trabalho durante um cargo run;
  4. SYSTEM_PREFIX (/usr/share/rustploy) — o layout do pacote Debian (ver a metadata deb no Cargo.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.rs as espelha em <id>/<arquivo> e filtra o docker-compose.yml/template.toml (ver 2.3).
  • route normaliza os separadores (\ vira /) e um eventual ./ inicial: as chaves do include_dir são sempre relativas e com /.
  • Como o embutido não muda sob o processo, EmbeddedAssets devolve None em modified (desliga o hot-reload) e responde false à 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 com cargo 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 sem chdir: o motor carrega o app inteiro só da árvore embutida (template, <link> de estilo/tema, <script src> Luau, a cadeia de require dos handlers) e renderiza. Se qualquer asset — inclusive um módulo required — faltasse, register_component/render falharia.

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.component.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áximo LOGO_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 crate image não cobre: continuam sendo logos válidos e caem no ramo de cópia, sem passar pelo resize. is_logo decide o que entra no staging; is_raster é o subconjunto que o image decodifica (as features habilitadas no Cargo.toml).
  • Degradação. stage_raster nunca derruba o build: se não consegue decodificar (ou a imagem já é pequena), copia os bytes originais. downscale_png devolve None nesses 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 o EmbeddedAssets o serve por essa chave. Re-codificar um raster para PNG mantendo, digamos, um nome .jpg é inofensivo — o iced/image decodifica 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 .zip com exatamente dois arquivos, rustploy.yml e rustploy.vars.toml, na raiz — sem diretório de staging: os dois são escritos direto no arquivo final (write_manifest_zip);
  • importar lê um .zip e exige exatamente um *.yml/*.yaml e 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_host e internal_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 teste renomear_servico_na_aba_general do 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 sem product a GUI põe http:// (a webui, hoje, não põe esquema nenhum — divergência anterior ao rename, não mexida aqui).
  • fmt_time existe desde que o arquivo deixou de fazer a aritmética de fuso à mão e passou a chamar o global date da 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 …Z e como …-03:00 tem 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; sem finished_at conta até agora — o valor varia, o formato não. O offset local é perguntado ao sistema com date +%z (a mesma fonte que o localtime do Luau consulta), para o teste não precisar de crate de data só para conferir uma subtração; hora_deslocada soma 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 em test_o_app_todo_carrega. O logout zerando a sessão é de tests/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", o open do <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 no make 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 seu open — "true" abre, vazio fecha; (2) o gatilho ☰ da topbar dispara drawer::toggle:menu, a ação que o builtin Drawer consome de qualquer lugar da tela; (3) abaixo de 900 px os rótulos dos NavItem não são forçados a hidden por 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ó Text filho; 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.gv importa 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 o app.gv e 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: o disconnect antigo limpava só quatro chaves à mão. Hoje ele apaga o ctx inteiro e deixa o init() 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.component.gv comparava {view} contra um target de uma view só (equals) e project_services/service não são "projects". A correção usa one_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 propriedade color: do GSS para o fundo (background: é ignorada em botões), então o campo que importa é o color de node.kind, não o node.background genérico (esse é para containers/rows). O on_click chega namespaceado pelo componente que o hospeda (namespace_action, em eval.rs do glacier-ui): mesmo com o valor vindo de uma prop (action="nav_projects" em shell.component.gv), o botão vive dentro do template do NavItem, 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 é da screen (acompanha a navegação); e o tamanho de cada janela filha vai na chamada open_window{ component = "…", size = "…" } dos handlers, porque a screen é 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 e application_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 .gv abre com a casca certa: app no manifesto, screen nas telas abertas em outra janela e component no 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 (um component nã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 .gv que ainda não foi escrito. comentarios_fora remove // 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 .gv nã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 o format/dashboard.luau não emitir vira MissingProp e 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 único FILLER compartilhado pelas duas grades, e em Lua um campo = nil simplesmente não existe — por isso ele carrega a união dos dois contratos como string vazia, e é isso que o teste tranca. Ele fixa data_loading = "false" porque o connection.luau semeia "true" no init e o <scrollable> da grade fica escondido atrás disso — sem a correção o for-each nunca roda e o teste passaria sem ter avaliado um card sequer. A fileira de teste é um card real mais um filler, exatamente a forma que M.project_rows produz 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+).

  • id nomeia o diretório de dados (~/.local/share/rustploy, onde moram o storage do 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, decorations são da janela principal. O ícone as filhas herdam; o tamanho de cada uma vai no open_window("tela", { size = … }) dos handlers.
  • Fonte e daemon. A fonte padrão vale para todas as janelas (declarada no próprio app(...)); o application_id do Linux casa a janela com o rustploy-gui.desktop. O MSAA x4 padrão do iced custa caro num fallback 100% por software (sem GPU compatível: o wgpu recusa 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 o antialiasing é 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>, ver job_actions em 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ções on_press/on_click window:* 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 — o init delas não roda na principal. Os tamanhos estão nos handlers.

6.3 O shell (views/shell.component.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.component.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 carrega drawer-panel + sidebar). A gaveta empurra o conteúdo (não cobre) e não desenha gatilho: o ☰ mora na topbar e drawer::toggle:menu funciona de qualquer lugar. O init() (connection.luau) semeia menu = "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 prefixo app: 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 builtin drawer. Sai drawer::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.screen.gv), disparada por open_new_service_window.
  • Projects. Criar projeto abre uma janela à parte (new_project_form.screen.gv, motor Glacier próprio); ao concluir, ela envia um broadcast("project_created") que o on_broadcast (handlers/projects.luau) recebe para atualizar a grade. Ver open_new_project_window.
  • Projeto aberto (project_services.component.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 usa virtualize = 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, ver PRIMITIVAS.md). O valor é a altura do card; o spacing: 10 da .grid o motor soma sozinho. Errar desalinha a barra de rolagem, não quebra.
  • Aba Variáveis (project_env.component.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 .env sã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.luau e docs/secrets.md.

6.4 As telas globais (views/home.component.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 atributo if=.
  • Deploy Engine. KPIs + deploys em andamento + histórico das últimas 24 h. Tudo vem do Command::DeployEngineStatus (ver os eng_*); 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 em handlers/deploy_queue.luau. São três abas (deploy_engine_tab: fila, executando, histórico; handler deploy_engine_tab em handlers/nav.luau), cada uma num arquivo de views/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_inventory no 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 recusa rm de um rodando sem force).
    • 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 VOLUME da 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_repos chega filtrado pela busca (format.registry_repos(snap.registry_repos, term) em stream.luau), enquanto o registry_repos_count é da lista crua; por isso a mensagem de "Faça docker push…" usa o count (trocar pelo empty de registry_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 o fallback distingue "registry vazio" de "busca sem resultado".
  • CSS compartilhado das colunas de ação. align_x só 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, sem text_align o 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_4 a de job em Schedules (4 controles: rodar/logs/pausar-ativar/remover) e .col_act_5 a 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_url e o built-in open:<chave> do glacier abre no navegador padrão.
  • Settings → Infra as Code. O manifesto é um .zip com exatamente rustploy.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. Ver manifest_export/manifest_import em handlers/settings.luau e 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 handler docker_cleanup_weekday só 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 de weekdays, semeada em handlers/connection.luau a partir de format.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 0 e 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 chave docker_cleanup_time é "HH:MM"; format.hour_minute_join/format.hour_minute_split fazem a ponte com o {hour, minute} que o daemon espera — o contrato HTTP não mudou.

6.5 O detalhe do serviço (views/service.component.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, em net::poll_stream) e some quando o deploy termina; o desfecho e o tempo total aparecem então em service_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) e header_titles passa 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 (@media em app.gss) e entram os ícones. O .svc_actions_full/.svc_actions_compact e o swap por @media vivem em app.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 todos shrink (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-each sempre renderizado que a reavaliação processava a cada tick; agora é janela.
  • CSS. .btn_export/.btn_export_on/.env_editor vivem em app.gss (compartilhados com o shell). .dep_actions são 2 botões-ícone (ver build log / remover) com o rótulo no tooltip; o padding era 7 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 Select só aparecem depois, já populados, em vez de piscarem vazios. Renomear é um form próprio, fora do form general (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; o target/action seguem gitea por ser a chave de estado (provider_tab), não o nome do provedor. O placeholder do editor Compose é um l"""…""" (glacier-ui 0.119+): um texto em que a quebra de linha é conteúdo. "…" não interpreta \n e só aceitaria a quebra literal, com as linhas de continuação coladas na margem; """ dobra as linhas em espaços. O l""" (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 em new_job_window.screen.gv.
  • Environment. Valem as regras de scrollable e do editor .env de 6.1. Os comentários (# …) do .env são arrastáveis como as variáveis (ao soltar, reancoram na seguinte), mas sem delete; a classe da linha muda para kv_row_drag no 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 = 1 do advanced_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ó que erro_f_hc_status nunca 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 linhas erro_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.screen.gv). Janela de logs ao vivo (runtime ou build), aberta por handlers/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) e log_window_seed (histórico inicial); ver scripts/log_window.luau. O motivo da janela está em 6.1. Não tem title no cabeçalho de propósito: o título é dinâmico ("Logs — nginx", "Build — abc123", "Job — …") e quem o sabe é quem a abre, no open_window{ title = … }; o tamanho (900×560) vai no open_window{ size = … }. O corpo é um <textarea readonly> (text_editor do iced): texto selecionável e copiável (arrastar + Ctrl+C), mas não editável (readonly ignora digitação, apagar e colar; seleção e scroll seguem funcionando). Cada linha nova é appendada no fim via append_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. O textarea do 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.screen.gv). Aberta por handlers/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 emite broadcast("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 via JobUpdate).
    • Aba Git: o mesmo picker conta→repo→branch da aba Gitea do serviço; o repo escolhido precisa já ter um docker-compose.yml no 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 .env colado, igual ao editor de projeto/serviço, parseado em new_job_create.
    • Recorrência diária: os pares HORA/MINUTO de texto livre viraram um <timeedit> só (chave new_job_time, "HH:MM") — a mesma troca e o mesmo motivo da limpeza automática (ver 6.4).
  • Novo projeto (new_project_form.screen.gv). Aberta por handlers/projects.luau (open_new_project_window). Ao concluir, o script (scripts/new_project_window.luau) emite broadcast("project_created") e chama close_window(). A validação é declarada no <form> (ver 6.1).
  • Novo token do registry (new_registry_token_window.screen.gv). Aberta por handlers/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. O broadcast("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.screen.gv + new_service.component.gv). A janela é aberta por handlers/wizard.luau (open_new_service_window), que semeia a conexão + o projeto-alvo, e reaproveita o corpo do wizard (new_service.component.gv, importado como <NewServiceWizard/>); a lógica vem de handlers/wizard.luau, carregada pelo script de entrada. Ao criar, o wizard emite broadcast("service_created") e chama close_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 contexto ns_* (os handlers ns_*). 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 de for-each) tem a prop resolvida uma vez e não re-avalia quando o flag muda — um if interno à prop ficava preso em "true" e o spinner nunca sumia. O if inline do chamador é re-avaliado ao vivo (mesmo padrão do if que esconde/mostra a grid). note sobrescreve o texto padrão quando fornecido (prop ausente → vazio) e é a única prop, opcional; o default = "" 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 do width/height do nó (o size: do GSS é corpo de fonte e não vale aqui) e a cor cai no color da classe.
  • nav_item — item de navegação da sidebar: ícone + rótulo. Fica "ligado" (pílula azul) quando a view atual ({view}) casa com target via one_of (glacier-ui 0.57.8), não equals: target pode ser mais de uma view separada por espaço (ex.: "projects project_services service"), para o item continuar aceso nas sub-telas dele; um target de uma view só funciona igual (one_of com 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 (Button com o rótulo em text), 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 uma row com on_press (o mesmo mecanismo de clique dos handles de resize/drag — qualquer nó com on_press/cursor vira um mouse_area), hack removido na 0.57.6 (<Button> com filhos: um filho vira o conteúdo direto, mais de um vira uma Row implícita respeitando spacing/align_y do próprio nó). O tooltip="{label}" (glacier-ui 0.37.5) mostra o rótulo completo ao pairar o mouse; o wrap acontece depois do mouse_area em widget.rs, então o hover funciona na linha inteira, não só no ícone. A prop view não entra em props: 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á @media de 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 em shell/project_jobs.component.gv e em home/schedules.component.gv. O estado de aberto/fechado fica numa chave por linha, job_actions_open_<job_id>: o value do popup é interpolado com o laço (process_tpl no eval), e fechar é zerar a chave (close_job_actions em handlers/jobs.luau, que zera todas porque job_run_cancel e open_job_logs_window só 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ção dialog:nome, e o Luau só abre os diálogos de corpo fixo, como confirm{}, 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 chamado Painel: é o apelido de <popover> no motor. O texto "quente/frio" vem pronto do daemon (JobWarmStatus, docs/plano-aquecer-job.md), via format.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_card e service_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 num Fragment e aplica os filhos no pai, então o par if/else vira dois irmãos do grid, sem nó wrapper. Todas as props são obrigatórias, inclusive no modo filler (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. O service_card é o análogo do project_card na aba "Serviços" de um projeto, e o crachá de estado reaproveita o rp-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/…). Com accent = "1" o número usa o realce verde (stat_num_g); sem accent, o neutro (stat_num). A prop ausente vira string vazia, então != "1" → neutro; por isso accent é opcional com default = "".
  • 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 classe state_<kind> define a cor (ver .state_* em app.gss), de modo que a paleta fica centralizada no estilo. A largura vem de .col_state (app.gss), junto com a coluna ESTADO do thead — 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 ao target desta aba; caso contrário neutro (tab). O equals é 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 conforme logo_kind), nome + descrição e o botão "Escolher" à direita. Os logos vêm de assets/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 atributo src (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: center da linha).