--!strict
-- fmt/dashboard.luau — builders de lista do dashboard (deployments/projects/
-- services/docker/ingress/monitoring/deploy engine), mesma forma JSON que
-- view.rs produzia. Iterado pelos ForEach dos templates via a fachada fmt.luau.
local Types = require("types")
type ServicePair = Types.ServicePair
type MetricsMap = Types.MetricsMap
type GridCard = Types.GridCard
local Time = require("time")
local Util = require("util")
local M = {}
-- Nº de colunas da grade de cards (services/projects). glacier não reestrutura
-- Row↔Column via @media, então o número de colunas tem que ser decidido aqui
-- (recalculado a cada snapshot, ~2s). O que importa é a largura que SOBRA para a
-- grade, não a da janela: a sidebar-gaveta (264) come espaço enquanto está aberta
-- (`ctx.menu`, o ☰ da topbar) e o `.content` tem padding lateral — contar só a
-- janela dava 3 colunas de ~235px numa janela de 1100px, e o card era esmagado
-- (o `name:`/`id:` do container quebrava em várias linhas). Cada card precisa de
-- CARD_MIN px; cabem os que couberem, entre 1 e 3.
local CARD_MIN = 350
local SIDEBAR = 264
local function grid_cols(): number
local w = viewport().width
local menu = (ctx :: any).menu
local sidebar = if menu == "true" or menu == true then SIDEBAR else 0
-- Mesmos breakpoints do app.gss: padding lateral do `.content` e vão entre cards.
local pad, gap = 64, 16
if w < 560 then
pad, gap = 20, 10
elseif w < 900 then
pad, gap = 36, 10
end
local disponivel = w - sidebar - pad
local cols = math.floor((disponivel + gap) / (CARD_MIN + gap))
return math.max(1, math.min(3, cols))
end
-- Um card VAZIO que preenche a fileira do grid. Os campos vão como string
-- vazia, não `nil`: em Lua um campo `nil` não existe, e o card recebe o item
-- inteiro por `spread` — um campo ausente é uma prop obrigatória faltando, o
-- que o motor recusa. Como um filler serve às DUAS grades (serviços e
-- projetos), ele carrega a união dos dois contratos.
local FILLER: GridCard = {
filler = "1",
id = "",
name = "",
project = "",
-- service_card.gvb
port = "",
status_label = "",
status_color = "",
cpu = "",
mem = "",
container_name = "",
container_id = "",
container_extra = "",
-- project_card.gvb
description = "",
service_count = "",
running_count = "",
can_delete = "",
}
-- Deployments (aba Deployments). `list` = Vec<DeploymentSummary>.
-- Nomes truncados por Util.col_budgets (o filtro `matches` roda antes, sobre o
-- valor completo — a busca continua casando com o que foi cortado).
function M.deployments(list: {any}?, term: string?): string
local B = Util.col_budgets()
local rows = {}
for _, s in ipairs(list or {}) do
if Util.matches(term, { s.service_name, s.project_name }) then
local d = s.deployment
local label, _ = Util.state_label_color(d.state)
table.insert(rows, {
service = Util.ellipsis(s.service_name, B.svc),
project = Util.ellipsis(s.project_name, B.proj),
state_label = label,
state_kind = Util.state_kind(d.state),
duration = Time.fmt_duration(d),
start = Time.date_dm_hms(d.started_at),
})
end
end
return Util.enc_array(rows)
end
-- Escolhe o container "primário" de um serviço p/ exibir no card: o live, senão
-- o primeiro em execução, senão o primeiro da lista. Devolve (nome, id_curto,
-- extra) onde extra é "+N" quando há mais de um container.
local function primary_container(svc: Types.Service): (string, string, string)
local list = svc.containers or {}
if #list == 0 then return "—", "", "" end
local chosen = list[1]
for _, c in ipairs(list) do
if svc.live_container_id and c.id == svc.live_container_id then chosen = c; break end
if c.state == "running" and chosen.state ~= "running" then chosen = c end
end
local extra = #list > 1 and string.format("+%d", #list - 1) or ""
return chosen.name or "—", string.sub(chosen.id or "", 1, 12), extra
end
-- Card de serviço (com CPU/mem mesclados de `metrics_by_id[svc.id]`).
local function service_card(svc: Types.Service, proj: string, metrics: MetricsMap?)
local status_label, status_color = Util.status_label_color(svc.status)
local cpu, mem = "—", "—"
local m = metrics and metrics[svc.id]
if m then
cpu = string.format("%.1f%%", m.cpu_percent or 0)
mem = Time.fmt_bytes(m.mem_used_bytes)
end
local cname, cid, cextra = primary_container(svc)
return {
filler = "0",
id = svc.id,
name = svc.spec.name,
project = proj,
port = tostring(svc.spec.port),
status_label = status_label,
status_color = status_color,
cpu = cpu,
mem = mem,
container_name = cname,
container_id = cid,
container_extra = cextra,
}
end
-- Array plano de cards de serviço (para seleção/contagem).
function M.services(pairs_: {ServicePair}, metrics: MetricsMap?, term: string?): string
local rows = {}
for _, p in ipairs(pairs_) do
if Util.matches(term, { p.svc.spec.name, p.proj }) then
table.insert(rows, service_card(p.svc, p.proj, metrics))
end
end
return Util.enc_array(rows)
end
-- Cards de serviço em linhas de N colunas (grid_cols), com filler (glacier
-- não tem grid).
function M.service_rows(pairs_: {ServicePair}, metrics: MetricsMap?, term: string?): string
local filtered: {GridCard} = {}
for _, p in ipairs(pairs_) do
if Util.matches(term, { p.svc.spec.name, p.proj }) then
table.insert(filtered, service_card(p.svc, p.proj, metrics))
end
end
local cols = grid_cols()
local rows = {}
local i = 1
while i <= #filtered do
local cards = {}
for j = i, math.min(i + cols - 1, #filtered) do
table.insert(cards, filtered[j])
end
while #cards < cols do table.insert(cards, FILLER) end
table.insert(rows, { cards = cards })
i = i + cols
end
return Util.enc_array(rows)
end
-- Card de projeto, agregando contagem de serviços de `pairs_`.
local function project_card(p: any, pairs_: {ServicePair})
local count, running = 0, 0
for _, e in ipairs(pairs_) do
if e.svc.spec.project_id == p.id then
count = count + 1
if e.svc.status == "Running" then running = running + 1 end
end
end
return {
filler = "0",
id = p.id,
name = p.name,
description = p.description or "",
service_count = tostring(count),
running_count = tostring(running),
can_delete = (count == 0) and "1" or "0",
}
end
function M.projects(list: {any}?, pairs_: {ServicePair}, term: string?): string
local rows = {}
for _, p in ipairs(list or {}) do
if Util.matches(term, { p.name, p.description or "" }) then
table.insert(rows, project_card(p, pairs_))
end
end
return Util.enc_array(rows)
end
function M.project_rows(list: {any}?, pairs_: {ServicePair}, term: string?): string
local filtered: {GridCard} = {}
for _, p in ipairs(list or {}) do
if Util.matches(term, { p.name, p.description or "" }) then
table.insert(filtered, project_card(p, pairs_))
end
end
local cols = grid_cols()
local rows = {}
local i = 1
while i <= #filtered do
local cards = {}
for j = i, math.min(i + cols - 1, #filtered) do
table.insert(cards, filtered[j])
end
while #cards < cols do table.insert(cards, FILLER) end
table.insert(rows, { cards = cards })
i = i + cols
end
return Util.enc_array(rows)
end
-- Linhas Docker (containers gerenciados) derivadas dos serviços, ordenadas.
function M.docker_rows(pairs_: {ServicePair}, term: string?): string
local sorted: {ServicePair} = {}
for _, p in ipairs(pairs_) do table.insert(sorted, p) end
table.sort(sorted, function(a: ServicePair, b: ServicePair)
return a.svc.spec.name < b.svc.spec.name
end)
local rows = {}
for _, p in ipairs(sorted) do
if Util.matches(term, { p.svc.spec.name, p.proj }) then
local label, _ = Util.status_label_color(p.svc.status)
local cid = "—"
if p.svc.live_container_id and p.svc.live_container_id ~= "" then
cid = string.sub(p.svc.live_container_id :: string, 1, 12)
end
table.insert(rows, {
name = p.svc.spec.name,
project = p.proj,
image = Util.source_summary(p.svc.spec.source),
container = cid,
status_label = label,
status_kind = Util.status_kind(p.svc.status),
})
end
end
return Util.enc_array(rows)
end
function M.docker_images(list: {any}?, term: string?): string
local B = Util.col_budgets()
local sorted = {}
for _, img in ipairs(list or {}) do table.insert(sorted, img) end
table.sort(sorted, function(a: any, b: any)
return table.concat(a.tags or {}, ",") < table.concat(b.tags or {}, ",")
end)
local rows = {}
for _, img in ipairs(sorted) do
local tagstr = table.concat(img.tags or {}, " ")
if Util.matches(term, { tagstr, img.project or "", img.service or "" }) then
local in_use = (img.containers or 0) > 0
-- Trunca por caractere (não por palavra): uma tag sem espaços mais
-- longa que a coluna estoura e sobrepõe as vizinhas (o Row não clipa
-- filhos) — mesmo caso do sha256 em docker_containers.
local tags = (#(img.tags or {}) == 0) and "<none>"
or Util.ellipsis(table.concat(img.tags, ", "), B.svc)
local id = img.id or ""
id = string.gsub(id, "^sha256:", "")
table.insert(rows, {
id = string.sub(id, 1, 12),
id_full = id, -- sem "sha256:" (o `:` quebraria a ação nome:sufixo)
tags = tags,
size = Time.fmt_bytes(img.size_bytes),
created = Time.date_dm_hm(img.created),
project = img.project or "—",
service = img.service or "—",
-- "PROJETO / SERVIÇO" já combinado e truncado pra col_proj (o
-- template exibe {i.owner}; project/service ficam pra ações).
owner = Util.ellipsis((img.project or "—") .. " / " .. (img.service or "—"), B.proj),
in_use_label = in_use and "EM USO" or "SEM USO",
in_use_kind = Util.in_use_kind(in_use),
})
end
end
return Util.enc_array(rows)
end
function M.docker_volumes(list: {any}?, term: string?): string
local B = Util.col_budgets()
local sorted = {}
for _, v in ipairs(list or {}) do table.insert(sorted, v) end
table.sort(sorted, function(a: any, b: any) return a.name < b.name end)
local rows = {}
for _, v in ipairs(sorted) do
if Util.matches(term, { v.name, v.driver }) then
table.insert(rows, {
-- Nome truncado só pra exibição; a ação de remover usa v.name
-- completo via `name_full`.
name = Util.ellipsis(v.name, B.svc),
name_full = v.name,
driver = v.driver,
mountpoint = v.mountpoint,
size = (v.size_bytes and v.size_bytes >= 0) and Time.fmt_bytes(v.size_bytes) or "—",
in_use_label = v.in_use and "EM USO" or "SEM USO",
in_use_kind = Util.in_use_kind(v.in_use),
})
end
end
return Util.enc_array(rows)
end
function M.docker_networks(list: {any}?, term: string?): string
local B = Util.col_budgets()
local sorted = {}
for _, n in ipairs(list or {}) do table.insert(sorted, n) end
table.sort(sorted, function(a: any, b: any) return a.name < b.name end)
local rows = {}
for _, n in ipairs(sorted) do
if Util.matches(term, { n.name, n.project or "" }) then
table.insert(rows, {
name = Util.ellipsis(n.name, B.svc),
id_full = n.id or "",
driver = n.driver,
scope = n.scope,
project = Util.ellipsis(n.project or "—", B.proj),
containers = tostring(n.container_count or 0),
in_use_label = n.in_use and "EM USO" or "SEM USO",
in_use_kind = Util.in_use_kind(n.in_use),
})
end
end
return Util.enc_array(rows)
end
-- Containers do host (DockerContainerInfo): todos, rodando + parados. `can_remove`
-- = "1" só nos parados (o Docker recusa remover um rodando sem force). `owner`
-- mostra projeto/serviço quando atribuído (label rustploy.service_id), senão
-- "rustploy" (managed) ou "—".
function M.docker_containers(list: {any}?, term: string?): string
local B = Util.col_budgets()
local sorted = {}
for _, c in ipairs(list or {}) do table.insert(sorted, c) end
table.sort(sorted, function(a: any, b: any) return (a.name or "") < (b.name or "") end)
local rows = {}
for _, c in ipairs(sorted) do
if Util.matches(term, { c.name, c.image, c.state, c.project or "", c.service or "" }) then
local label, kind = Util.container_state(c.state)
local owner: string
if c.project and c.service then
owner = tostring(c.project) .. " / " .. tostring(c.service)
elseif c.managed then
owner = "rustploy"
else
owner = "—"
end
-- A referência da imagem vira `sha256:<digest>` quando a tag original
-- some (imagem removida/reconstruída) — sem espaços, não quebra linha
-- e, sem truncar, estoura a coluna e sobrepõe as vizinhas (o Row não
-- clipa filhos). Tira o prefixo redundante (igual a docker_images) e
-- trunca por caractere (Util.ellipsis, não por palavra).
local image = c.image or "—"
image = string.gsub(image, "^sha256:", "")
table.insert(rows, {
id_full = c.id or "",
id = string.sub(c.id or "", 1, 12),
name = Util.ellipsis(c.name or "—", B.svc),
image = Util.ellipsis(image, B.img),
owner = Util.ellipsis(owner, B.state),
state_label = label,
state_kind = kind,
can_remove = Util.container_stopped(c.state) and "1" or "0",
})
end
end
return Util.enc_array(rows)
end
-- Ingress: uma linha por rota de domínio (não filtrado pela busca). Retorna (json, count).
function M.ingress(pairs_: {ServicePair}): (string, number)
local B = Util.col_budgets()
local rows = {}
for _, p in ipairs(pairs_) do
local spec = p.svc.spec
local port = spec.port
for _, r in ipairs(Util.domain_routes(spec)) do
if r.domain and string.gsub(r.domain, "%s", "") ~= "" then
local scheme = r.tls and "https" or "http"
local cport = r.port or port
table.insert(rows, {
domain = Util.ellipsis(r.domain, B.svc),
url = scheme .. "://" .. r.domain,
service = Util.ellipsis(spec.name, B.proj),
project = p.proj,
upstream = ":" .. tostring(cport),
tls = r.tls and "TLS" or "—",
})
end
end
end
return Util.enc_array(rows), #rows
end
-- Portas TCP de host: uma linha por serviço com `host_port` configurado —
-- exposição direta de porta TCP no host, independente das rotas HTTP de
-- domínio acima (um serviço pode ter as duas, só uma, ou nenhuma). Não
-- filtrado pela busca (mesma convenção de `M.ingress`).
function M.host_ports(pairs_: {ServicePair}): (string, number)
local B = Util.col_budgets()
local rows = {}
for _, p in ipairs(pairs_) do
local spec = p.svc.spec
if spec.host_port ~= nil then
table.insert(rows, {
service = Util.ellipsis(spec.name, B.svc),
project = Util.ellipsis(p.proj, B.proj),
host_port = tostring(spec.host_port),
host_port_n = spec.host_port,
container_port = tostring(spec.port),
})
end
end
table.sort(rows, function(a: any, b: any) return a.host_port_n < b.host_port_n end)
return Util.enc_array(rows), #rows
end
-- Monitoring: uma linha por serviço COM métricas vivas.
function M.monitoring(pairs_: {ServicePair}, metrics: MetricsMap?): string
local B = Util.col_budgets()
local rows = {}
for _, p in ipairs(pairs_) do
local m = metrics and metrics[p.svc.id]
if m then
table.insert(rows, {
name = Util.ellipsis(p.svc.spec.name, B.svc),
project = Util.ellipsis(p.proj, B.proj),
cpu = string.format("%.1f%%", m.cpu_percent or 0),
mem = Time.fmt_bytes(m.mem_used_bytes),
rx = Time.fmt_bytes(m.net_rx_bytes),
tx = Time.fmt_bytes(m.net_tx_bytes),
})
end
end
return Util.enc_array(rows)
end
local DEPLOY_STEPS = {
{ label = "Fila", states = { "Pending" } },
{ label = "Checks", states = { "PreDeployCheck" } },
{ label = "Deps", states = { "ResolvingDeps" } },
{ label = "Obter", states = { "PullingImage", "CloningRepo", "BuildingImage", "ComposingUp" } },
{ label = "Staging", states = { "Staging" } },
{ label = "Health", states = { "HealthcheckPolling" } },
{ label = "Swap", states = { "SwappingIn", "Draining", "Promoting" } },
{ label = "Live", states = { "Live" } },
}
local STEP_ICON = { done = "✓ ", current = "● ", failed = "✕ ", pending = "○ " }
local function step_of(state: any): number
for i, st in ipairs(DEPLOY_STEPS) do
if table.find(st.states, state) then
return i
end
end
return 0
end
local function deploy_stepper(info: any): {any}
local state = info.state
local cur = step_of(state)
local failed = 0
if state == "Live" or state == "Stopped" or state == "Pruning" then
cur = #DEPLOY_STEPS + 1
elseif state == "Failed" or state == "RollingBack" then
local log = info.states or {}
for i = #log, 1, -1 do
local idx = step_of(log[i].from)
if idx > 0 then
failed = idx
break
end
end
cur = failed
end
local out = {}
for i, st in ipairs(DEPLOY_STEPS) do
local status = "pending"
if i == failed then
status = "failed"
elseif i < cur then
status = "done"
elseif i == cur then
status = "current"
end
table.insert(out, { text = STEP_ICON[status] .. st.label, status = status })
end
return out
end
local function iso_epoch(iso: string?): number
local y, mo, d, h, mi, s = string.match(iso or "", "^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)")
if not y then
return 0
end
return os.time({ year = tonumber(y), month = tonumber(mo), day = tonumber(d), hour = tonumber(h), min = tonumber(mi), sec = tonumber(s) } :: any)
end
function M.eng_detail(info: any): string
local log = info.states or {}
local rows = {}
for i, t in ipairs(log) do
local last = i == #log
local dur = "—"
if i < #log then
dur = Time.fmt_secs(math.max(0, iso_epoch(log[i + 1].at) - iso_epoch(t.at)))
elseif not (t.to == "Failed" or t.to == "Live" or t.to == "Stopped") then
dur = Time.fmt_secs(info.current_state_secs)
end
local kind = "ok"
if t.to == "Failed" then
kind = "bad"
elseif last and info.state == t.to then
kind = "info"
end
table.insert(rows, { label = t.to, kind = kind, dur = dur, msg = Util.short_reason(t.message) })
end
return Util.enc_array(rows)
end
function M.eng_detail_steps(info: any): string
return Util.enc_array(deploy_stepper(info))
end
-- Deploy Engine: "Executando agora".
function M.eng_active(active: {any}?): string
local B = Util.col_budgets()
local rows = {}
for _, info in ipairs(active or {}) do
local label, _ = Util.state_label_color(info.state)
table.insert(rows, {
service = Util.ellipsis(info.service_name, B.svc),
project = Util.ellipsis(info.project_name, B.proj),
state_label = label,
state_kind = Util.state_kind(info.state),
steps = deploy_stepper(info),
total = Time.fmt_secs(info.elapsed_secs),
phase = Time.fmt_secs(info.current_state_secs),
service_id = info.service_id,
})
end
return Util.enc_array(rows)
end
-- Deploy Engine: "Na fila" (deploys esperando; o primeiro é o próximo a rodar).
-- Inclui deployment_id (para cancelar/promover) e uma posição 1-based.
function M.eng_queued(queued: {any}?): string
local B = Util.col_budgets()
local rows = {}
for i, info in ipairs(queued or {}) do
table.insert(rows, {
deployment_id = info.deployment_id,
pos = tostring(i),
service = Util.ellipsis(info.service_name, B.svc),
project = Util.ellipsis(info.project_name, B.proj),
})
end
return Util.enc_array(rows)
end
-- Deploy Engine: "Histórico 24h".
function M.eng_recent(recent: {any}?): string
local B = Util.col_budgets()
local rows = {}
for _, info in ipairs(recent or {}) do
local label, _ = Util.state_label_color(info.state)
local icon = "○"
if info.state == "Live" then icon = "✓"
elseif info.state == "Failed" then icon = "✕" end
table.insert(rows, {
icon = icon,
service = Util.ellipsis(info.service_name, B.svc),
project = Util.ellipsis(info.project_name, B.proj),
state_label = label,
state_kind = Util.state_kind(info.state),
duration = Time.fmt_secs(info.elapsed_secs),
start = Time.time_hms(info.started_at),
})
end
return Util.enc_array(rows)
end
return M