--!strict
-- format/util.luau — busca, codificação de array e mapas de estado (paleta =
-- view.rs). Usado pelos demais submódulos format/*.luau.
local Types = require("types")
type ServicePair = Types.ServicePair
type ServiceSpec = Types.ServiceSpec
type DomainRoute = Types.DomainRoute
local M = {}
-- ── Busca ────────────────────────────────────────────────────────────────
-- Termo vazio sempre casa (a busca é filtro, não requisito). `term` já deve
-- vir em minúsculas.
function M.matches(term: string?, fields: { any }): boolean
if not term or term == "" then
return true
end
for _, field in ipairs(fields) do
if
type(field) == "string"
and string.find(string.lower(field), term, 1, true)
then
return true
end
end
return false
end
-- ── Texto ────────────────────────────────────────────────────────────────
-- Trunca `s` para no máximo `max` caracteres, anexando "…" quando corta. Usado
-- em valores longos (ex.: URL de conexão) que senão empurram os controles ao
-- lado (o botão "Copiar") para fora da área visível. O valor completo continua
-- disponível noutra chave de contexto para o clipboard.
function M.ellipsis(text: string?, max: number): string
local normalized_text = text or ""
if
utf8.len(normalized_text)
and (utf8.len(normalized_text) :: number) > max
then
local cut_offset = utf8.offset(normalized_text, max + 1)
if cut_offset then
return string.sub(normalized_text, 1, cut_offset - 1) .. "…"
end
end
return normalized_text
end
-- Motivo de falha vindo do daemon (`DeployStateChanged.message`,
-- `ServiceStatus::Error(...)`) reduzido ao que cabe numa notificação/toast:
-- primeira linha não-vazia, aparada e truncada. O texto original é multi-linha
-- com frequência (a saída de erro do `docker build`) — o log completo continua
-- disponível na tela de log do serviço, que agora recebe a causa inteira.
-- Devolve "" quando não há motivo, para o chamador cair no texto genérico.
function M.short_reason(message: any, max: number?): string
if type(message) ~= "string" then
return ""
end
for line in string.gmatch(message, "[^\r\n]+") do
local trimmed_line = string.match(line, "^%s*(.-)%s*$") or ""
if trimmed_line ~= "" then
return M.ellipsis(trimmed_line, max or 140)
end
end
return ""
end
-- Orçamento de caracteres por coluna de tabela conforme a largura da janela,
-- espelhando as larguras de .col_svc/.col_proj/.col_state/.col_img no app.gss
-- (breakpoints 900/560; fonte mono size 13 ≈ 9 px/char, com 1 char de folga
-- pro "…" do ellipsis). O Row do glacier não clipa filhos: um valor sem
-- espaços mais largo que a coluna sobrepõe as vizinhas — então todo texto de
-- dado (nome de serviço/volume, tag, domínio…) passa por M.ellipsis com estes
-- limites. Recalculado a cada snapshot (~2s), como grid_cols em
-- dashboard.luau.
export type ColBudgets = {
svc: number,
proj: number,
state: number,
img: number,
}
function M.column_budgets(): ColBudgets
local viewport_width = viewport().width
if viewport_width < 560 then
return { svc = 10, proj = 7, state = 12, img = 15 }
elseif viewport_width < 900 then
return { svc = 15, proj = 11, state = 14, img = 26 }
end
return { svc = 24, proj = 18, state = 19, img = 41 }
end
-- Remove sequências de escape ANSI (cor/cursor/erase) das linhas de log. O
-- daemon repassa o stdout/stderr cru dos containers, cheio de CSI (`ESC[..m`
-- etc.); sem limpar, além de aparecerem como lixo (`[2m[32m…`) na UI, cada
-- linha vira uma string longa com bytes de controle que o text-shaping do iced
-- processa a um custo patológico — reconstruído a CADA re-render (por evento
-- de log), o que congela a janela. Também tira CR e NUL soltos.
function M.strip_ansi(text: string?): string
local cleaned_text = text or ""
-- CSI: ESC [ params byte-final(letra) — cobre SGR (cor), erase (K/J),
-- cursor.
cleaned_text = string.gsub(cleaned_text, "\27%[[%d;?]*%a", "")
-- OSC: ESC ] … BEL. E qualquer ESC + 1 byte remanescente (ESC c, ESC =,
-- …).
cleaned_text = string.gsub(cleaned_text, "\27%][^\7]*\7", "")
cleaned_text = string.gsub(cleaned_text, "\27.", "")
cleaned_text = string.gsub(cleaned_text, "[\r\0]", "")
return cleaned_text
end
-- ── Codificação de arrays ────────────────────────────────────────────────
-- CUIDADO: uma tabela Luau vazia vira `{}` (objeto) no json.encode, não `[]`.
-- Os templates iteram arrays, então forçamos "[]" quando vazio.
function M.encode_array(items: { any }): string
if #items == 0 then
return "[]"
end
return json.encode(items)
end
-- ── Mapas de estado (paleta = view.rs) ───────────────────────────────────
-- ServiceStatus vem como string ("Running") ou tabela ({Error="..."}).
function M.status_label_color(status: any): (string, string)
if type(status) == "table" then
if status.Error ~= nil then
return i18n.tr("Error"), "#F85149"
end
end
if status == "Running" then
return i18n.tr("Running"), "#3FB950"
elseif status == "Deploying" then
return i18n.tr("Deploying"), "#58A6FF"
elseif status == "Queued" then
return i18n.tr("Na fila"), "#8B949E"
elseif status == "Degraded" then
return i18n.tr("Degraded"), "#D29922"
elseif status == "Stopping" then
return i18n.tr("Stopping"), "#8B949E"
elseif status == "Stopped" then
return i18n.tr("Stopped"), "#8B949E"
end
return i18n.tr("Error"), "#F85149"
end
function M.status_kind(status: any): string
if type(status) == "table" and status.Error ~= nil then
return "bad"
end
if status == "Running" then
return "ok"
elseif status == "Deploying" then
return "info"
elseif status == "Queued" then
return "muted"
elseif status == "Degraded" then
return "warn"
elseif status == "Stopping" or status == "Stopped" then
return "muted"
end
return "bad"
end
-- DeployState vem como string ("Live", "Failed", "BuildingImage", ...).
function M.state_label_color(state: string?): (string, string)
if state == "Live" then
return i18n.tr("LIVE"), "#3FB950"
elseif state == "Stopped" then
return i18n.tr("STOPPED"), "#8B949E"
elseif state == "Failed" then
return i18n.tr("FAILED"), "#F85149"
elseif state == "PreDeployCheck" then
return i18n.tr("PRÉ-DEPLOY CHECK"), "#58A6FF"
end
return i18n.tr("BUILDING"), "#58A6FF"
end
function M.state_kind(state: string?): string
if state == "Live" then
return "ok"
elseif state == "Stopped" then
return "muted"
elseif state == "Failed" then
return "bad"
end
return "info"
end
function M.in_use_kind(in_use: boolean): string
if in_use then
return "ok"
end
return "muted"
end
-- Estado bruto de um container Docker ("running"/"exited"/… ) → (rótulo,
-- kind). Só "running"/"restarting"/"paused" contam como "ativo" (não
-- removíveis sem force); o resto é parado e pode ser removido individualmente.
function M.container_state(state: string?): (string, string)
local lowered_state = string.lower(state or "")
if lowered_state == "running" then
return "running", "ok"
elseif lowered_state == "restarting" then
return "restarting", "info"
elseif lowered_state == "paused" then
return "paused", "warn"
elseif lowered_state == "created" then
return "created", "info"
elseif lowered_state == "dead" then
return "dead", "bad"
end
-- exited, removing, ou desconhecido
return (lowered_state ~= "" and lowered_state or "exited"), "muted"
end
-- `true` quando o container está PARADO (removível sem force). Espelha o
-- daemon (Docker recusa `rm` de um container rodando/reiniciando sem `-f`).
function M.container_stopped(state: string?): boolean
local lowered_state = string.lower(state or "")
return lowered_state ~= "running"
and lowered_state ~= "restarting"
and lowered_state ~= "paused"
end
-- ServiceSource → texto de "imagem/origem" (source_summary().1).
function M.source_summary(source: any): string
if type(source) ~= "table" then
return "—"
end
if source.Registry then
return source.Registry.image or "—"
elseif source.Git then
local git_source = source.Git
return (git_source.url or "") .. " @ " .. (git_source.branch or "")
elseif source.Archive then
return source.Archive.original_filename
or source.Archive.archive_id
or i18n.tr("zip enviado")
elseif source.Compose then
return "docker-compose"
end
return "—"
end
-- domain_routes(): a lista `domains` nova, ou o legado `domain`/`tls_enabled`.
function M.domain_routes(spec: ServiceSpec): { DomainRoute }
local routes: { DomainRoute } = {}
if spec.domains and #spec.domains > 0 then
for _, domain in ipairs(spec.domains) do
table.insert(routes, {
domain = domain.domain,
port = domain.port,
tls = domain.tls == true,
})
end
elseif spec.domain and spec.domain ~= "" then
table.insert(routes, {
domain = spec.domain :: string,
port = nil,
tls = spec.tls_enabled == true,
})
end
return routes
end
-- Fila efetiva de pré-deploy check: `pre_deploy_job_ids` quando não vazia,
-- senão cai no `pre_deploy_job_id` legado (retrocompat) — mesmo idioma de
-- `ServiceSpec::pre_deploy_checks` no lado Rust.
function M.pre_deploy_checks(spec: any): { string }
if spec.pre_deploy_job_ids and #spec.pre_deploy_job_ids > 0 then
return spec.pre_deploy_job_ids
elseif spec.pre_deploy_job_id and spec.pre_deploy_job_id ~= "" then
return { spec.pre_deploy_job_id }
end
return {}
end
-- `services` do snapshot é uma lista de { project_name, service }.
-- Normaliza para pares { svc = Service, proj = "nome" }.
function M.service_pair_list(services: { any }?): { ServicePair }
local out: { ServicePair } = {}
for _, service in ipairs(services or {}) do
table.insert(
out,
{ svc = service.service, proj = service.project_name or "" }
)
end
return out
end
return M