@@ -8,9 +8,18 @@ defmodule RealtimeWeb.Dashboard.Muster do
88 node's health at a glance. Use the node picker (top-right) to switch nodes;
99 the page reads the selected node from `page.node` and RPCs it for the snapshot
1010 (the LiveView itself always runs on the serving node).
11+
12+ It also offers a **group lookup**: given a group id, it resolves the group's
13+ router on the inspected node (`Forum.Muster.router/2`), RPCs that router for
14+ the barrier-gated fan-out targets (`Forum.Muster.targets/3`), and shows both
15+ the inspected node's local member count and the raw occupancy the router holds
16+ for the group. This makes it easy to see, for one group, who would receive a
17+ broadcast and why (targets vs. flood).
1118 """
1219 use Phoenix.LiveDashboard.PageBuilder
1320
21+ alias Realtime.GenRpc
22+
1423 # Lifecycle status -> Bootstrap contextual color.
1524 @ status_colors % {
1625 ready: "success" ,
@@ -26,19 +35,48 @@ defmodule RealtimeWeb.Dashboard.Muster do
2635
2736 @ impl true
2837 def mount ( _params , _session , socket ) do
29- { :ok , assign ( socket , node_data: node_data ( socket . assigns . page . node ) ) }
38+ { :ok , assign ( socket , group_query: nil ) |> refresh_data ( ) }
3039 end
3140
3241 @ impl true
3342 def handle_event ( "refresh" , _params , socket ) do
34- { :noreply , assign ( socket , node_data: node_data ( socket . assigns . page . node ) ) }
43+ { :noreply , refresh_data ( socket ) }
44+ end
45+
46+ @ impl true
47+ def handle_event ( "lookup_group" , % { "group" => group } , socket ) do
48+ query = normalize_group ( group )
49+ { :noreply , socket |> assign ( group_query: query ) |> refresh_data ( ) }
50+ end
51+
52+ @ impl true
53+ def handle_event ( "clear_group" , _params , socket ) do
54+ { :noreply , socket |> assign ( group_query: nil ) |> refresh_data ( ) }
3555 end
3656
3757 # Invoked by LiveDashboard's automatic refresh timer (enabled by default). Without
3858 # this, the timer would tick and re-render but never re-fetch the snapshot.
3959 @ impl true
4060 def handle_refresh ( socket ) do
41- { :noreply , assign ( socket , node_data: node_data ( socket . assigns . page . node ) ) }
61+ { :noreply , refresh_data ( socket ) }
62+ end
63+
64+ # Re-fetches the node snapshot and (if a group is queried) re-runs the group
65+ # lookup, so both stay live across the manual and automatic refresh paths.
66+ defp refresh_data ( socket ) do
67+ node = socket . assigns . page . node
68+
69+ assign ( socket ,
70+ node_data: node_data ( node ) ,
71+ group_data: group_data ( node , socket . assigns . group_query )
72+ )
73+ end
74+
75+ defp normalize_group ( group ) do
76+ case String . trim ( group ) do
77+ "" -> nil
78+ trimmed -> trimmed
79+ end
4280 end
4381
4482 @ impl true
@@ -102,12 +140,95 @@ defmodule RealtimeWeb.Dashboard.Muster do
102140 </ div >
103141 <% end %>
104142 </ div >
143+
144+ < div class = "card mb-3 " >
145+ < div class = "card-header " > < strong > Group lookup</ strong > </ div >
146+ < div class = "card-body " >
147+ < form phx-submit = "lookup_group " class = "d-flex gap-2 align-items-center mb-0 " >
148+ < input
149+ type = "text "
150+ name = "group "
151+ value = { @ group_query }
152+ placeholder = "group id (e.g. tenant id) "
153+ class = "form-control form-control-sm flex-grow-1 "
154+ autocomplete = "off "
155+ />
156+ < button type = "submit " class = "btn btn-sm btn-primary text-nowrap " > Look up</ button >
157+ <%= if @ group_query do %>
158+ < button type = "button " phx-click = "clear_group " class = "btn btn-sm btn-outline-secondary text-nowrap " > Clear</ button >
159+ <% end %>
160+ </ form >
161+
162+ <%= if @ group_data do %>
163+ <%= if @ group_data . error do %>
164+ < div class = "text-danger small mt-3 " > <%= @ group_data . error %> </ div >
165+ <% else %>
166+ < table class = "table table-sm table-hover mb-0 mt-3 " style = "table-layout: fixed " >
167+ < colgroup >
168+ < col style = "width: 16rem " />
169+ < col />
170+ </ colgroup >
171+ < tbody >
172+ < tr > < td class = "ps-3 " > Group</ td > < td class = "text-break " > < code > <%= inspect ( @ group_data . group ) %> </ code > </ td > </ tr >
173+ < tr >
174+ < td class = "ps-3 " > Inspected node</ td >
175+ < td class = "text-break " > <%= @ group_data . node %> </ td >
176+ </ tr >
177+ < tr >
178+ < td class = "ps-3 " > Local members here</ td >
179+ < td > <%= @ group_data . local_member_count %> </ td >
180+ </ tr >
181+ < tr >
182+ < td class = "ps-3 " > Sender view hash</ td >
183+ < td class = "text-break " > < code > <%= inspect ( @ group_data . view_hash ) %> </ code > </ td >
184+ </ tr >
185+ < tr >
186+ < td class = "ps-3 " > Router (< code > router/2</ code > )</ td >
187+ < td class = "text-break " > <%= fmt_router ( @ group_data . router ) %> </ td >
188+ </ tr >
189+ < tr >
190+ < td class = "ps-3 " > Targets via router (< code > targets/3</ code > )</ td >
191+ < td class = "text-break " > <%= fmt_targets ( @ group_data . targets ) %> </ td >
192+ </ tr >
193+ < tr >
194+ < td class = "ps-3 " > Router occupancy (raw, count)</ td >
195+ < td class = "text-break " > <%= fmt_occupancy ( @ group_data . router_occupancy ) %> </ td >
196+ </ tr >
197+ </ tbody >
198+ </ table >
199+ <% end %>
200+ <% end %>
201+ </ div >
202+ </ div >
105203 </ div >
106204 """
107205 end
108206
109207 defp status_color ( status ) , do: Map . get ( @ status_colors , status , "secondary" )
110208
209+ defp fmt_router ( { :ok , node } ) , do: to_string ( node )
210+ defp fmt_router ( { :rebalancing , nodes } ) , do: "rebalancing — fan out to #{ inspect ( nodes ) } "
211+
212+ defp fmt_targets ( { :ok , nodes } ) ,
213+ do: "#{ inspect ( Enum . sort ( nodes ) ) } (#{ length ( nodes ) } node#{ plural ( nodes ) } )"
214+
215+ defp fmt_targets ( { :error , :flood } ) ,
216+ do: "flood — barrier not satisfied (converging, or sender/router view mismatch)"
217+
218+ defp fmt_targets ( { :rebalancing , nodes } ) ,
219+ do: "rebalancing — no single router; fan out to #{ inspect ( nodes ) } "
220+
221+ defp fmt_targets ( { :error , :rpc_error , reason } ) , do: "router RPC failed: #{ inspect ( reason ) } "
222+
223+ defp fmt_occupancy ( nodes ) when is_list ( nodes ) ,
224+ do: "#{ inspect ( Enum . sort ( nodes ) ) } (#{ length ( nodes ) } )"
225+
226+ defp fmt_occupancy ( { :rebalancing , _nodes } ) , do: "—"
227+ defp fmt_occupancy ( { :error , :rpc_error , reason } ) , do: "router RPC failed: #{ inspect ( reason ) } "
228+
229+ defp plural ( [ _ ] ) , do: ""
230+ defp plural ( _ ) , do: "s"
231+
111232 # The page LiveView always runs on the serving node; `page.node` is the node
112233 # chosen in the picker (a URL param). So we gather locally only when that node
113234 # IS us, and RPC to gather_local_info/0 on the selected node otherwise.
@@ -152,6 +273,99 @@ defmodule RealtimeWeb.Dashboard.Muster do
152273 end
153274 end
154275
276+ # Group lookup: reads the inspected node's local routing state, then (on the
277+ # serving node) asks that state's router node for its targets/occupancy — the
278+ # two hops kept flat rather than nested. nil group => no lookup.
279+ defp group_data ( _target , nil ) , do: nil
280+
281+ defp group_data ( target , group ) do
282+ base = % { group: group , node: target , error: nil }
283+
284+ # Two independent hops, orchestrated here on the serving node so neither RPC
285+ # is nested inside the other: first read the inspected node's local routing
286+ # state, then ask that state's router node for its targets/occupancy.
287+ case GenRpc . call ( target , __MODULE__ , :gather_local_group_info , [ group ] , [ ] ) do
288+ { :ok , local } ->
289+ { targets , occupancy } = router_view ( local . scope , group , local . view_hash , local . router )
290+
291+ Map . merge ( base , % {
292+ local_member_count: local . local_member_count ,
293+ view_hash: local . view_hash ,
294+ router: local . router ,
295+ targets: targets ,
296+ router_occupancy: occupancy
297+ } )
298+
299+ { :error , :rpc_error , reason } ->
300+ Map . put ( base , :error , "RPC failed: #{ inspect ( reason ) } " )
301+
302+ { :error , msg } ->
303+ Map . put ( base , :error , msg )
304+ end
305+ end
306+
307+ @ doc """
308+ Reads the inspected node's local routing state for `group`: the configured
309+ scope, this node's cluster-view hash (the sender hash used for `targets/3`),
310+ the group's router as this node's ring sees it (`Forum.Muster.router/2`), and
311+ the local member count here.
312+
313+ Runs on the node being inspected so `router/2` and the view hash reflect that
314+ node. `group_data/2` then asks the returned router node for `targets/3` and its
315+ raw occupancy, keeping the two hops flat rather than nesting one RPC in another.
316+
317+ Returns `{:error, message}` when no scope is configured or the coordinator has
318+ not published its state yet, so the page can surface a friendly error.
319+ """
320+ def gather_local_group_info ( group ) do
321+ case Application . get_env ( :realtime , :muster_scope ) do
322+ nil ->
323+ { :error , "No Muster scope configured on this node" }
324+
325+ scope ->
326+ try do
327+ { :ok ,
328+ % {
329+ scope: scope ,
330+ view_hash: Forum.Muster . view_hash ( scope ) ,
331+ router: Forum.Muster . router ( scope , group ) ,
332+ local_member_count: Forum.Muster . local_member_count ( scope , group )
333+ } }
334+ rescue
335+ ArgumentError ->
336+ { :error , "Muster scope #{ inspect ( scope ) } has no published state on this node yet" }
337+ catch
338+ :exit , reason -> { :error , "Muster group lookup failed: #{ inspect ( reason ) } " }
339+ end
340+ end
341+ end
342+
343+ # With a settled ring, ask the router node for its targets/3 and raw occupancy
344+ # in a single RPC; while rebalancing there is no single router, so surface the
345+ # fan-out list. GenRpc.call handles router_node == node() itself; on RPC failure
346+ # both slots carry the {:error, :rpc_error, _} for the fmt_* helpers to render.
347+ defp router_view ( scope , group , view_hash , { :ok , router_node } ) do
348+ case GenRpc . call ( router_node , __MODULE__ , :router_group_info , [ scope , group , view_hash ] , [ ] ) do
349+ { :error , :rpc_error , _ } = error -> { error , error }
350+ { targets , occupancy } -> { targets , occupancy }
351+ end
352+ end
353+
354+ defp router_view ( _scope , _group , _view_hash , { :rebalancing , nodes } = rebalancing ) do
355+ { rebalancing , { :rebalancing , nodes } }
356+ end
357+
358+ @ doc """
359+ Reads the router-role view of `group` on this (router) node in one hop: the
360+ barrier-gated fan-out targets (`Forum.Muster.targets/3`) and the raw occupancy
361+ the router currently holds (`Forum.Muster.Scope.occupancy/2`). Returned as
362+ `{targets_result, occupancy}` so `group_data/2` fetches both with a single RPC
363+ to the router.
364+ """
365+ def router_group_info ( scope , group , view_hash ) do
366+ { Forum.Muster . targets ( scope , group , view_hash ) , Forum.Muster . occupancy ( scope , group ) }
367+ end
368+
155369 defp to_view ( s ) do
156370 counts = s . group_state_counts
157371 group_counts = Enum . map ( @ group_state_order , fn state -> { state , Map . get ( counts , state , 0 ) } end )
0 commit comments