neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Runners: the settings pages announce a runner's first connection
8b605ac · Gabriel Morell · 2026-10-27 04:53
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