rustploy-gui 0.1.43

Rustploy — desktop client (glacier-ui) for the Rustploy PaaS daemon
--!strict
-- format/state_git.luau — a sincronização do estado com o git (Fases 6 e 7 de
-- docs/plano-backup-estado-em-git.md) vista pela GUI: o aviso do topo, os textos
-- da tela Settings → Sync, as listas "removidos no git" e "valores em claro", o
-- selo "mudança pendente de deploy" e o dono do container órfão. Tudo vem do
-- snapshot (`snapshot.state_git`, `snapshot.infra_audit`); nada aqui fala com a
-- rede. A webui faz o mesmo em `webui/app.js` (`stateGit*`) — mude os dois juntos.

local Time = require("time")
local Util = require("util")

local M = {}

type Status = {
    mode: string?,
    remote: string?,
    branch: string?,
    provider_id: string?,
    git_available: boolean?,
    recovery_configured: boolean?,
    last_commit: { sha: string, at: string, subject: string }?,
    last_push_ok: boolean?,
    last_push_at: string?,
    last_fetch_at: string?,
    last_error: string?,
    blocked_by_plaintext: number?,
    ahead: number?,
    behind: number?,
    paused_reason: string?,
    incoming_rejected: { sha: string, reason: string }?,
    removed_in_git: {
        {
            id: string,
            kind: string,
            name: string,
            project: string,
            sha: string,
            at: string,
        }
    }?,
    pending_deploy: { string }?,
}

local function short(sha: string?): string
    return string.sub(sha or "", 1, 7)
end

local function time_label(iso_timestamp: string?): string
    if iso_timestamp == nil or iso_timestamp == "" then
        return "—"
    end
    return Time.date_day_month_hour_minute_second(iso_timestamp)
end
M.time_label = time_label

-- Texto do aviso do topo, ou "" quando está tudo bem (nada a mostrar).
function M.alert_message(status: Status?): string
    if status == nil or status.mode == nil or status.mode == "off" then
        return ""
    end
    if status.paused_reason ~= nil and status.paused_reason ~= "" then
        return i18n.tr(
            "Sincronização com o git pausada: o histórico divergiu. Abra Settings → Sync."
        )
    end
    if status.incoming_rejected ~= nil then
        return string.format(
            i18n.tr(
                "Um commit do repositório foi recusado (%s). Abra Settings → Sync."
            ),
            short(status.incoming_rejected.sha)
        )
    end
    if (status.blocked_by_plaintext or 0) > 0 then
        return string.format(
            i18n.tr(
                "Commits suspensos: %d valor(es) em claro. Abra Settings → Sync."
            ),
            status.blocked_by_plaintext or 0
        )
    end
    return ""
end

-- Conjunto de ids de serviço com "mudança pendente de deploy".
function M.pending_ids(status: Status?): { [string]: boolean }
    local ids = {}
    if status ~= nil then
        for _, service_id in ipairs(status.pending_deploy or {}) do
            ids[service_id] = true
        end
    end
    return ids
end

-- Linhas da lista "Removidos no git" (JSON): quem sumiu e quando.
function M.removed_rows(status: Status?): string
    local rows = {}
    if status ~= nil then
        for _, removal in ipairs(status.removed_in_git or {}) do
            local detail: string = removal.kind
            if removal.project ~= nil and removal.project ~= "" then
                detail = detail .. " · " .. removal.project
            end
            detail = detail
                .. " · "
                .. short(removal.sha)
                .. " · "
                .. time_label(removal.at)
            table.insert(
                rows,
                { id = removal.id, name = removal.name, detail = detail }
            )
        end
    end
    return Util.encode_array(rows)
end

-- Linhas da auditoria de valores em claro (JSON). Nunca traz valores. `index`
-- (1-based) identifica o item para os handlers de "não é segredo".
function M.audit_rows(findings: { any }?): string
    local rows = {}
    for index, finding in ipairs(findings or {}) do
        local kind: string = finding.definite and i18n.tr("certeza")
            or i18n.tr("suspeita")
        local scope: string = tostring(finding.scope or "")
        local project: string = tostring(finding.project or "")
        local detail: string = kind
        if scope ~= "" then
            detail = scope .. " · " .. kind
        end
        if project ~= "" then
            detail = project .. " · " .. detail
        end
        table.insert(rows, {
            index = tostring(index),
            what = finding.what or "",
            detail = detail,
            can_allow = (not finding.definite and not finding.allowed) and "1"
                or "0",
            can_block_again = (not finding.definite and finding.allowed)
                    and "1"
                or "0",
        })
    end
    return Util.encode_array(rows)
end

-- Dono de um container na lista Runtime: o órfão ("removido no git") é dito
-- como tal; senão projeto/serviço, "rustploy" (gerido) ou "—".
function M.container_owner(container: any): string
    local removed_in_git = container.removed_in_git
    if removed_in_git ~= nil and removed_in_git ~= "" then
        return string.format(
            i18n.tr("removido no git (%s)"),
            tostring(removed_in_git)
        )
    end
    if container.project and container.service then
        return tostring(container.project)
            .. " / "
            .. tostring(container.service)
    end
    if container.managed then
        return "rustploy"
    end
    return "—"
end

-- Todos os valores de ctx que a tela precisa, calculados do status. Booleanos
-- viram "true"/"false" (o ctx do glacier só guarda strings). `plain` = sem
-- `Util`/`i18n` quando não há status ainda.
function M.context(status: Status?, findings: { any }?): { [string]: string }
    local s: Status = status or {}
    local mode = s.mode or "off"
    local last_commit = s.last_commit
    local push_label = "—"
    if s.last_push_at ~= nil and s.last_push_at ~= "" then
        push_label = (s.last_push_ok and i18n.tr("ok") or i18n.tr("falhou"))
            .. " · "
            .. time_label(s.last_push_at)
    end
    local flag = function(value: boolean): string
        return value and "true" or "false"
    end
    return {
        state_git_alert_message = M.alert_message(status),
        state_git_mode = mode,
        state_git_is_enabled = flag(mode ~= "off"),
        state_git_is_sync = flag(mode == "sync"),
        state_git_git_missing = flag(
            status ~= nil and s.git_available == false
        ),
        state_git_recovery_missing = flag(
            status ~= nil and s.recovery_configured == false
        ),
        state_git_last_commit_label = last_commit ~= nil and (short(
            last_commit.sha
        ) .. " · " .. last_commit.subject) or "—",
        state_git_last_push_label = push_label,
        state_git_last_fetch_label = time_label(s.last_fetch_at),
        state_git_ahead_behind_label = tostring(s.ahead or 0)
            .. " / "
            .. tostring(s.behind or 0),
        state_git_last_error = s.last_error or "",
        state_git_paused_reason = s.paused_reason or "",
        state_git_is_paused = flag(
            s.paused_reason ~= nil and s.paused_reason ~= ""
        ),
        state_git_rejected_label = s.incoming_rejected ~= nil and (i18n.tr(
            "Commit"
        ) .. " " .. string.sub(s.incoming_rejected.sha, 1, 12)) or "",
        state_git_rejected_reason = s.incoming_rejected ~= nil
                and s.incoming_rejected.reason
            or "",
        state_git_has_rejected = flag(s.incoming_rejected ~= nil),
        state_git_removed = M.removed_rows(status),
        state_git_removed_count = tostring(#(s.removed_in_git or {})),
        state_git_audit = M.audit_rows(findings),
        state_git_audit_count = tostring(#(findings or {})),
    }
end

return M