rustploy-gui 0.1.21

Rustploy — desktop client (glacier-ui) for the Rustploy PaaS daemon
--!strict
--!nolint FunctionUnused
-- handlers/wizard.luau — wizard "Novo serviço". A construção do ServiceSpec
-- (incl. compose de banco/broker e blueprints de template) vive no daemon: o
-- cliente só coleta os campos e delega via Command::WizardCatalog (catálogos)
-- + WizardCreate (montagem+criação).

local State = require("../state")
local Helpers = require("../helpers")

local NS_ALPHABET =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
math.randomseed(os.time())
local function token_urlsafe(length: number): string
    local characters = {}
    for index = 1, length do
        local alphabet_index = math.random(1, #NS_ALPHABET)
        characters[index] =
            string.sub(NS_ALPHABET, alphabet_index, alphabet_index)
    end
    return table.concat(characters)
end

local function new_service_is_broker(kind_id: string): boolean
    return kind_id == "kafka" or kind_id == "rabbitmq" or kind_id == "nats"
end

-- Botão "+ Novo serviço" (cabeçalho do projeto): abre o wizard numa JANELA à
-- parte (motor Glacier próprio). O catálogo (dbs/brokers/templates) é buscado
-- AQUI e passado via `data` — buscar no init da janela NÃO funciona: o `init`
-- roda no register do componente e o glacier descarta o `fetch` disparado por
-- ele (só efeitos/streams de AÇÕES despachadas rodam no executor). Este
-- handler é uma ação despachada, então o `fetch` do WizardCatalog resolve
-- normalmente e a janela abre depois, já com o catálogo. Ao criar, a janela
-- envia broadcast("service_created") e se fecha (ver new_service_create).
-- Substitui o antigo fluxo inline (view=new_service), removido do shell.gvb.
-- `start` opcional ("import"): o ícone de importar do card de serviço abre o
-- wizard direto no passo Importar (`open_new_service_window:import`).
function open_new_service_window(start: string?): ()
    local project_id = ctx.selected_project_id or ""
    local api = State.api
    if project_id == "" or not api then
        return
    end
    local databases_json, brokers, templates =
        json.array({}), json.array({}), json.array({})
    local wizard_catalog_ok, wizard_catalog_response =
        api:rpc { WizardCatalog = { search = "" } }
    if wizard_catalog_ok and wizard_catalog_response.WizardCatalog then
        databases_json = wizard_catalog_response.WizardCatalog.dbs
            or json.array({})
        brokers = wizard_catalog_response.WizardCatalog.brokers
            or json.array({})
        templates = wizard_catalog_response.WizardCatalog.templates
            or json.array({})
    end
    open_window {
        component = "new_service",
        size = "560 700",
        decorations = false,
        resizable = false,
        data = {
            api_url = ctx.api_url or "",
            api_token = ctx.api_token or "",
            selected_project_id = project_id,
            project_name = ctx.project_name or "",
            new_service_start = start or "",
            new_service_databases = databases_json,
            new_service_brokers = brokers,
            new_service_templates = templates,
        },
    }
end

-- "Cancelar": fecha a janela do wizard.
function new_service_cancel(): ()
    close_window()
end

-- "‹ Voltar": num passo interno volta à escolha de tipo; no passo inicial
-- (pick_type) fecha a janela.
function new_service_back(): ()
    local step = ctx.new_service_step
    if
        step == "pick_db"
        or step == "pick_broker"
        or step == "app_form"
        or step == "compose_form"
        or step == "pick_template"
        or step == "db_form"
        or step == "template_form"
        or step == "import_form"
    then
        ctx.new_service_step = "pick_type"
        ctx.new_service_message = ""
    else
        close_window()
    end
end

function new_service_kind(kind_choice: string): ()
    if kind_choice == "application" then
        ctx.new_service_step = "app_form"
    elseif kind_choice == "compose" then
        ctx.new_service_step = "compose_form"
    elseif kind_choice == "database" then
        ctx.new_service_step = "pick_db"
    elseif kind_choice == "broker" then
        ctx.new_service_step = "pick_broker"
    elseif kind_choice == "template" then
        ctx.new_service_step = "pick_template"
    elseif kind_choice == "import" then
        ctx.new_service_step = "import_form"
    end
end

-- Banco escolhido: pré-preenche o formulário (senhas geradas no cliente).
function new_service_database(database_kind: string): ()
    local json_decode_ok, database_options =
        Helpers.decode_list(ctx.new_service_databases)
    if not json_decode_ok or type(database_options) ~= "table" then
        return
    end
    for _, database_option in ipairs(database_options) do
        if database_option.id == database_kind then
            ctx.new_service_database_kind = database_kind
            ctx.new_service_database_label = database_option.label
            ctx.new_service_image = database_option.image
            ctx.new_service_database_name = database_option.has_db_name
                    and "app"
                or ""
            ctx.new_service_database_user = database_option.user or ""
            ctx.new_service_database_password = token_urlsafe(22)
            ctx.new_service_database_root_password = token_urlsafe(22)
            ctx.new_service_use_replica = "false"
            ctx.new_service_expose_external = "false"
            ctx.new_service_database_has_dbname = database_option.has_db_name
                    and "true"
                or "false"
            ctx.new_service_database_has_user = database_option.has_user
                    and "true"
                or "false"
            ctx.new_service_database_has_rootpw = database_option.has_rootpw
                    and "true"
                or "false"
            ctx.new_service_database_has_replica = database_option.has_replica
                    and "true"
                or "false"
            ctx.new_service_name = ""
            ctx.new_service_step = "db_form"
            return
        end
    end
end

-- Broker escolhido: compartilha o passo db_form (mesmas chaves ns_db_*).
function new_service_broker(broker_kind: string): ()
    local json_decode_ok, broker_options =
        Helpers.decode_list(ctx.new_service_brokers)
    if not json_decode_ok or type(broker_options) ~= "table" then
        return
    end
    for _, broker_option in ipairs(broker_options) do
        if broker_option.id == broker_kind then
            ctx.new_service_database_kind = broker_kind
            ctx.new_service_database_label = broker_option.label
            ctx.new_service_image = broker_option.image
            ctx.new_service_database_name = ""
            ctx.new_service_database_user = broker_option.user or ""
            ctx.new_service_database_password = token_urlsafe(22)
            ctx.new_service_database_root_password = ""
            ctx.new_service_use_replica = "false"
            ctx.new_service_expose_external = "false"
            ctx.new_service_database_has_dbname = "false"
            ctx.new_service_database_has_user = broker_option.has_user
                    and "true"
                or "false"
            ctx.new_service_database_has_rootpw = "false"
            ctx.new_service_database_has_replica = "false"
            ctx.new_service_name = ""
            ctx.new_service_step = "db_form"
            return
        end
    end
end

-- Template escolhido: carrega nome/slug + variáveis editáveis (do catálogo).
function new_service_pick_template(template_id: string): ()
    for _, template in ipairs(State.new_service_templates_all) do
        if template.id == template_id then
            ctx.new_service_template_id = template_id
            ctx.new_service_template_name = template.name
            ctx.new_service_name = template.slug or ""
            ctx.new_service_template_vars = template.vars or json.array({})

            for _, variable in ipairs(template.vars or {}) do
                ctx["new_service_template_variable_value_" .. variable.idx] =
                    ""
            end
            ctx.new_service_step = "template_form"
            return
        end
    end
end

-- Busca do catálogo de templates (filtra o cache no cliente).
function new_service_template_search(search_text: string): ()
    ctx.new_service_template_search = search_text or ""
    local term = string.lower(search_text or "")
    local rows = {}
    for _, template in ipairs(State.new_service_templates_all) do
        if
            term == ""
            or string.find(string.lower(template.name or ""), term, 1, true)
            or string.find(
                string.lower(template.description or ""),
                term,
                1,
                true
            )
        then
            table.insert(rows, template)
        end
    end
    ctx.new_service_templates = rows
end

function new_service_create(): ()
    local project_id = ctx.selected_project_id or ""
    local api = State.api
    if project_id == "" or not api then
        return
    end
    local step = ctx.new_service_step
    local kind, selected_template_id
    if step == "app_form" then
        kind, selected_template_id = "application", ""
    elseif step == "compose_form" then
        kind, selected_template_id = "compose", ""
    elseif step == "db_form" then
        selected_template_id = ctx.new_service_database_kind or ""
        kind = new_service_is_broker(selected_template_id) and "broker"
            or "database"
    elseif step == "template_form" then
        kind, selected_template_id =
            "template", ctx.new_service_template_id or ""
    else
        ctx.new_service_message = "passo inválido"
        return
    end

    -- Nome obrigatório, exceto no template (tem slug default).
    local name = Helpers.trim(ctx.new_service_name)
    if
        step ~= "template_form"
        and Helpers.trim(ctx.new_service_app_name) == ""
        and name == ""
    then
        ctx.error_new_service_name = "informe um nome"
        return
    end
    ctx.error_new_service_name = ""

    -- Valores das variáveis do template, na ordem declarada.
    local tvals = json.array {}
    if step == "template_form" then
        local json_decode_ok, vars =
            Helpers.decode_list(ctx.new_service_template_vars)
        if json_decode_ok and type(vars) == "table" then
            for _, variable in ipairs(vars) do
                table.insert(
                    tvals,
                    ctx["new_service_template_variable_value_" .. variable.idx]
                        or ""
                )
            end
        end
    end

    local create_request = {
        kind = kind,
        id = selected_template_id,
        project_id = project_id,
        name = name,
        app_name = Helpers.trim(ctx.new_service_app_name),
        db_name = Helpers.trim(ctx.new_service_database_name),
        user = Helpers.trim(ctx.new_service_database_user),
        password = Helpers.trim(ctx.new_service_database_password),
        root_password = Helpers.trim(ctx.new_service_database_root_password),
        image = Helpers.trim(ctx.new_service_image),
        use_replica = (Helpers.is_true(ctx.new_service_use_replica)),
        template_values = tvals,
        -- Porta externa automática + liberação no firewall (daemon aloca da
        -- faixa [external_ports]; sentinela host_port=0 no build_spec).
        expose_external = (Helpers.is_true(ctx.new_service_expose_external)),
    }
    ctx.new_service_message = "criando…"
    ctx.new_service_busy = "true"
    local wizard_create_ok, wizard_create_response =
        api:rpc_checked { WizardCreate = create_request }
    ctx.new_service_busy = ""
    if wizard_create_ok and wizard_create_response.Service then
        -- Sucesso: avisa a janela principal (que atualiza a grade de serviços
        -- do projeto aberto) e fecha esta janela do wizard.
        local display_name = (name ~= "" and name)
            or Helpers.trim(ctx.new_service_app_name)
        broadcast("service_created", {
            project_id = project_id,
            name = display_name,
        })
        close_window()
    else
        ctx.new_service_message = wizard_create_ok and "resposta inesperada"
            or ("erro: " .. wizard_create_response)
        toast {
            message = tostring(
                wizard_create_ok and "resposta inesperada do daemon"
                    or wizard_create_response
            ),
            kind = "error",
        }
    end
end