rustploy-gui 0.1.53

Rustploy — desktop client (glacier-ui) for the Rustploy PaaS daemon
--!strict
-- format/jobs.luau — formata Job/JobSummary/JobRun (tarefas one-shot via
-- docker-compose) pra exibição: tela global "Schedules" (sidebar) e aba
-- "Jobs" do detalhe do projeto. Recorrência é estruturada (Interval/Daily/
-- Weekly, ou nil = só manual) — resumida em texto curto pra tabela.

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

local M = {}

local WEEKDAYS = { "seg", "ter", "qua", "qui", "sex", "sáb", "dom" }

-- Resumo textual de uma `Recurrence?` (serde externally-tagged: `nil` = só
-- manual; `{IntervalHours=6}` / `{Daily={hour=,minute=}}` / `{Weekly={...}}`).
function M.recurrence_label(recurrence: any): string
    if recurrence == nil then
        return i18n.tr("manual")
    end
    if recurrence.IntervalHours ~= nil then
        return i18n.tr("a cada ") .. tostring(recurrence.IntervalHours) .. "h"
    end
    if recurrence.Daily ~= nil then
        return string.format(
            i18n.tr("diariamente às %02d:%02d"),
            recurrence.Daily.hour,
            recurrence.Daily.minute
        )
    end
    if recurrence.Weekly ~= nil then
        local weekday_name = i18n.tr(WEEKDAYS[(recurrence.Weekly.weekday % 7) + 1] or "?")
        return string.format(
            i18n.tr("semanalmente %s às %02d:%02d"),
            weekday_name,
            recurrence.Weekly.hour,
            recurrence.Weekly.minute
        )
    end
    return "—"
end

-- Rótulo/cor (mesmo esquema de StateCell) da última execução de um job.
-- `run == nil` (nunca rodou) / `run.success == nil` (rodando agora) /
-- `true`/`false` (ok/falhou).
function M.run_status(run: any): (string, string)
    if run == nil then
        return i18n.tr("nunca rodou"), "muted"
    end
    if run.success == nil then
        return i18n.tr("rodando…"), "warn"
    end
    if run.success then
        return "ok", "ok"
    end
    return i18n.tr("falhou"), "bad"
end

-- Aquecimento de um serviço com build (`ServiceWarmStatus`); `nil` = sem
-- build, sem selo. Mesmas cores do job.
function M.service_warm_status(warm: any): (string, string)
    if warm == nil then
        return "", "muted"
    end
    return M.warm_status(warm)
end

-- Rótulo/cor (mesmo esquema de StateCell) do aquecimento de um job. `warm` é o
-- `JobWarmStatus` do daemon (fonte única da verdade): aqui só se formata,
-- nunca se deduz. `nil` (daemon antigo, sem o campo) conta como frio.
function M.warm_status(warm: any): (string, string)
    if warm ~= nil and warm.warming then
        return i18n.tr("aquecendo…"), "warn"
    end
    if warm ~= nil and warm.warm then
        return i18n.tr("quente"), "ok"
    end
    return i18n.tr("frio"), "muted"
end

-- Tela global "Schedules": uma linha por job, de todos os projetos.
-- `inflight` (opcional): `State.jobs_inflight` — job_id → true entre o click
-- em "Rodar agora" e a confirmação do servidor (ver handlers/jobs.luau::
-- job_run_now). Sem isso, `running` só reflete o snapshot já confirmado
-- pelo daemon, deixando o botão clicável durante todo o round-trip da RPC.
function M.summaries(
    job_summaries: { any }?,
    inflight: { [string]: boolean }?
): (string, number)
    local column_budgets = Util.column_budgets()
    local rows = {}
    for _, job_summary in ipairs(job_summaries or {}) do
        local job = job_summary.job
        local status_label, status_kind = M.run_status(job_summary.last_run)
        local warm = job_summary.warm
        local warm_label, warm_kind = M.warm_status(warm)
        table.insert(rows, {
            id = job.id,
            name = Util.ellipsis(job.name, column_budgets.svc),
            name_full = job.name,
            project = job_summary.project_name,
            trigger_service = job_summary.trigger_service_name,
            -- trigger_service_name é nil quando o job é autônomo (sem
            -- serviço gatilho) — mostra só o projeto nesse caso.
            owner = job_summary.trigger_service_name and Util.ellipsis(
                tostring(job_summary.project_name)
                    .. " / "
                    .. tostring(job_summary.trigger_service_name),
                column_budgets.proj
            ) or Util.ellipsis(
                tostring(job_summary.project_name),
                column_budgets.proj
            ),
            recurrence = M.recurrence_label(job.recurrence),
            enabled = job.enabled,
            enabled_label = job.enabled and "Pausar" or "Ativar",
            last_run_label = status_label,
            last_run_kind = status_kind,
            -- `running`: usado pra desativar/trocar o botão "Rodar agora"
            -- enquanto o job_run em voo não termina. `status_kind == "warn"`
            -- é o estado CONFIRMADO pelo servidor; `inflight[job.id]` cobre o
            -- intervalo entre o click e essa confirmação chegar.
            running = status_kind == "warn"
                or (inflight ~= nil and inflight[job.id] == true),
            last_run_id = job_summary.last_run and job_summary.last_run.id
                or "",
            next_run_at = job.next_run_at and Time.date_day_month_hour_minute(
                job.next_run_at
            ) or "—",
            is_warm = warm ~= nil and warm.warm == true,
            is_warming = warm ~= nil and warm.warming == true,
            warm_label = warm_label,
            warm_kind = warm_kind,
            warm_reason = warm ~= nil and warm.reason or "",
            warmed_at_label = warm ~= nil
                    and warm.warmed_at
                    and Time.date_day_month_hour_minute(warm.warmed_at)
                or "",
        })
    end
    return Util.encode_array(rows), #rows
end

-- Fila de checks de pré-deploy do serviço (aba Advanced), na ORDEM
-- configurada, com o nome resolvido a partir dos Jobs do projeto — mesmo
-- idioma de retrocompat do `ServiceSpec::pre_deploy_checks` no lado Rust
-- (`Util.pre_deploy_checks`, que cai no `pre_deploy_job_id` legado quando a
-- fila nova está vazia). Um job apagado por fora (Schedules) mas ainda
-- referenciado aparece com rótulo "(job removido)" em vez de sumir — assim
-- dá pra removê-lo da fila pela própria UI.
function M.pre_deploy_queue(spec: any, jobs: { any }?): string
    local by_id = {}
    for _, job in ipairs(jobs or {}) do
        by_id[job.id] = job.name
    end
    local rows = {}
    for position, job_id in ipairs(Util.pre_deploy_checks(spec)) do
        table.insert(rows, {
            id = job_id,
            name = by_id[job_id] or (i18n.tr("(job removido: ") .. job_id .. ")"),
            index = tostring(position),
        })
    end
    return Util.encode_array(rows)
end

-- Jobs do projeto que AINDA NÃO estão na fila — opções do `<select>` de
-- "adicionar à fila" (evita duplicar o mesmo job na sequência).
function M.pre_deploy_available(spec: any, jobs: { any }?): string
    local in_queue = {}
    for _, job_id in ipairs(Util.pre_deploy_checks(spec)) do
        in_queue[job_id] = true
    end
    local rows = {}
    for _, job in ipairs(jobs or {}) do
        if not in_queue[job.id] then
            table.insert(rows, { id = job.id, name = job.name })
        end
    end
    return Util.encode_array(rows)
end

-- Histórico de execuções (`Command::JobRunHistory`) — usado pra listar runs e
-- abrir "Ver logs" (log_window apontando pro `job_run_id`).
function M.runs(runs: { any }?): string
    local rows = {}
    for _, run in ipairs(runs or {}) do
        local label, kind = M.run_status(run)
        table.insert(rows, {
            id = run.id,
            started_at = Time.date_day_month_hour_minute_second(
                run.started_at
            ),
            finished_at = run.finished_at
                    and Time.date_day_month_hour_minute_second(run.finished_at)
                or "—",
            exit_code = run.exit_code ~= nil and tostring(run.exit_code)
                or "—",
            status_label = label,
            status_kind = kind,
        })
    end
    return Util.encode_array(rows)
end

return M