rustploy-gui 0.1.53

Rustploy — desktop client (glacier-ui) for the Rustploy PaaS daemon
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
--!strict
--!nolint FunctionUnused
-- handlers/projects.luau — grade de projetos: criar/editar/remover projeto,
-- variáveis de ambiente de projeto, ações de serviço a partir do card do grid
-- (parar/remover), e o "Stop All" de todo o cluster (topbar).

local State = require("../state")
local Helpers = require("../helpers")
local format = require("../format")
local Stream = require("stream")
local Registry = require("registry")
local History = require("history")

-- Abrir a lista de serviços de um projeto (grade read-only via snapshot).
function open_project(project_id: string): ()
    History.push()
    State.revealed_secrets = {}
    ctx.selected_project_id = project_id
    ctx.view = "project_services"
    ctx.project_tab = "services"
    ctx.editing_project = "false"
    ctx.project_action_message = ""
    ctx.project_env_text_open = "false"
    ctx.rename_open = ""
    ctx.project_loading = "true"
    Stream.update_open_project()
end

-- Voltar (history.luau) reabre o projeto pelo mesmo caminho do clique e
-- devolve a aba.
History.register("project_services", function(snap)
    open_project(snap.selected_project_id or "")
    ctx.project_tab = snap.project_tab
end)

-- ── Criar projeto (agora em uma JANELA à parte) ──────────────────────────

-- Abre a janela de cadastro de projeto (motor Glacier próprio, isolado). Passa
-- a conexão atual (api_url/api_token) via `data` para que a janela chame
-- ProjectCreate por conta própria; ao concluir, ela emite broadcast(
-- "project_created"), recebido por `on_broadcast` abaixo. Ver
-- views/new_project_form.screen.gv + scripts/new_project_window.luau.
function open_new_project_window(): ()
    open_window {
        component = "new_project",
        size = "460 340",
        decorations = false,
        resizable = false, -- diálogo fixo (borderless, sem alças de resize)
        data = {
            api_url = ctx.api_url or "",
            api_token = ctx.api_token or "",
        },
    }
end

-- Abre a janela "Reordenar serviços" do projeto aberto, semeada com os serviços na
-- ordem atual (a do snapshot, que já é a do `service_order` do project.yml). Ao
-- gravar, a janela emite broadcast("services_reordered") — ver on_broadcast.
function open_services_reorder_window(): ()
    local project_id = ctx.selected_project_id or ""
    local items = json.array {}
    for _, last_pair in ipairs(State.last_pairs) do
        if last_pair.svc.spec.project_id == project_id then
            table.insert(items, {
                id = last_pair.svc.id,
                name = last_pair.svc.spec.name,
            })
        end
    end
    if project_id == "" or #items < 2 then
        toast {
            message = tostring(i18n.tr("Nada para reordenar")),
            kind = "info",
        }
        return
    end
    open_window {
        component = "services_reorder",
        size = "440 560",
        decorations = false,
        resizable = false,
        data = {
            api_url = ctx.api_url or "",
            api_token = ctx.api_token or "",
            reorder_project_id = project_id,
            reorder_items = json.encode(items),
        },
    }
end

-- Recebe mensagens de OUTRAS janelas (glacier broadcast). Em ambos os casos
-- força um refresh imediato do snapshot (o card novo aparece na hora, sem
-- esperar o próximo tick do SSE) e confirma num toast:
--   "project_created" — a janela de cadastro de projeto concluiu.
--   "service_created" — a janela do wizard de serviço concluiu (a grade
--                       do projeto aberto é reconstruída pelo
--                       update_open_project).
function on_broadcast(event: string, payload: any): ()
    local name = (type(payload) == "table" and payload.name) or ""
    if event == "project_created" then
        toast {
            message = tostring(
                i18n.tr("Projeto \"") .. (name ~= "" and name or "projeto") .. i18n.tr("\" criado")
            ),
            kind = "success",
        }
        Stream.refresh_now()
    elseif event == "services_reordered" then
        toast {
            message = tostring(i18n.tr("Serviços reordenados")),
            kind = "success",
        }
        Stream.refresh_now()
    elseif event == "service_created" then
        toast {
            message = tostring(
                i18n.tr("Serviço \"")
                    .. (name ~= "" and name or i18n.tr("serviço"))
                    .. i18n.tr("\" criado")
            ),
            kind = "success",
        }
        Stream.refresh_now()
    elseif event == "job_created" then
        toast {
            message = tostring(
                i18n.tr("Job \"") .. (name ~= "" and name or "job") .. i18n.tr("\" criado")
            ),
            kind = "success",
        }
        Stream.refresh_now()
    elseif event == "job_updated" then
        toast {
            message = tostring(
                i18n.tr("Job \"") .. (name ~= "" and name or "job") .. i18n.tr("\" salvo")
            ),
            kind = "success",
        }
        Stream.refresh_now()
    elseif event == "registry_token_created" then
        toast {
            message = tostring(
                i18n.tr("Token \"") .. (name ~= "" and name or "token") .. i18n.tr("\" criado")
            ),
            kind = "success",
        }
        Registry.refresh_tokens()
    end
end

-- ── Criar / remover projeto ──────────────────────────────────────────────

-- NOTA: create_project (formulário inline, removido da UI em favor da janela
-- acima) é mantido por ser reutilizável — um fluxo de criação embutido poderia
-- voltar a chamá-lo. Hoje não há call-site na UI.
function create_project(): ()
    local api = State.api
    if not api then
        return
    end
    local name = Helpers.trim(ctx.new_project_inline_name)
    if name == "" then
        ctx.error_new_project_inline_name = i18n.tr("informe um nome")
        return
    end
    ctx.error_new_project_inline_name = ""
    local description = Helpers.trim(ctx.new_project_inline_description)
    local create_command = {
        ProjectCreate = {
            name = name,
            description = (description ~= "" and description or nil),
        },
    }
    ctx.new_project_inline_message = i18n.tr("criando…")
    local rpc_ok, rpc_response = api:rpc_checked(create_command)
    if rpc_ok then
        ctx.new_project_inline_name = ""
        ctx.new_project_inline_description = ""
        ctx.new_project_inline_message = i18n.tr("projeto criado")
        toast {
            message = tostring(i18n.tr("Projeto \"") .. name .. i18n.tr("\" criado")),
            kind = "success",
        }
        Stream.refresh_now()
    else
        ctx.new_project_inline_message = i18n.tr("erro: ") .. rpc_response
        toast {
            message = tostring(rpc_response),
            kind = "error",
        }
    end
end

function delete_project(project_id: string): ()
    if project_id == "" then
        return
    end
    local confirmed = confirm {
        title = i18n.tr("Remover projeto?"),
        message = i18n.tr("O projeto será removido. Só é possível remover projetos sem serviços."),
        confirm_label = i18n.tr("Remover projeto"),
        destructive = true,
    }

    if not confirmed then
        return
    end

    local api = State.api
    if not api or project_id == "" then
        return
    end
    ctx.project_action_message = i18n.tr("removendo…")
    local project_delete_ok, project_delete_response =
        api:rpc_checked { ProjectDelete = { id = project_id } }
    if project_delete_ok then
        History.drop("selected_project_id", project_id)
        ctx.selected_project_id = ""
        ctx.view = "projects"
        toast { message = tostring(i18n.tr("Projeto removido")), kind = "success" }
        Stream.refresh_now()
    else
        ctx.project_action_message = i18n.tr("erro: ") .. project_delete_response
        toast { message = tostring(project_delete_response), kind = "error" }
    end
end

-- ── Editar projeto (cabeçalho de project_services) ──────────────────────

function edit_project_toggle(): ()
    ctx.edit_project_name = ctx.project_name or ""
    ctx.edit_project_description = ctx.project_description or ""
    ctx.error_edit_project_name = ""
    ctx.editing_project = "true"
end

function cancel_project_edit(): ()
    ctx.editing_project = "false"
end

-- on_validation_error do form(name = edit_project) — o motor já publicou
-- {error_edit_project_name} e acendeu o :invalid; aqui é só o aviso.
function project_edit_show_validation_errors(_erros_json: string?): ()
    toast { message = i18n.tr("Informe o nome do projeto"), kind = "warning" }
end

-- on_submit: só roda com a validação (rules="required" no NOME) aprovada.
function save_project_edit(): ()
    local project_id = ctx.selected_project_id or ""
    local api = State.api
    if project_id == "" or not api then
        return
    end
    local name = Helpers.trim(ctx.edit_project_name)
    local description = Helpers.trim(ctx.edit_project_description)
    local project_update_ok, project_update_response = api:rpc_checked {
        ProjectUpdate = {
            id = project_id,
            name = name,
            description = (description ~= "" and description or nil),
        },
    }
    if project_update_ok then
        ctx.editing_project = "false"
        ctx.project_action_message = i18n.tr("projeto atualizado")
        ctx.project_name = name
        ctx.project_description = description
    else
        ctx.project_action_message = i18n.tr("erro: ") .. project_update_response
    end
end

-- ── Variáveis de ambiente do projeto (aba "Variáveis") ──────────────────
-- Fluxo idêntico ao do serviço: env_vars + env_comments (`# ...` ancorados por
-- key). O `mutate` recebe uma tabela `pe = { env_vars, env_comments }` e a
-- altera in place; enviamos as duas listas no ProjectEnvSet.

local function with_project_env(mutate: (project_env_record: any) -> ()): ()
    local project_id = ctx.selected_project_id or ""
    local api = State.api
    if project_id == "" or not api then
        return
    end
    local project_list_ok, project_list_response = api:rpc("ProjectList")
    if not project_list_ok or not project_list_response.Projects then
        return
    end
    local found_project
    for _, project in ipairs(project_list_response.Projects) do
        if project.id == project_id then
            found_project = project
            break
        end
    end
    if found_project == nil then
        return
    end
    local project_env_record = {
        env_vars = found_project.env_vars or json.array {},
        env_comments = found_project.env_comments or json.array {},
    }
    mutate(project_env_record)
    local project_env_set_ok, project_env_set_response = api:rpc_checked {
        ProjectEnvSet = {
            project_id = project_id,
            env_vars = project_env_record.env_vars,
            env_comments = project_env_record.env_comments,
        },
    }
    if project_env_set_ok then
        ctx.project_env = format.env_json_with_comments(
            project_env_record.env_vars,
            project_env_record.env_comments
        )
        ctx.project_env_count = tostring(#project_env_record.env_vars)
        -- Não sobrescrever o textarea enquanto o usuário edita (ver
        -- stream.luau).
        if not Helpers.is_true(ctx.project_env_text_open) then
            local dotenv = format.env_dotenv_with_comments(
                project_env_record.env_vars,
                project_env_record.env_comments
            )
            ctx.project_env_text = dotenv
            ctx.project_env_text_original = dotenv
        end
        toast { message = tostring("Salvo"), kind = "success" }
    else
        toast { message = tostring(project_env_set_response), kind = "error" }
    end
end

-- Reordena vars/comments a partir de uma ordem de keys (vars + linhas
-- `__c<idx>`): cada comentário reancora na primeira var real que o segue.
local function reorder_project_env(
    vars: { any },
    comments: { any },
    keys: { any }
): ({ any }, { any })
    local var_keys = {}
    local by_key = {}
    for _, variable in ipairs(vars) do
        var_keys[variable.key] = true
        by_key[variable.key] = variable
    end
    local seen = {}
    local new_vars = json.array {}
    local new_comments = json.array {}
    for key_position, key in ipairs(keys) do
        local comment_index_text = key:match("^__c(%d+)$")
        if comment_index_text then
            -- __c é 0-based
            local comment_index = (tonumber(comment_index_text) or 0) + 1
            local comment = comments[comment_index]
            if comment then
                seen[comment_index] = true
                local anchor = nil
                for key_index = key_position + 1, #keys do
                    if var_keys[keys[key_index]] then
                        anchor = keys[key_index]
                        break
                    end
                end
                table.insert(
                    new_comments,
                    { text = comment.text, before_key = anchor }
                )
            end
        elseif by_key[key] then
            table.insert(new_vars, by_key[key])
            by_key[key] = nil
        end
    end
    for _, variable in ipairs(vars) do
        if by_key[variable.key] then
            table.insert(new_vars, variable)
        end
    end
    for index, comment in ipairs(comments) do
        if not seen[index] then
            table.insert(new_comments, comment)
        end
    end
    return new_vars, new_comments
end

-- on_validation_error do form(name = project_env_add) — a chave é
-- `rules="required"`; o motor publica {error_project_env_new_key} e acende o
-- :invalid. Aqui é só o aviso.
function project_env_show_validation_errors(_erros_json: string?): ()
    toast { message = i18n.tr("Informe a chave (KEY)"), kind = "warning" }
end

function project_env_add(): ()
    -- rules="required" no form(...) garante a chave preenchida aqui.
    local key = Helpers.trim(ctx.project_env_new_key)
    local raw_value = ctx.project_env_new_value or ""
    -- Duas formas de virar referência de secret (a que o daemon decifra no
    -- deploy, deploy/env_resolve.rs):
    --   • toggle "usar secret" ligado (ver handlers/secrets.luau) — o campo
    --     valor carrega o NOME puro do secret, não o conteúdo;
    --   • `<secret:NOME>`/`secret:NOME` digitado no valor, como no editor
    --     .env.
    if not Helpers.confirm_env_key(key, ctx.project_env) then
        return
    end
    local secret_name: string? = Helpers.parse_secret_ref(raw_value)
    if
        Helpers.is_true(ctx.project_env_new_is_secret)
        and secret_name == nil
    then
        -- Modo secret: o valor JÁ é o nome (o chip preenche assim).
        local name = Helpers.trim(raw_value)
        if name == "" then
            ctx.error_project_env_new_key = i18n.tr("escolha um secret")
            return
        end
        secret_name = name
    end
    with_project_env(function(project_env_record: any): ()
        local kept = json.array {}
        for _, env_var in ipairs(project_env_record.env_vars) do
            if env_var.key ~= key then
                table.insert(kept, env_var)
            end
        end
        local value = secret_name and { Secret = secret_name }
            or { Plain = raw_value }
        table.insert(kept, { key = key, value = value })
        project_env_record.env_vars = kept
    end)
    ctx.project_env_new_key = ""
    ctx.project_env_new_value = ""
    ctx.project_env_new_is_secret = "false"
end

function project_env_delete(key: string): ()
    with_project_env(function(project_env_record: any): ()
        local kept = json.array {}
        for _, env_var in ipairs(project_env_record.env_vars) do
            if env_var.key ~= key then
                table.insert(kept, env_var)
            end
        end
        project_env_record.env_vars = kept
        local new_env_comments = json.array {}
        for _, env_comment in ipairs(project_env_record.env_comments) do
            if env_comment.before_key ~= key then
                table.insert(new_env_comments, env_comment)
            end
        end
        project_env_record.env_comments = new_env_comments
    end)
end

function project_env_reorder(reorder_json: string?): ()
    if not reorder_json then
        return
    end
    local json_decode_ok, keys = pcall(json.decode, reorder_json)
    if not json_decode_ok or type(keys) ~= "table" then
        return
    end
    with_project_env(function(project_env_record: any): ()
        local reordered_vars, reordered_comments = reorder_project_env(
            project_env_record.env_vars,
            project_env_record.env_comments,
            keys
        )
        project_env_record.env_vars = reordered_vars
        project_env_record.env_comments = reordered_comments
    end)
end

-- ── Editor .env do projeto (mesmo fluxo do serviço) ─────────────────────

function project_env_text_toggle(): ()
    ctx.project_env_text_open = (Helpers.is_true(ctx.project_env_text_open))
            and "false"
        or "true"
end

function project_env_export(): ()
    -- Abre o editor .env já preenchido (o texto vem do snapshot em
    -- project_env_text).
    ctx.project_env_text_open = "true"
end

function project_env_text_cancel(): ()
    ctx.project_env_text = ctx.project_env_text_original or ""
    ctx.project_env_text_open = "false"
end

function project_env_import(): ()
    local vars, comments, errors = Helpers.parse_dotenv(ctx.project_env_text)
    if #errors > 0 then
        toast { message = Helpers.dotenv_errors_message(errors), kind = "error" }
        return
    end
    with_project_env(function(project_env_record: any): ()
        project_env_record.env_vars = vars
        project_env_record.env_comments = comments
    end)
    ctx.project_env_text_open = "false"
end

-- ── Ações de serviço a partir de um card do grid do projeto ─────────────
-- O grid se atualiza no próximo snapshot; refresh_now traz isso pra hora.

-- Painel "Em execução": copia a URL COMPLETA do webhook (o card mostra o token
-- mascarado até o usuário pedir).
function copy_webhook(url: string): ()
    copy(url)
    toast { message = i18n.tr("Webhook copiado"), kind = "success" }
end

-- Alterna entre token mascarado e visível no card; reconstrói a lista na hora
-- (sem rede) a partir do último snapshot.
function running_toggle_reveal(service_id: string): ()
    State.running_revealed[service_id] = not State.running_revealed[service_id]
    Stream.rebuild_lists()
end

function service_stop_id(service_id: string): ()
    local confirmed = confirm {
        title = i18n.tr("Parar serviço?"),
        message = i18n.tr("O tráfego para este serviço será interrompido até um novo deploy."),
        confirm_label = i18n.tr("Parar"),
        destructive = true,
    }

    if not confirmed then
        return
    end

    local api = State.api
    if not api then
        return
    end
    ctx.project_action_message = i18n.tr("enviando…")
    local service_stop_ok, service_stop_response =
        api:rpc_checked { ServiceStop = { service_id = service_id } }
    ctx.project_action_message = service_stop_ok and i18n.tr("serviço parado")
        or ("erro: " .. service_stop_response)
    if not service_stop_ok then
        toast { message = tostring(service_stop_response), kind = "error" }
    end
    Stream.refresh_now()
end

function stop_delete_service(service_id: string): ()
    -- Jobs de Schedules gatilhados por este serviço são removidos em cascata
    -- pelo daemon — avisa quais no confirm (jobs_by_id vem do snapshot).
    local deployment_names = {}
    for _, job in State.jobs_by_id do
        if job.trigger_service_id == service_id then
            table.insert(deployment_names, tostring(job.name))
        end
    end
    local message = i18n.tr("O serviço será parado e removido.")
    if #deployment_names > 0 then
        table.sort(deployment_names)
        message = message
            .. i18n.tr("Os jobs de Schedules que dependem dele também serão removidos, com histórico: ")
            .. table.concat(deployment_names, ", ")
            .. "."
    end
    message = message .. i18n.tr(" Essa ação não pode ser desfeita.")
    local confirmed = confirm {
        title = i18n.tr("Parar e remover serviço?"),
        message = message,
        confirm_label = i18n.tr("Remover"),
        destructive = true,
    }

    if not confirmed then
        return
    end

    local api = State.api
    if not api then
        return
    end
    ctx.project_action_message = i18n.tr("removendo…")
    local service_stop_ok, service_stop_response =
        api:rpc_checked { ServiceStop = { service_id = service_id } }
    if not service_stop_ok then
        ctx.project_action_message = i18n.tr("erro ao parar: ") .. service_stop_response
        toast { message = tostring(service_stop_response), kind = "error" }
        return
    end
    local service_delete_ok, service_delete_response =
        api:rpc_checked { ServiceDelete = { id = service_id } }
    ctx.project_action_message = service_delete_ok
            and i18n.tr("serviço parado e removido")
        or (i18n.tr("erro ao remover: ") .. service_delete_response)
    if service_delete_ok then
        History.drop("selected_service", service_id)
        toast { message = tostring(i18n.tr("Serviço removido")), kind = "success" }
    else
        toast { message = tostring(service_delete_response), kind = "error" }
    end
    Stream.refresh_now()
end

-- ── Stop All (topbar) ────────────────────────────────────────────────────

function stop_all(): ()
    local confirmed = confirm {
        title = i18n.tr("Parar todos os serviços?"),
        message = i18n.tr("Todos os serviços em execução serão parados agora. Você pode reiniciá-los depois com um novo deploy."),
        confirm_label = i18n.tr("Parar todos"),
        destructive = true,
    }

    if not confirmed then
        return
    end

    local api = State.api
    if not api then
        return
    end
    ctx.status_line = i18n.tr("parando todos…")
    local stop_all_managed_ok, stop_all_managed_response =
        api:rpc_checked("StopAllManaged")
    if stop_all_managed_ok then
        local stopped_count = stop_all_managed_response.StopAllResult
                and stop_all_managed_response.StopAllResult.count
            or 0
        ctx.status_line = "parados: " .. tostring(stopped_count)
    else
        ctx.status_line = "erro: " .. stop_all_managed_response
    end
end