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
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
--!strict
--!nolint FunctionUnused
-- handlers/stream.luau — consumidor do SSE de /api/events: aplica o snapshot
-- periódico (2s) e os eventos vivos do bus (métricas, logs, deploy). Exporta
-- open_stream/rebuild_lists/update_open_project/refresh_now para os demais
-- handlers (login, busca, abrir projeto, mutações que refletem na hora).

local State = require("../state")
local Helpers = require("../helpers")
local format = require("../format")
local Api = require("../net/api")
local Services = require("services")

local M = {}

-- Reconstrói as listas filtradas pela busca a partir do último snapshot — sem
-- rede, para a busca filtrar na hora (equivalente ao antigo search_pairs).
local function rebuild_lists(): ()
    local snap = State.snap
    if not snap then
        return
    end
    local term = State.search_term
    ctx.deployments =
        format.deployments(snap.deployments, term, State.last_pairs)
    ctx.projects = format.projects(snap.projects, State.last_pairs, term)
    ctx.project_rows =
        format.project_rows(snap.projects, State.last_pairs, term)
    ctx.services = format.services(State.last_pairs, State.metrics_by_id, term)
    ctx.service_rows =
        format.service_rows(State.last_pairs, State.metrics_by_id, term)
    local running, running_count = format.running(
        snap.dashboard,
        State.metrics_by_id,
        term,
        State.running_revealed
    )
    ctx.running = running
    ctx.running_count = tostring(running_count)
    -- ctx.docker_rows: a antiga sub-aba Containers (só serviços rustploy) foi
    -- substituída pela lista host-wide (docker_containers). format.docker_rows
    -- fica disponível para reuso; não recalculamos aqui à toa. ctx.docker_rows
    -- = format.docker_rows(State.last_pairs, term)
    ctx.docker_containers =
        format.docker_containers(snap.docker_containers, term)
    ctx.docker_images = format.docker_images(
        snap.docker_images,
        term,
        Helpers.is_true(ctx.docker_images_only_used)
    )
    ctx.docker_volumes = format.docker_volumes(
        snap.docker_volumes,
        term,
        Helpers.is_true(ctx.docker_volumes_only_used)
    )
    ctx.docker_networks = format.docker_networks(
        snap.docker_networks,
        term,
        Helpers.is_true(ctx.docker_networks_only_used)
    )
    ctx.registry_repos = format.registry_repos(snap.registry_repos, term)
    -- Lista do select da sub-aba Registry do serviço: sem o filtro de busca.
    ctx.registry_picker_repos = format.registry_repos(snap.registry_repos, nil)
end
M.rebuild_lists = rebuild_lists

-- Cabeçalho + grade de serviços do projeto aberto (view=project_services).
local function update_open_project(): ()
    local snap = State.snap
    if not snap then
        return
    end
    local project_id = ctx.selected_project_id or ""
    if project_id == "" then
        return
    end
    local found_project
    for _, project in ipairs(snap.projects or {}) do
        if project.id == project_id then
            found_project = project
            break
        end
    end
    if not found_project then
        return
    end
    local project_pairs = {}
    for _, last_pair in ipairs(State.last_pairs) do
        if last_pair.svc.spec.project_id == project_id then
            table.insert(project_pairs, last_pair)
        end
    end
    ctx.project_name = found_project.name
    ctx.project_description = found_project.description or ""
    ctx.project_can_delete = (#project_pairs == 0) and "1" or "0"
    ctx.project_loading = "false"
    ctx.project_services = format.service_rows(
        project_pairs,
        State.metrics_by_id,
        State.search_term
    )
    -- Destinos dos movimentos "↓ para o serviço" (abas Variáveis e Secrets do projeto).
    -- A primeira opção é o valor nulo (id vazio): selecionada, os botões ↓
    -- ficam travados; qualquer serviço real os destrava.
    local service_options = json.array {
        { id = "", display = i18n.tr("escolha o serviço de destino") },
    }
    local target_still_valid = false
    for _, last_pair in ipairs(project_pairs) do
        table.insert(
            service_options,
            { id = last_pair.svc.id, display = last_pair.svc.spec.name }
        )
        if last_pair.svc.id == ctx.move_target_service_id then
            target_still_valid = true
        end
    end
    ctx.project_service_options = json.encode(service_options)
    if not target_still_valid then
        ctx.move_target_service_id = ""
    end
    -- Variáveis de ambiente do projeto (aba "Variáveis (N)"). Sem isto, a aba
    -- nasce "Variáveis ()" até a primeira edição.
    local project_env_vars = found_project.env_vars or {}
    local project_comments = found_project.env_comments or {}
    ctx.project_env =
        format.env_json_with_comments(project_env_vars, project_comments)
    ctx.project_env_count = tostring(#project_env_vars)
    -- O snapshot chega ~1x/s; se reescrevêssemos project_env_text (o value do
    -- textarea) toda vez, o que o usuário digita seria resetado. Só
    -- ressincroniza o editor .env quando ele está fechado.
    if not Helpers.is_true(ctx.project_env_text_open) then
        local project_dotenv =
            format.env_dotenv_with_comments(project_env_vars, project_comments)
        ctx.project_env_text = project_dotenv
        ctx.project_env_text_original = project_dotenv
    end

    -- Aba "Secrets (N)": só os nomes — `projects[].secrets` vem do snapshot
    -- (ver http_api.rs::snapshot), nunca o valor. A mesma lista alimenta os
    -- chips de "usar secret" da aba Variáveis.
    local secrets = found_project.secrets or {}
    ctx.project_secrets = format.secret_rows(secrets, "project", State.revealed_secrets)
    ctx.project_secrets_count = tostring(#secrets)

    -- Aba "Jobs (N)": jobs deste projeto (filtrados do `msg.jobs` global —
    -- já busca tudo a cada snapshot, sem RPC extra).
    local project_jobs = {}
    for _, job in ipairs(snap.jobs or {}) do
        if job.job.project_id == project_id then
            table.insert(project_jobs, job)
        end
    end
    local jobs_json, jobs_count =
        format.job_summaries(project_jobs, State.jobs_inflight)
    ctx.project_jobs = jobs_json
    ctx.project_jobs_count = tostring(jobs_count)
end

M.update_open_project = update_open_project

-- Recomputa SÓ `ctx.jobs_summary`/`ctx.jobs_count` (tela global "Schedules")
-- a partir do último snapshot em cache — sem RPC. Chamada por
-- handlers/jobs.luau::job_run_now logo após tocar `State.jobs_inflight`,
-- pra o botão desabilitar na hora, sem esperar o round-trip do
-- `JobRunNow`/`refresh_now()`.
local function refresh_jobs_summary_local(): ()
    local snap = State.snap
    if not snap then
        return
    end
    local jobs_summary, jobs_count =
        format.job_summaries(snap.jobs, State.jobs_inflight)
    ctx.jobs_summary = jobs_summary
    ctx.jobs_count = tostring(jobs_count)
end
M.refresh_jobs_summary_local = refresh_jobs_summary_local

local function refresh_deploy_engine_detail(active: { any }?): ()
    local detail_deployment_id = (ctx :: any).deploy_engine_detail_id
    if detail_deployment_id == nil or detail_deployment_id == "" then
        return
    end
    for _, info in ipairs(active or {}) do
        if info.service_id == detail_deployment_id then
            ctx.deploy_engine_detail_title = info.service_name
            ctx.deploy_engine_detail_steps =
                format.deploy_engine_detail_steps(info)
            ctx.deploy_engine_detail_rows = format.deploy_engine_detail(info)
            return
        end
    end
    ctx.deploy_engine_detail_id = ""
    ctx.deploy_engine_detail_open = "false"
end
M.refresh_deploy_engine_detail = refresh_deploy_engine_detail

-- Preenche a aba Atualizações e o selo do Settings com um UpdateInfo. Vem do
-- CheckUpdate (handlers/settings.luau::update_check) ou do campo `update` do
-- snapshot, que traz a última consulta do daemon, inclusive a periódica
-- (`[update] check_interval_hours`). Mora aqui, e não em settings, porque
-- settings já requer este módulo.
function M.apply_update_info(update_info: any): ()
    State.update_checked_at = update_info.checked_at
    ctx.update_current_version = update_info.current_version
    ctx.update_latest_version = update_info.latest_version
    ctx.update_is_available = update_info.is_update_available and "true"
        or "false"
    ctx.update_release_url = update_info.release_url
    ctx.update_checked_at_label =
        format.date_day_month_hour_minute(update_info.checked_at)
    ctx.update_message = update_info.is_update_available
            and (i18n.tr("Há uma versão nova: ") .. update_info.latest_version)
        or i18n.tr("Você está na versão mais recente.")
end

local function apply_snapshot(snapshot: any): ()
    State.snap = snapshot
    State.last_pairs = format.service_pair_list(snapshot.services)
    -- Selo "aquecido/frio": o daemon decide (ServiceWarmStatus), aqui só se
    -- anexa.
    local warm_by_id = {}
    for _, warm in ipairs(snapshot.service_warm or {}) do
        warm_by_id[warm.service_id] = warm
    end
    State.service_warm_by_id = warm_by_id
    -- Sincronização do estado com o git: serviços com "mudança pendente de deploy"
    -- (um commit externo mudou o declarado) e o resto da situação (aviso do topo,
    -- tela Settings → Sync, órfãos). Ver format/state_git.luau.
    local pending_by_id = format.state_git_pending_ids(snapshot.state_git)
    for _, last_pair in ipairs(State.last_pairs) do
        last_pair.warm = warm_by_id[last_pair.svc.id]
        last_pair.pending_deploy = pending_by_id[last_pair.svc.id] == true
    end
    ctx.service_pending_deploy_visible = pending_by_id[ctx.service_id] == true and "true" or "false"
    for key, value in pairs(format.state_git_context(snapshot.state_git, snapshot.infra_audit)) do
        (ctx :: any)[key] = value
    end
    -- O formulário parte do que o daemon tem gravado, uma vez só (depois é do usuário).
    if snapshot.state_git ~= nil and ctx.state_git_form_loaded ~= "true" then
        local status = snapshot.state_git
        ctx.state_git_form_mode = status.mode or "off"
        ctx.state_git_form_remote = status.remote or ""
        ctx.state_git_form_branch = status.branch or "main"
        ctx.state_git_form_provider_id = status.provider_id or ""
        ctx.state_git_form_loaded = "true"
    end
    -- Detalhe do serviço aberto: o selo acompanha o snapshot.
    local open_warm = warm_by_id[ctx.service_id]
    local open_warm_label, open_warm_color =
        format.service_warm_status(open_warm)
    ctx.service_warm_visible = open_warm ~= nil and "true" or "false"
    ctx.service_warm_label = open_warm_label
    ctx.service_warm_color = open_warm_color
    ctx.service_warm_reason = open_warm ~= nil
            and not open_warm.warming
            and open_warm.reason
        or ""

    -- Status do daemon.
    local daemon_status = snapshot.status
    if daemon_status then
        ctx.daemon_version = daemon_status.version or ""
        ctx.daemon_uptime = format.format_uptime(daemon_status.uptime_secs)
        ctx.services_running = tostring(daemon_status.services_running or 0)
        ctx.services_total = tostring(daemon_status.services_total or 0)
        ctx.services_label = (
            tostring(daemon_status.services_running or 0)
            .. "/"
            .. tostring(daemon_status.services_total or 0)
        )
    end

    -- Deploy Engine (inline, não filtrado pela busca).
    local engine = snapshot.engine
    if engine then
        ctx.deploy_engine_active_count = tostring(#(engine.active or {}))
        ctx.deploy_engine_success_24h = tostring(engine.successful_24h or 0)
        ctx.deploy_engine_failed_24h = tostring(engine.failed_24h or 0)
        ctx.deploy_engine_total_24h = tostring(engine.total_24h or 0)
        ctx.deploy_engine_uptime = format.format_uptime(engine.uptime_secs)
        ctx.deploy_engine_active =
            format.deploy_engine_active(engine.active, State.last_pairs)
        M.refresh_deploy_engine_detail(engine.active)
        ctx.deploy_engine_recent =
            format.deploy_engine_recent(engine.recent, State.last_pairs)
        ctx.deploy_engine_recent_count = tostring(#(engine.recent or {}))
        -- Fila global (um por vez): itens esperando + estado pausado.
        ctx.deploy_engine_queued =
            format.deploy_engine_queued(engine.queued, State.last_pairs)
        ctx.deploy_engine_queued_count = tostring(#(engine.queued or {}))
        ctx.deploy_engine_paused = (engine.paused and "true" or "false")

        -- Timer decorrido do deploy em voo do serviço aberto (granularidade de
        -- 2s — sem timer no Luau, vem do próprio snapshot; ver o plano de
        -- migração). O desfecho terminal vem por DeployStateChanged
        -- (apply_bus_event).
        if
            State.deploy_track.running
            and ctx.selected_service == State.deploy_track.service_id
        then
            for _, active_deployment in ipairs(engine.active or {}) do
                if
                    active_deployment.service_id
                    == State.deploy_track.service_id
                then
                    ctx.service_deploy_running = "true"
                    ctx.service_deploy_elapsed =
                        format.format_seconds(active_deployment.elapsed_secs)
                    break
                end
            end
        end
    end

    -- Contagens.
    ctx.deployments_count = tostring(#(snapshot.deployments or {}))
    ctx.projects_count = tostring(#(snapshot.projects or {}))
    ctx.services_count = tostring(#State.last_pairs)
    ctx.docker_images_count = tostring(#(snapshot.docker_images or {}))
    ctx.docker_volumes_count = tostring(#(snapshot.docker_volumes or {}))
    ctx.docker_networks_count = tostring(#(snapshot.docker_networks or {}))
    ctx.docker_containers_count = tostring(#(snapshot.docker_containers or {}))

    -- Registry OCI embutido: status/config vem sempre; tags de um repo
    -- específico são buscadas sob demanda (ver handlers/registry.luau), não
    -- fazem parte do snapshot.
    local registry_status = snapshot.registry_status
    if registry_status then
        ctx.registry_status_enabled = tostring(registry_status.enabled)
        ctx.registry_host = (
            registry_status.domain and registry_status.domain ~= ""
        )
                and registry_status.domain
            or ("127.0.0.1:" .. tostring(registry_status.port))
        ctx.registry_loopback = "127.0.0.1:" .. tostring(registry_status.port)
        ctx.registry_status_label = registry_status.enabled
                and (i18n.tr("ativo em ") .. ctx.registry_host)
            or "desabilitado"
        ctx.registry_storage_human =
            format.format_bytes(registry_status.storage_bytes or 0)
        -- Retenção de tags: o estado salvo (usado na confirmação do GC) acompanha o
        -- snapshot; o formulário parte dele uma vez só (depois é do usuário).
        local retention = registry_status.retention
        if retention then
            local seen = tostring(retention.enabled == true)
                .. "/"
                .. tostring(retention.keep_last or 10)
            ctx.registry_retention_saved_enabled =
                tostring(retention.enabled == true)
            ctx.registry_retention_saved_keep_last =
                tostring(retention.keep_last or 10)
            -- O formulário parte do servidor e o segue quando o salvo muda (outro
            -- cliente, daemon reiniciado); enquanto o salvo é o mesmo, é do usuário.
            if ctx.registry_retention_seen ~= seen then
                ctx.registry_retention_form_enabled =
                    ctx.registry_retention_saved_enabled
                ctx.registry_retention_form_keep_last =
                    ctx.registry_retention_saved_keep_last
                ctx.registry_retention_seen = seen
            end
        end
    end
    ctx.registry_repos_count = tostring(#(snapshot.registry_repos or {}))

    -- Ingress + Monitoring (não filtrados pela busca).
    local ingress, ingress_count = format.ingress(State.last_pairs)
    ctx.ingress = ingress
    ctx.ingress_count = tostring(ingress_count)
    local host_ports, host_ports_count = format.host_ports(State.last_pairs)
    ctx.host_ports = host_ports
    ctx.host_ports_count = tostring(host_ports_count)
    ctx.monitoring = format.monitoring(State.last_pairs, State.metrics_by_id)

    -- Jobs one-shot (tela global "Schedules"): guarda o Job cru por id
    -- (job_toggle precisa do compose/name/main_service completos pra
    -- reenviar o JobUpdate) e formata as linhas de exibição.
    local jobs_by_id = {}
    for _, job in ipairs(snapshot.jobs or {}) do
        jobs_by_id[job.job.id] = job.job
    end
    State.jobs_by_id = jobs_by_id
    local jobs_summary, jobs_count =
        format.job_summaries(snapshot.jobs, State.jobs_inflight)
    ctx.jobs_summary = jobs_summary
    ctx.jobs_count = tostring(jobs_count)

    -- Só reescreve a aba quando o daemon consultou de novo (checked_at mudou).
    local update_info = snapshot.update
    if
        type(update_info) == "table"
        and update_info.checked_at ~= State.update_checked_at
    then
        M.apply_update_info(update_info)
    end

    rebuild_lists()
    update_open_project()
    ctx.data_loading = "false"
end

-- Refresh imediato após uma mutação: pega o snapshot completo num único RPC (o
-- mesmo builder do SSE) e aplica, para a tela refletir na hora sem esperar o
-- tick de 2s. Chamado pelos handlers de criar/remover projeto/serviço.
local function refresh_now(): ()
    local api = State.api
    if not api then
        return
    end
    local snapshot_ok, snapshot_response = api:rpc("Snapshot")
    if snapshot_ok and snapshot_response.Snapshot then
        local json_decode_ok, decoded =
            pcall(json.decode, snapshot_response.Snapshot)
        if json_decode_ok and type(decoded) == "table" then
            apply_snapshot(decoded)
        end
    end
end
M.refresh_now = refresh_now

-- Limpeza em massa da tela Deployments: apaga (DeployDelete) todo deployment
-- em estado terminal Stopped/Failed do último snapshot recebido — em
-- andamento (Live e os estados intermediários) nunca são tocados, e o próprio
-- handler do daemon recusa apagar um deployment não-terminal como segunda
-- trava.
function deployments_clear_finished(): ()
    local confirmed = confirm {
        title = i18n.tr("Limpar deployments parados/falhos?"),
        message = i18n.tr("Remove do histórico todo deployment em estado Stopped ou Failed, e seus build logs. Deployments em andamento não são afetados. Ação irreversível."),
        confirm_label = i18n.tr("Limpar"),
        destructive = true,
    }

    if not confirmed then
        return
    end

    local api = State.api
    local snap = State.snap
    if not api or not snap then
        return
    end
    local deployment_ids = {}
    for _, deployment in ipairs(snap.deployments or {}) do
        local deployment_info = deployment.deployment
        if
            deployment_info
            and (
                deployment_info.state == "Stopped"
                or deployment_info.state == "Failed"
            )
        then
            table.insert(deployment_ids, deployment_info.id)
        end
    end
    if #deployment_ids == 0 then
        ctx.deployments_message = i18n.tr("nada para limpar")
        return
    end
    ctx.deployments_message =
        string.format(i18n.tr("removendo %d deployment(s)…"), #deployment_ids)
    local removed = 0
    local failed = 0
    for _, deployment_id in ipairs(deployment_ids) do
        local deleted = api:rpc_checked {
            DeployDelete = { deployment_id = deployment_id },
        }
        if deleted then
            removed = removed + 1
        else
            failed = failed + 1
        end
    end
    if failed == 0 then
        ctx.deployments_message = string.format(i18n.tr("%d removido(s)"), removed)
    else
        ctx.deployments_message =
            string.format(i18n.tr("%d removido(s), %d falharam"), removed, failed)
        toast {
            message = tostring(
                string.format(
                    i18n.tr("%d deployment(s) não puderam ser removidos"),
                    failed
                )
            ),
            kind = "error",
        }
    end
    refresh_now()
end

-- Eventos vivos do bus: métricas e transições de deploy. Logs (runtime/build)
-- NÃO passam mais aqui — o daemon os tirou do firehose `/api/events` e os
-- serve por endpoints dedicados, consumidos só pelas janelas de log isoladas
-- (ver log_window.luau; open_logs_window / deployment_logs em
-- handlers/services.luau). Assim a janela principal não reprocessa (reavalia)
-- sua árvore inteira por linha.
local function apply_bus_event(bus_event: any): ()
    -- Eventos unit-variant chegam como string JSON (ex.:
    -- "DeployQueueChanged"), não table — tratados antes do guard de table
    -- abaixo.
    if bus_event == "DeployQueueChanged" then
        -- A fila global mudou: busca um snapshot fresco na hora, sem esperar o
        -- tick de 2s.
        refresh_now()
        return
    end
    if type(bus_event) ~= "table" then
        return
    end
    if bus_event.ServiceWarmChanged then
        local service_warm_event = bus_event.ServiceWarmChanged
        if
            not service_warm_event.warming
            and service_warm_event.success == false
        then
            local failure = service_warm_event.error
                    and (" — " .. tostring(service_warm_event.error))
                or ""
            toast {
                message = tostring(i18n.tr("falha ao aquecer o serviço") .. failure),
                kind = "error",
            }
        end
        refresh_now()
    elseif bus_event.JobWarmChanged then
        local warm_event = bus_event.JobWarmChanged
        if not warm_event.warming then
            local warmed_job = State.jobs_by_id[warm_event.job_id]
            local warmed_job_name = warmed_job and warmed_job.name or "job"
            if warm_event.success then
                toast {
                    message = tostring(warmed_job_name .. " aquecido"),
                    kind = "success",
                }
            else
                local failure = warm_event.error
                        and (" — " .. tostring(warm_event.error))
                    or ""
                toast {
                    message = tostring(
                        warmed_job_name .. i18n.tr(": falha ao aquecer") .. failure
                    ),
                    kind = "error",
                }
            end
        end
        refresh_now()
    elseif bus_event.DockerCleanupCompleted then
        -- Settings.luau não é `require`ado aqui de propósito: ele já requer
        -- este arquivo (`Stream = require("stream")`), e um require cíclico
        -- devolveria um módulo incompleto. Atualiza os 3 campos direto.
        local cleanup_event = bus_event.DockerCleanupCompleted
        ctx.docker_cleanup_running = "false"
        ctx.docker_cleanup_last_run_text =
            format.docker_cleanup_last_run_summary({
                at = cleanup_event.at,
                results = cleanup_event.results,
            })
        State.docker_cleanup_last_run_at_raw = cleanup_event.at
    elseif bus_event.ContainerMetrics then
        local container_metrics = bus_event.ContainerMetrics
        if container_metrics.service_id then
            State.metrics_by_id[container_metrics.service_id] =
                container_metrics
        end
    elseif bus_event.SystemMetrics then
        local system_metrics = bus_event.SystemMetrics
        ctx.host_cpu = string.format("%.0f%%", system_metrics.cpu_percent or 0)
        ctx.host_memory = (
            format.format_bytes(system_metrics.mem_used_bytes)
            .. " / "
            .. format.format_bytes(system_metrics.mem_total_bytes)
        )
        ctx.host_disk = (
            format.format_bytes(system_metrics.disk_used_bytes)
            .. " / "
            .. format.format_bytes(system_metrics.disk_total_bytes)
        )
        ctx.host_load = string.format(
            "%.2f %.2f %.2f",
            system_metrics.load_avg_1 or 0,
            system_metrics.load_avg_5 or 0,
            system_metrics.load_avg_15 or 0
        )
    elseif bus_event.DeployStateChanged then
        local deploy_event = bus_event.DeployStateChanged
        if deploy_event.service_id == State.deploy_track.service_id then
            local terminal = deploy_event.state == "Live"
                or deploy_event.state == "Failed"
                or deploy_event.state == "Stopped"
            if terminal then
                State.deploy_track.running = false
                local live = deploy_event.state == "Live"
                -- Notificação NATIVA DO SO no desfecho: o usuário disparou o
                -- deploy e provavelmente foi fazer outra coisa — o toast
                -- (dentro da janela) não seria visto. Emitida sempre,
                -- independente da tela/serviço selecionado (por isso fora do
                -- gate de selected_service abaixo). app_name = "Rustploy" (não
                -- o nome do binário "rustploy-gui"): há desktops (ex.: certos
                -- GNOME/extensões) que descartam a notificação quando o
                -- app_name casa com um .desktop instalado — o nome de exibição
                -- evita isso. Ver glacier-ui 0.43 e docs/notificacao-so.md.
                local service_name = State.deploy_track.name
                if service_name == "" then
                    service_name = i18n.tr("serviço")
                end
                -- `d.message` é o MOTIVO da falha (o texto do Docker, o
                -- healthcheck que não passou…). O evento sempre o carregou;
                -- era este consumidor que o descartava, e a notificação dizia
                -- só "Failed" — o usuário ficava sem hipótese nenhuma. Ver
                -- docs/plano-erro-de-deploy-invisivel.md.
                local failure_reason =
                    format.short_reason(deploy_event.message)
                if live then
                    notify {
                        title = i18n.tr("Deploy concluído"),
                        body = service_name
                            .. i18n.tr(" está no ar e recebendo tráfego."),
                        app_name = "Rustploy",
                        icon = "rustploy-gui",
                    }
                else
                    notify {
                        title = i18n.tr("Deploy falhou"),
                        body = (
                            failure_reason ~= ""
                                and (service_name .. ": " .. failure_reason)
                            or (
                                service_name
                                .. ": "
                                .. tostring(deploy_event.state)
                                .. "."
                            )
                        ),
                        app_name = "Rustploy",
                        icon = "rustploy-gui",
                    }
                    -- Toast além da notificação do SO: quem está com a janela
                    -- aberta olhando o deploy não recebe notificação em alguns
                    -- desktops, e é justamente quem mais quer ver o motivo.
                    toast {
                        message = service_name
                            .. ": "
                            .. (
                                failure_reason ~= "" and failure_reason
                                or ("deploy " .. tostring(deploy_event.state))
                            ),
                        kind = "error",
                    }
                end
                if deploy_event.service_id == ctx.selected_service then
                    ctx.service_deploy_running = "false"
                    ctx.service_action_message = (
                        live and i18n.tr("deploy concluído")
                        or (
                            failure_reason ~= ""
                                and (i18n.tr("deploy falhou: ") .. failure_reason)
                            or ("deploy: " .. tostring(deploy_event.state))
                        )
                    )
                    ctx.service_action_color = live and "#3FB950" or "#F85149"
                    Services.fetch_service_detail(deploy_event.service_id)
                end
            elseif deploy_event.service_id == ctx.selected_service then
                ctx.service_deploy_running = "true"
                ctx.service_action_message = i18n.tr("deploy · ")
                    .. tostring(deploy_event.state)
                ctx.service_action_color = "#58A6FF"
            end
        end
    end
end

-- Abre a conexão SSE (chamado por connect(), em handlers/connection.luau, após
-- a autenticação inicial dar certo). Os callbacks vão inline: cada um é usado
-- num lugar só, e a assinatura dos três continua conferida contra o que o
-- motor entrega sem precisar anotar variável nenhuma — `glacier.d.luau`
-- declara
-- `sse(url, opts: StreamOptions?)`, então é a própria chamada que checa a
-- tabela literal contra `StreamOptions`.

-- Reconexão: uma queda de rede de poucos segundos não deve devolver a pessoa
-- ao login. Quando o stream cai com a sessão aberta, tenta de novo com espera
-- crescente (1, 2, 4, 8, 16 s); o daemon manda um snapshot ao reconectar, que
-- ressincroniza a tela. Esgotadas as tentativas, vale o caminho antigo
-- ("conexão encerrada" + login). Um aviso de reinício (`restart_notice`, de
-- "Atualizar daemon") não reconecta: o daemon vai demorar e o aviso é do login.
local RECONNECT_DELAYS_MS = { 1000, 2000, 4000, 8000, 16000 }
local reconnect_attempts = 0

function M.open_stream(): ()
    if State.state_conn then
        return
    end

    local sse_url = ctx.api_url .. "/api/events"

    State.state_conn = sse(sse_url, {
        -- Cada evento SSE traz um `data:` JSON auto-descritivo com `kind`.
        -- Nota: o firehose /api/events não manda mais `bus_batch` (era só p/
        -- logs, que saíram daqui — ver apply_bus_event). Os lotes de log vivem
        -- no endpoint dedicado consumido pela janela isolada
        -- (log_window.luau).
        headers = Api.auth_headers(ctx.api_token),

        on_message = function(data: string): ()
            local json_decode_ok, decoded = pcall(json.decode, data)
            if not json_decode_ok or type(decoded) ~= "table" then
                return
            end
            if decoded.kind == "snapshot" then
                reconnect_attempts = 0
                apply_snapshot(decoded)
            elseif decoded.kind == "bus" then
                apply_bus_event(decoded.event)
            end
        end,

        on_error = function(error_message: string): ()
            ctx.status_line = "stream: " .. tostring(error_message)
        end,

        on_close = function(): ()
            State.state_conn = nil
            if not Helpers.is_true(ctx.connected) then
                return
            end

            local delay = RECONNECT_DELAYS_MS[reconnect_attempts + 1]
            if State.restart_notice == "" and delay ~= nil then
                reconnect_attempts += 1
                ctx.status_line =
                    `conexão perdida — reconectando ({reconnect_attempts}/{#RECONNECT_DELAYS_MS})…`
                after(delay, function(): ()
                    if
                        Helpers.is_true(ctx.connected)
                        and State.api ~= nil
                        and State.state_conn == nil
                    then
                        M.open_stream()
                    end
                end)
                return
            end

            reconnect_attempts = 0
            if State.restart_notice ~= "" then
                ctx.status_line = State.restart_notice
                State.restart_notice = ""
            else
                ctx.status_line = i18n.tr("conexão encerrada")
            end
            ctx.connected = "false"
            ctx.screen = "login"
        end,
    })
end

return M