Runners: the settings pages announce a runner's first connection

8b605ac · Gabriel Morell · 2026-10-27 04:53

14 files +185 -4
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/workflows/runners.ex
+42 −0
@@ -386,12 +386,54 @@ defmodule GitGud.Workflows.Runners do
386 386
387 387 def find_by_uuid_and_token(_, _), do: nil
388 388
389 + @doc """
390 + Record that `runner` was just heard from. The first time — a runner
391 + that has never connected — is announced on its scope's `topic/1` as
392 + `{:runner_connected, %{id: id, name: name}}`, once: the update only
393 + matches while `last_seen_at` is still nil, so of two polls racing on
394 + a new runner only one announces it.
395 + """
396 + def touch_last_seen(%Runner{last_seen_at: nil} = runner) do
397 + now = DateTime.utc_now(:second)
398 +
399 + case Repo.update_all(
400 + from(r in Runner, where: r.id == ^runner.id and is_nil(r.last_seen_at)),
401 + set: [last_seen_at: now]
402 + ) do
403 + {1, _} ->
404 + runner = %{runner | last_seen_at: now}
405 +
406 + Phoenix.PubSub.broadcast(
407 + GitGud.PubSub,
408 + topic_for(runner),
409 + {:runner_connected, %{id: runner.id, name: runner.name}}
410 + )
411 +
412 + {:ok, runner}
413 +
414 + {0, _} ->
415 + touch_last_seen(%{runner | last_seen_at: now})
416 + end
417 + end
418 +
389 419 def touch_last_seen(%Runner{} = runner) do
390 420 runner
391 421 |> Ecto.Changeset.change(last_seen_at: DateTime.utc_now(:second))
392 422 |> Repo.update()
393 423 end
394 424
425 + @doc """
426 + The PubSub topic a runners page follows for the runners of `scope`:
427 + a repository's, an organization's, or the global ones (`:global`).
428 + """
429 + def topic(%Repository{id: id}), do: "runners:repo:#{id}"
430 + def topic(%Organization{id: id}), do: "runners:org:#{id}"
431 + def topic(:global), do: "runners:global"
432 +
433 + defp topic_for(%Runner{repository_id: rid}) when is_integer(rid), do: "runners:repo:#{rid}"
434 + defp topic_for(%Runner{organization_id: oid}) when is_integer(oid), do: "runners:org:#{oid}"
435 + defp topic_for(%Runner{}), do: topic(:global)
436 +
395 437 @doc """
396 438 Delete a runner. Caller is responsible for authorization (repo admin
397 439 for repo-scoped, org admin for org-scoped, app admin for global).
modified lib/git_gud_web/live/org_live/settings_runners.ex
+21 −2
@@ -32,6 +32,13 @@ defmodule GitGudWeb.OrgLive.SettingsRunners do
32 32 |> redirect(to: ~p"/orgs/#{handle}")}
33 33
34 34 true ->
35 + # A runner of this org's, or a global one, connecting for the
36 + # first time appears here without a reload.
37 + if connected?(socket) do
38 + Phoenix.PubSub.subscribe(GitGud.PubSub, Runners.topic(org))
39 + Phoenix.PubSub.subscribe(GitGud.PubSub, Runners.topic(:global))
40 + end
41 +
35 42 {:ok,
36 43 socket
37 44 |> assign(:org, org)
@@ -193,6 +200,14 @@ defmodule GitGudWeb.OrgLive.SettingsRunners do
193 200 end
194 201 end
195 202
203 + @impl true
204 + def handle_info({:runner_connected, %{name: name}}, socket) do
205 + {:noreply,
206 + socket
207 + |> load_runners()
208 + |> put_flash(:info, "Runner '#{name}' connected.")}
209 + end
210 +
196 211 defp runner_attrs(scope, runner, metadata) do
197 212 scope
198 213 |> Runners.audit_scope()
@@ -308,11 +323,15 @@ defmodule GitGudWeb.OrgLive.SettingsRunners do
308 323
309 324 <section class="space-y-2">
310 325 <h2 class="font-semibold">Runners eligible for this org</h2>
311 − <ul class="divide-y divide-base-300 border border-base-300 rounded">
326 + <ul id="runners" class="divide-y divide-base-300 border border-base-300 rounded">
312 327 <li :if={@runners == []} class="px-3 py-3 text-sm opacity-60">
313 328 No runners registered yet.
314 329 </li>
315 − <li :for={r <- @runners} class="px-3 py-3 flex items-baseline justify-between gap-3">
330 + <li
331 + :for={r <- @runners}
332 + id={"runner-row-#{r.id}"}
333 + class="px-3 py-3 flex items-baseline justify-between gap-3"
334 + >
316 335 <div class="space-y-0.5">
317 336 <p class="font-mono text-sm">{r.name}</p>
318 337 <p class="text-xs opacity-60">
modified lib/git_gud_web/live/repo_live/settings_runners.ex
+23 −2
@@ -41,6 +41,15 @@ defmodule GitGudWeb.RepoLive.SettingsRunners do
41 41 |> redirect(to: ~p"/r/#{owner}/#{name}")}
42 42
43 43 true ->
44 + # A runner this repo can use — its own, its org's or a global
45 + # one — connecting for the first time appears here without a
46 + # reload.
47 + if connected?(socket) do
48 + for scope <- [repo, repo.organization, :global], scope do
49 + Phoenix.PubSub.subscribe(GitGud.PubSub, Runners.topic(scope))
50 + end
51 + end
52 +
44 53 {:ok,
45 54 socket
46 55 |> assign(:repo, repo)
@@ -211,6 +220,14 @@ defmodule GitGudWeb.RepoLive.SettingsRunners do
211 220 end
212 221 end
213 222
223 + @impl true
224 + def handle_info({:runner_connected, %{name: name}}, socket) do
225 + {:noreply,
226 + socket
227 + |> load_runners()
228 + |> put_flash(:info, "Runner '#{name}' connected.")}
229 + end
230 +
214 231 defp runner_attrs(scope, runner, metadata) do
215 232 scope
216 233 |> Runners.audit_scope()
@@ -340,11 +357,15 @@ defmodule GitGudWeb.RepoLive.SettingsRunners do
340 357
341 358 <section class="space-y-2 max-w-3xl">
342 359 <h2 class="font-semibold">Runners eligible for this repo</h2>
343 − <ul class="divide-y divide-base-300 border border-base-300 rounded">
360 + <ul id="runners" class="divide-y divide-base-300 border border-base-300 rounded">
344 361 <li :if={@runners == []} class="px-3 py-3 text-sm opacity-60">
345 362 No runners registered yet.
346 363 </li>
347 − <li :for={r <- @runners} class="px-3 py-3 flex items-baseline justify-between gap-3">
364 + <li
365 + :for={r <- @runners}
366 + id={"runner-row-#{r.id}"}
367 + class="px-3 py-3 flex items-baseline justify-between gap-3"
368 + >
348 369 <div class="space-y-0.5">
349 370 <p class="font-mono text-sm">{r.name}</p>
350 371 <p class="text-xs opacity-60">
added test/git_gud_web/live/runner_connected_test.exs
+99 −0
@@ -0,0 +1,99 @@
1 +defmodule GitGudWeb.RunnerConnectedTest do
2 + @moduledoc """
3 + A runner's first connection shows on the runners pages that list it,
4 + without a reload: the list refreshes and a toast says so. Announced
5 + once, however many polls race on the new runner.
6 + """
7 + use GitGudWeb.ConnCase, async: false
8 +
9 + import Phoenix.LiveViewTest
10 + import GitGud.AccountsFixtures
11 + import GitGud.ForgeFixtures
12 +
13 + alias GitGud.Organizations
14 + alias GitGud.Repositories.Storage
15 + alias GitGud.Workflows.Runners
16 +
17 + defp repo_page(conn) do
18 + {owner, repo} = repository_fixture()
19 + repo = GitGud.Repo.preload(repo, [:owner, :organization])
20 +
21 + {:ok, lv, _} =
22 + live(
23 + log_in_user(conn, owner),
24 + "/r/#{Storage.repo_handle(repo)}/#{repo.name}/settings/runners"
25 + )
26 +
27 + {repo, lv}
28 + end
29 +
30 + test "a repository's runner connecting refreshes the list, with a toast", %{conn: conn} do
31 + {repo, _} = repo_page(conn)
32 + {:ok, runner, _uuid, _token} = Runners.create_offline(%{name: "caprica"}, repo)
33 +
34 + # Opened after the runner exists, so it starts out never connected.
35 + {:ok, lv, _} =
36 + live(
37 + log_in_user(conn, repo.owner),
38 + "/r/#{Storage.repo_handle(repo)}/#{repo.name}/settings/runners"
39 + )
40 +
41 + assert has_element?(lv, "#runner-row-#{runner.id}", "never connected")
42 + refute has_element?(lv, "#flash-info")
43 +
44 + {:ok, _} = Runners.touch_last_seen(runner)
45 +
46 + assert has_element?(lv, "#runner-row-#{runner.id}", "last seen")
47 + assert has_element?(lv, "#flash-info", "Runner 'caprica' connected.")
48 + end
49 +
50 + test "a global runner connecting shows on a repository's page too", %{conn: conn} do
51 + {_repo, lv} = repo_page(conn)
52 + {:ok, runner, _uuid, _token} = Runners.create_offline(%{name: "pool"})
53 +
54 + {:ok, _} = Runners.touch_last_seen(runner)
55 +
56 + assert has_element?(lv, "#runner-row-#{runner.id}", "last seen")
57 + assert has_element?(lv, "#flash-info", "Runner 'pool' connected.")
58 + end
59 +
60 + test "another repository's runner connecting doesn't", %{conn: conn} do
61 + {_repo, lv} = repo_page(conn)
62 + {_other_owner, other} = repository_fixture()
63 + {:ok, runner, _uuid, _token} = Runners.create_offline(%{name: "elsewhere"}, other)
64 +
65 + {:ok, _} = Runners.touch_last_seen(runner)
66 +
67 + refute has_element?(lv, "#runner-row-#{runner.id}")
68 + refute has_element?(lv, "#flash-info")
69 + end
70 +
71 + test "an org's runner connecting shows on the org's page", %{conn: conn} do
72 + admin = user_fixture()
73 + handle = "rc#{System.unique_integer([:positive])}"
74 + {:ok, org} = Organizations.create_organization(admin, %{"handle" => handle, "name" => handle})
75 + {:ok, runner, _uuid, _token} = Runners.create_offline(%{name: "builder"}, org)
76 +
77 + {:ok, lv, _} = live(log_in_user(conn, admin), ~p"/orgs/#{handle}/settings/runners")
78 + assert has_element?(lv, "#runner-row-#{runner.id}", "never connected")
79 +
80 + {:ok, _} = Runners.touch_last_seen(runner)
81 +
82 + assert has_element?(lv, "#runner-row-#{runner.id}", "last seen")
83 + assert has_element?(lv, "#flash-info", "Runner 'builder' connected.")
84 + end
85 +
86 + test "announced on the first connection only" do
87 + {:ok, runner, _uuid, _token} = Runners.create_offline(%{name: "once"})
88 + Phoenix.PubSub.subscribe(GitGud.PubSub, Runners.topic(:global))
89 +
90 + # Two polls holding the same never-connected runner, as two requests
91 + # racing on a new runner would.
92 + {:ok, _} = Runners.touch_last_seen(runner)
93 + {:ok, _} = Runners.touch_last_seen(runner)
94 +
95 + assert_receive {:runner_connected, %{name: "once"}}
96 + refute_receive {:runner_connected, _}
97 + assert GitGud.Repo.reload!(runner).last_seen_at
98 + end
99 +end

Parents: a28ca1a