Say which clients are on the other end, not just how many

4fa4e81 · Gabriel Morell · 2026-09-03 00:31

11 files +59 -14
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