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
--!strict
--!nolint FunctionUnused
-- handlers/secrets.luau — aba "Secrets" do projeto: criar/sobrescrever e
-- apagar valores cifrados, e o atalho "usar secret" da aba Variáveis.
--
-- A UI é write-only DE PROPÓSITO: o daemon só devolve os NOMES (SecretList →
-- Response::SecretNames), nunca o blob cifrado, então não há o que exibir nem
-- como "editar" um valor — sobrescrever é salvar outro com o mesmo nome
-- (o SecretSet do daemon faz ON CONFLICT DO UPDATE). Ver docs/secrets.md.
--
-- A lista em si não é buscada aqui: vem no snapshot (`projects[].secrets`),
-- montada por handlers/stream.luau em
-- ctx.project_secrets/project_secrets_count.

local State = require("../state")
local Helpers = require("../helpers")
local Stream = require("stream")
local Services = require("services")

-- Nomes atualmente conhecidos (do snapshot já renderizado), para saber se um
-- SecretSet cria ou substitui — o daemon não distingue os dois casos.
local function known_names(): { [string]: boolean }
    local names_set = {}
    local json_decode_ok, rows = Helpers.decode_list(ctx.project_secrets)
    if json_decode_ok and type(rows) == "table" then
        for _, row in ipairs(rows) do
            names_set[tostring(row.name)] = true
        end
    end
    return names_set
end

-- on_validation_error do form(name = secret_add) — NOME e valor são
-- `rules="required"` no form(...); o motor publica
-- {error_project_secret_new_name} / {error_project_secret_new_value} e acende
-- o :invalid. Aqui é só o aviso.
function secret_add_show_validation_errors(_erros_json: string?): ()
    toast {
        message = i18n.tr("Preencha nome e valor do secret"),
        kind = "warning",
    }
end

function secret_add(): ()
    local project_id = ctx.selected_project_id or ""
    local api = State.api
    if project_id == "" or not api then
        return
    end
    -- rules="required" no form(...) garante nome e valor preenchidos aqui.
    local name = Helpers.trim(ctx.project_secret_new_name)
    local value = ctx.project_secret_new_value or ""
    local replacing = known_names()[name] == true
    local secret_set_ok, secret_set_response = api:rpc_checked {
        SecretSet = { project_id = project_id, name = name, value = value },
    }
    if secret_set_ok then
        ctx.project_secret_new_name = ""
        ctx.project_secret_new_value = ""
        local message = i18n.tr('Secret "') .. name .. i18n.tr('" criado')
        if replacing then
            message = i18n.tr('Secret "')
                .. name
                .. i18n.tr(
                    '" substituído — o novo valor vale no próximo deploy'
                )
        end
        toast { message = message, kind = "success" }
        Stream.refresh_now()
    else
        toast { message = tostring(secret_set_response), kind = "error" }
    end
end

function secret_delete(name: string): ()
    local project_id = ctx.selected_project_id or ""
    if project_id == "" or name == "" then
        return
    end
    local confirmed = confirm {
        title = i18n.tr("Remover secret?"),
        message = i18n.tr(
            'O valor cifrado será apagado e não há como recuperá-lo. Variáveis que referenciam "'
        )
            .. name
            .. i18n.tr('" passarão a receber valor vazio no próximo deploy.'),
        confirm_label = i18n.tr("Remover secret"),
        destructive = true,
    }
    if not confirmed then
        return
    end
    local api = State.api
    if not api then
        return
    end
    local secret_delete_ok, secret_delete_response = api:rpc_checked {
        SecretDelete = { project_id = project_id, name = name },
    }
    if secret_delete_ok then
        toast {
            message = tostring(i18n.tr("Secret removido")),
            kind = "success",
        }
        Stream.refresh_now()
    else
        toast { message = tostring(secret_delete_response), kind = "error" }
    end
end

-- Chip "usar secret" (aba Variáveis): arma o form de adicionar var em modo
-- secret com o nome escolhido. Quem grava é project_env_add
-- (handlers/projects.luau), que nesse modo monta { Secret = nome } em vez de {
-- Plain = valor }.
function secret_use(name: string): ()
    ctx.project_env_new_is_secret = "true"
    ctx.project_env_new_value = name
    if Helpers.trim(ctx.project_env_new_key) == "" then
        ctx.project_env_new_key = name
    end
end

function project_env_secret_toggle(): ()
    local on = Helpers.is_true(ctx.project_env_new_is_secret)
    ctx.project_env_new_is_secret = on and "false" or "true"
    ctx.project_env_new_value = ""
end

-- ── Movimentos (SecretMove) a partir do PROJETO ─────────────────────────────
-- O destino dos "↓ para o serviço" é o serviço escolhido no <select> do topo da aba
-- (ctx.move_target_service_id); os outros movimentos não precisam dele.

function move_target_pick(service_id: string): ()
    ctx.move_target_service_id = service_id or ""
end

local function project_move(
    op: string,
    name: string,
    service_id: string?,
    done_message: string
): ()
    local project_id = ctx.selected_project_id or ""
    local api = State.api
    if project_id == "" or name == "" or not api then
        return
    end
    local move_ok, move_response = api:rpc_checked {
        SecretMove = {
            project_id = project_id,
            service_id = service_id,
            name = name,
            op = op,
        },
    }
    if move_ok then
        toast { message = done_message .. name, kind = "success" }
        Stream.refresh_now()
    else
        toast { message = tostring(move_response), kind = "error" }
    end
end

-- Devolve o serviço de destino escolhido, ou avisa e devolve nil.
local function target_service(): string?
    local service_id = ctx.move_target_service_id or ""
    if service_id == "" then
        toast {
            message = i18n.tr(
                "Escolha antes o serviço de destino, no seletor do topo."
            ),
            kind = "warning",
        }
        return nil
    end
    return service_id
end

function project_env_to_secret(key: string): ()
    project_move(
        "EnvToSecret",
        key,
        nil,
        i18n.tr("Variável movida para secret: ")
    )
end

function project_secret_to_env(name: string): ()
    local confirmed = confirm {
        title = i18n.tr("Voltar para variável em claro?"),
        message = i18n.tr('O valor de "')
            .. name
            .. i18n.tr(
                '" deixa de ser cifrado e passa a aparecer em claro na variável (e no estado versionado).'
            ),
        confirm_label = i18n.tr("Mover para variável"),
        destructive = true,
    }
    if not confirmed then
        return
    end
    project_move(
        "SecretToEnv",
        name,
        nil,
        i18n.tr("Secret movido para variável: ")
    )
end

function project_secret_to_service(name: string): ()
    local service_id = target_service()
    if service_id == nil then
        return
    end
    project_move(
        "ProjectSecretToService",
        name,
        service_id,
        i18n.tr("Secret movido para o serviço: ")
    )
end

function project_env_to_service(key: string): ()
    local service_id = target_service()
    if service_id == nil then
        return
    end
    project_move(
        "EnvProjectToService",
        key,
        service_id,
        i18n.tr("Variável movida para o serviço: ")
    )
end

-- ── Renomear a chave de uma variável / o nome de um secret ──────────────────
-- EnvKeyRename / SecretRename: o valor nunca passa pela tela (nem pelo agente). O ✏️ da linha só
-- ARMA um mini-formulário (ctx.rename_open = "project_env" | "project_secret" | "service_env" |
-- "service_secret"); quem grava é project_rename_apply / service_rename_apply.

local function rename_pick(tag: string, name: string): ()
    ctx.rename_open = tag
    ctx.rename_from = name
    ctx.rename_to = name
end

function project_env_rename_pick(key: string): ()
    rename_pick("project_env", key)
end

function project_secret_rename_pick(name: string): ()
    rename_pick("project_secret", name)
end

function service_env_rename_pick(key: string): ()
    rename_pick("service_env", key)
end

function service_secret_rename_pick(name: string): ()
    rename_pick("service_secret", name)
end

function rename_cancel(): ()
    ctx.rename_open = ""
    ctx.rename_from = ""
    ctx.rename_to = ""
end

function project_rename_apply(): ()
    local project_id = ctx.selected_project_id or ""
    local api = State.api
    local from = ctx.rename_from or ""
    local to = Helpers.trim(ctx.rename_to)
    if project_id == "" or from == "" or not api then
        return
    end
    if to == "" or to == from then
        toast {
            message = i18n.tr("Digite um nome novo, diferente do atual."),
            kind = "warning",
        }
        return
    end
    local body = { project_id = project_id, from = from, to = to }
    local rename_ok, rename_response
    if ctx.rename_open == "project_secret" then
        rename_ok, rename_response = api:rpc_checked { SecretRename = body }
    else
        rename_ok, rename_response = api:rpc_checked { EnvKeyRename = body }
    end
    if rename_ok then
        toast {
            message = i18n.tr("Renomeado: ") .. from .. " → " .. to,
            kind = "success",
        }
        rename_cancel()
        Stream.refresh_now()
    else
        toast { message = tostring(rename_response), kind = "error" }
    end
end

-- ── Edição no lugar da chave/valor de uma variável ──────────────────────────
-- O ✏️ (chave) e o ✎ (valor) da linha trocam o texto dela por um campo, na própria posição da
-- lista: nada é levado para um formulário no topo (numa lista grande ele ficava fora da tela).
-- Enter ou ✓ grava; ✕ desiste. Chave: EnvKeyRename; valor: EnvValueSet (o valor nunca passa
-- pela lista inteira, só a variável editada). `ctx.env_edit_id` é "key:NOME" | "value:NOME".

local function env_row(raw: any, key: string): any?
    local rows = raw
    if type(raw) == "string" then
        local ok, decoded = pcall(function()
            return json.decode(raw)
        end)
        rows = ok and decoded or {}
    end
    for _, row in ipairs(rows or {}) do
        if row.key == key then
            return row
        end
    end
    return nil
end

local function env_edit_start(scope: string, field: string, key: string): ()
    local raw = (scope == "service") and ctx.service_env or ctx.project_env
    local row = env_row(raw, key)
    if row == nil then
        return
    end
    local is_secret = row.kind == "secret"
    local draft = key
    if field == "value" then
        -- Variável secret: o texto editável é a referência (`secret:NOME`), nunca o valor cifrado.
        draft = is_secret and ("secret:" .. tostring(row.secret_name)) or tostring(row.value)
    end
    ctx.env_edit_scope = scope
    ctx.env_edit_id = field .. ":" .. key
    ctx.env_edit_draft = draft
    ctx.env_edit_was_secret = is_secret and "true" or "false"
end

function env_edit_cancel(): ()
    ctx.env_edit_id = ""
    ctx.env_edit_draft = ""
    ctx.env_edit_scope = ""
    ctx.env_edit_was_secret = "false"
end

function project_env_edit_key(key: string): ()
    env_edit_start("project", "key", key)
end

function project_env_edit_value(key: string): ()
    env_edit_start("project", "value", key)
end

function service_env_edit_key(key: string): ()
    env_edit_start("service", "key", key)
end

function service_env_edit_value(key: string): ()
    env_edit_start("service", "value", key)
end

function env_edit_apply(): ()
    local api = State.api
    local id = ctx.env_edit_id or ""
    local scope = ctx.env_edit_scope or ""
    local field, key = id:match("^(%a+):(.+)$")
    if not api or field == nil or key == nil then
        return
    end
    local is_service = scope == "service"
    local project_id = is_service and (ctx.service_project_id or "")
        or (ctx.selected_project_id or "")
    local service_id = is_service and (ctx.selected_service or "") or nil
    if project_id == "" or (is_service and service_id == "") then
        return
    end
    local draft = ctx.env_edit_draft or ""
    local was_secret = Helpers.is_true(ctx.env_edit_was_secret)
    local ok, response
    if field == "key" then
        local to = Helpers.trim(draft)
        if to == "" or to == key then
            env_edit_cancel()
            return
        end
        ok, response = api:rpc_checked {
            EnvKeyRename = {
                project_id = project_id,
                service_id = service_id,
                from = key,
                to = to,
            },
        }
    else
        ok, response = api:rpc_checked {
            EnvValueSet = {
                project_id = project_id,
                service_id = service_id,
                key = key,
                value = Helpers.env_value_from_draft(draft, was_secret),
            },
        }
    end
    if ok then
        toast {
            message = (field == "key") and (i18n.tr("Renomeado: ") .. key)
                or (i18n.tr("Valor salvo: ") .. key),
            kind = "success",
        }
        env_edit_cancel()
        if is_service then
            Services.fetch_service_detail(service_id)
        end
        Stream.refresh_now()
    else
        -- Fica no modo de edição: o texto digitado não se perde numa recusa do daemon.
        toast { message = tostring(response), kind = "error" }
    end
end

-- ── Ver o valor de um secret do PROJETO ─────────────────────────────────────
-- SecretReveal grava auditoria (quem, qual, quando; nunca o valor) e fica de fora do MCP.

function secret_reveal(name: string): ()
    local project_id = ctx.selected_project_id or ""
    local api = State.api
    if project_id == "" or name == "" or not api then
        return
    end
    local confirmed = confirm {
        title = i18n.tr("Ver o valor do secret?"),
        message = i18n.tr('O valor de "')
            .. name
            .. i18n.tr(
                '" vai aparecer na tela. O acesso fica registrado na auditoria do servidor (quem, qual secret e quando; nunca o valor).'
            ),
        confirm_label = i18n.tr("Ver valor"),
    }
    if not confirmed then
        return
    end
    local reveal_ok, reveal_response = api:rpc {
        SecretReveal = { project_id = project_id, name = name },
    }
    if
        not reveal_ok
        or type(reveal_response) ~= "table"
        or reveal_response.SecretValue == nil
    then
        toast { message = tostring(reveal_response), kind = "error" }
        return
    end
    State.revealed_secrets["project:" .. name] =
        reveal_response.SecretValue :: string
    Stream.refresh_now()
end

function secret_hide(name: string): ()
    State.revealed_secrets["project:" .. name] = nil
    Stream.refresh_now()
end