rustploy-gui 0.1.9

Rustploy — desktop client (glacier-ui) for the Rustploy PaaS daemon
--!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 ≈ 7,8 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 = 12, proj = 9, state = 12, img = 15 }
    elseif viewport_width < 900 then
        return { svc = 17, proj = 13, state = 14, img = 26 }
    end
    return { svc = 28, proj = 21, 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 "Error", "#F85149" end
    end
    if status == "Running" then return "Running", "#3FB950"
    elseif status == "Deploying" then return "Deploying", "#58A6FF"
    elseif status == "Queued" then return "Na fila", "#8B949E"
    elseif status == "Degraded" then return "Degraded", "#D29922"
    elseif status == "Stopping" then return "Stopping", "#8B949E"
    elseif status == "Stopped" then return "Stopped", "#8B949E"
    end
    return "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 "LIVE", "#3FB950"
    elseif state == "Stopped" then return "STOPPED", "#8B949E"
    elseif state == "Failed" then return "FAILED", "#F85149"
    elseif state == "PreDeployCheck" then return "PRÉ-DEPLOY CHECK", "#58A6FF"
    end
    return "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 "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