rustploy-gui 0.1.26

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