# 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 é](#1-o-que-é-este-crate-e-o-que-ele-não-é)
2. [Onde estão os assets](#2-onde-estão-os-assets)
3. [A ponte do zip do Infra as Code](#3-a-ponte-do-zip-do-infra-as-code)
4. [A API de agente (saiu do app)](#4-a-api-de-agente-saiu-do-app)
5. [Os testes e o que cada um trava](#5-os-testes-e-o-que-cada-um-trava)
6. [Os templates `.gv` por dentro](#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 `screen`s: 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](#3-a-ponte-do-zip-do-infra-as-code));
2. a **fonte de assets embutida** em release (ver [seção 2](#2-onde-estão-os-assets)).
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.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:
| `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](#23-o-buildrs)).
- `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 `require`d — 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.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](#4-a-api-de-agente-saiu-do-app)).
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.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.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](#65-o-detalhe-do-serviço)), 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](#67-os-componentes)).
**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.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 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.gv`),
disparada por `open_new_service_window`.
- **Projects.** Criar projeto abre uma janela à parte (`new_project_form.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.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.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.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](#3-a-ponte-do-zip-do-infra-as-code).
- **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.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.gv`.
- **Environment.** Valem as regras de `scrollable` e do editor `.env` de [6.1](#61-regras-que-valem-para-todos-os-templates).
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](#61-regras-que-valem-para-todos-os-templates)), 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.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](#61-regras-que-valem-para-todos-os-templates). 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.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.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.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.gv` + `new_service.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.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.gv` e em `home/schedules.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](#2-onde-estão-os-assets)). 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).