neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Say which clients are on the other end, not just how many
4fa4e81 · Gabriel Morell · 2026-09-03 00:31
Message
{commit_body(@commit)}
Files changed
modified
apps/room_sanctum/lib/room_sanctum/workers/ankyra_worker.ex
+24
−10
@@ -68,7 +68,7 @@ defmodule RoomSanctum.Worker.Ankyra do
| 68 | 68 | parent = self() |
| 69 | 69 | username = state.ankyra.username |
| 70 | 70 | |
| 71 | − spawn(fn -> send(parent, {:status_result, count_mqtt_connections(username)}) end) | |
| 71 | + spawn(fn -> send(parent, {:status_result, connected_clients(username)}) end) | |
| 72 | 72 | |
| 73 | 73 | {:noreply, state} |
| 74 | 74 | end |
@@ -115,7 +115,8 @@ defmodule RoomSanctum.Worker.Ankyra do
| 115 | 115 | end |
| 116 | 116 | |
| 117 | 117 | @doc """ |
| 118 | − How many clients are attached to this Ankyra, or nil if we could not find out. | |
| 118 | + Who is attached to this Ankyra, as `%{count: n, client_ids: ids}` -- or nil | |
| 119 | + if we could not find out. | |
| 119 | 120 | |
| 120 | 121 | RabbitMQ will not answer this over AMQP. An MQTT subscriber's queue is |
| 121 | 122 | exclusive to the connection that made it, so a passive `queue.declare` from |
@@ -126,23 +127,36 @@ defmodule RoomSanctum.Worker.Ankyra do
| 126 | 127 | receiving. |
| 127 | 128 | |
| 128 | 129 | The management API does know. Every connection carries the protocol it |
| 129 | − speaks and the user it authenticated as, and an Ankyra *is* a rabbit user -- | |
| 130 | − so MQTT connections under that username are exactly its clients. Counting | |
| 131 | − connections rather than probing per-client queues also drops the dependency | |
| 132 | − on `client_ids`, which this worker only re-reads once a minute. | |
| 130 | + speaks, the user it authenticated as, and -- for MQTT -- the client id it | |
| 131 | + announced. An Ankyra *is* a rabbit user, so MQTT connections under that | |
| 132 | + username are exactly its clients, and their ids are the ones the allow-list | |
| 133 | + is written in. | |
| 133 | 134 | |
| 134 | − nil rather than 0 when the API cannot be reached: "we do not know" and | |
| 135 | + `count` is every matching connection; `client_ids` only those that announced | |
| 136 | + one. They are not always the same number, which is why the count is not | |
| 137 | + taken from the length of the list: a connection with no client id still has | |
| 138 | + someone on the end of it. | |
| 139 | + | |
| 140 | + nil rather than zero when the API cannot be reached: "we do not know" and | |
| 135 | 141 | "nobody is listening" are different things, and the page draws them |
| 136 | 142 | differently. Reporting the unreachable case as zero is the bug this replaces. |
| 137 | 143 | """ |
| 138 | − def count_mqtt_connections(username) do | |
| 144 | + def connected_clients(username) do | |
| 139 | 145 | cfg = Application.get_env(:room_sanctum, :rabbit_mgmt, []) |
| 140 | 146 | |
| 141 | 147 | with url when is_binary(url) <- cfg[:url], |
| 142 | 148 | {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- fetch_connections(url, cfg), |
| 143 | 149 | {:ok, connections} when is_list(connections) <- Jason.decode(body) do |
| 144 | 150 | vhost = cfg[:vhost] || "/" |
| 145 | − Enum.count(connections, &mqtt_connection_for?(&1, username, vhost)) | |
| 151 | + mine = Enum.filter(connections, &mqtt_connection_for?(&1, username, vhost)) | |
| 152 | + | |
| 153 | + client_ids = | |
| 154 | + mine | |
| 155 | + |> Enum.map(&get_in(&1, ["client_properties", "client_id"])) | |
| 156 | + |> Enum.filter(&is_binary/1) | |
| 157 | + |> Enum.sort() | |
| 158 | + | |
| 159 | + %{count: length(mine), client_ids: client_ids} | |
| 146 | 160 | else |
| 147 | 161 | other -> |
| 148 | 162 | Logger.debug("ankyra: could not read connections from management API: #{inspect(other)}") |
@@ -156,7 +170,7 @@ defmodule RoomSanctum.Worker.Ankyra do
| 156 | 170 | # ever sent and the badge stuck on "checking" forever. |
| 157 | 171 | defp fetch_connections(url, cfg) do |
| 158 | 172 | HTTPoison.get( |
| 159 | − String.trim_trailing(url, "/") <> "/api/connections?columns=protocol,user,vhost", | |
| 173 | + String.trim_trailing(url, "/") <> "/api/connections?columns=protocol,user,vhost,client_properties.client_id", | |
| 160 | 174 | [{"accept", "application/json"}], |
| 161 | 175 | hackney: [basic_auth: {to_string(cfg[:username]), to_string(cfg[:password])}], |
| 162 | 176 | timeout: 2_000, |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_ankyra/show.ex
+22
−2
@@ -12,6 +12,7 @@ defmodule RoomSanctumWeb.AnkyraLive.Show do
| 12 | 12 | {:ok, |
| 13 | 13 | socket |
| 14 | 14 | |> assign(:consumer_count, nil) |
| 15 | + |> assign(:connected_client_ids, []) | |
| 15 | 16 | |> assign(:broker, default_broker())} |
| 16 | 17 | end |
| 17 | 18 |
@@ -25,8 +26,20 @@ defmodule RoomSanctumWeb.AnkyraLive.Show do
| 25 | 26 | end |
| 26 | 27 | |
| 27 | 28 | @impl true |
| 28 | − def handle_info({:ankyra_status, count}, socket) do | |
| 29 | − {:noreply, assign(socket, :consumer_count, count)} | |
| 29 | + def handle_info({:ankyra_status, nil}, socket) do | |
| 30 | + # The broker could not be asked. Say nothing rather than report an empty | |
| 31 | + # room -- the badge draws nil as "checking". | |
| 32 | + {:noreply, | |
| 33 | + socket | |
| 34 | + |> assign(:consumer_count, nil) | |
| 35 | + |> assign(:connected_client_ids, [])} | |
| 36 | + end | |
| 37 | + | |
| 38 | + def handle_info({:ankyra_status, %{count: count, client_ids: client_ids}}, socket) do | |
| 39 | + {:noreply, | |
| 40 | + socket | |
| 41 | + |> assign(:consumer_count, count) | |
| 42 | + |> assign(:connected_client_ids, client_ids)} | |
| 30 | 43 | end |
| 31 | 44 | |
| 32 | 45 | @impl true |
@@ -70,6 +83,13 @@ defmodule RoomSanctumWeb.AnkyraLive.Show do
| 70 | 83 | {:noreply, assign_pairing(socket, broker)} |
| 71 | 84 | end |
| 72 | 85 | |
| 86 | + # A client that auto-registers after this page was rendered is connected and | |
| 87 | + # allowed, but the copy of the allow-list in the socket predates it. Drawing | |
| 88 | + # it anyway beats a client that is plainly attached and nowhere on screen. | |
| 89 | + def unregistered_connected(ankyra, connected_client_ids) do | |
| 90 | + connected_client_ids -- (ankyra.client_ids || []) | |
| 91 | + end | |
| 92 | + | |
| 73 | 93 | defp assign_pairing(socket, broker) do |
| 74 | 94 | uri = pairing_uri(socket.assigns.ankyra, broker) |
| 75 | 95 |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_ankyra/show.html.heex
+13
−2
@@ -28,7 +28,7 @@
| 28 | 28 | <i class="fas fa-fw fa-plug-circle-xmark"></i> no subscribers |
| 29 | 29 | </span> |
| 30 | 30 | <% n -> %> |
| 31 | − <span class="badge badge-success gap-2"> | |
| 31 | + <span class="badge badge-success gap-2" title={Enum.join(@connected_client_ids, ", ")}> | |
| 32 | 32 | <i class="fas fa-fw fa-plug"></i> <%= n %> subscriber<%= if n == 1, do: "", else: "s" %> |
| 33 | 33 | </span> |
| 34 | 34 | <% end %> |
@@ -123,13 +123,18 @@
| 123 | 123 | </div> |
| 124 | 124 | |
| 125 | 125 | <div class="flex flex-wrap gap-2 my-2"> |
| 126 | − <%= if @ankyra.client_ids == [] do %> | |
| 126 | + <%= if @ankyra.client_ids == [] and @connected_client_ids == [] do %> | |
| 127 | 127 | <span class="text-sm opacity-60"> |
| 128 | 128 | <%= if @ankyra.auto_register_clients, do: "No client IDs yet — first connection will be auto-registered.", else: "No client IDs registered. Add one below or enable auto-register; otherwise all connections will be denied." %> |
| 129 | 129 | </span> |
| 130 | 130 | <% else %> |
| 131 | 131 | <%= for cid <- @ankyra.client_ids do %> |
| 132 | 132 | <div class="badge badge-secondary gap-2 p-3"> |
| 133 | + <%= if cid in @connected_client_ids do %> | |
| 134 | + <i class="fas fa-fw fa-circle text-success text-[0.5rem]" title="Connected now"></i> | |
| 135 | + <% else %> | |
| 136 | + <i class="fas fa-fw fa-circle opacity-30 text-[0.5rem]" title="Not connected"></i> | |
| 137 | + <% end %> | |
| 133 | 138 | <span class="font-mono"><%= cid %></span> |
| 134 | 139 | <button |
| 135 | 140 | phx-click="remove-client-id" |
@@ -141,6 +146,12 @@
| 141 | 146 | </button> |
| 142 | 147 | </div> |
| 143 | 148 | <% end %> |
| 149 | + <%= for cid <- unregistered_connected(@ankyra, @connected_client_ids) do %> | |
| 150 | + <div class="badge badge-secondary badge-outline gap-2 p-3" title="Connected and registered since this page was loaded — reload to manage it"> | |
| 151 | + <i class="fas fa-fw fa-circle text-success text-[0.5rem]"></i> | |
| 152 | + <span class="font-mono"><%= cid %></span> | |
| 153 | + </div> | |
| 154 | + <% end %> | |
| 144 | 155 | <% end %> |
| 145 | 156 | </div> |
| 146 | 157 |
Parents: 88dfaaf