The LiveView half of the dashboard overview — the data and the message
handling that back PhoenixKitWeb.Components.Core.DashboardOverview.
Used by /admin (PhoenixKitWeb.Live.Dashboard), whose operator half the
overview is. The deprecated /dashboard
(PhoenixKitWeb.Live.Dashboard.Index) is a separate page and does NOT use
this.
Gate the DATA, not only the markup
Hiding a card in HEEx is cosmetic — the operator aggregates would still have
run. assign_scope_gates/1 therefore decides FIRST and works SECOND: a
visitor who may not see the statistics causes
- no
Roles.get_extended_stats/0,Sessions.get_session_stats/0orPresence.get_presence_stats/0, - no migration-version read,
- and no
Events.subscribe_to_*at all,
and the six statistics assigns are set to nil.
One gate site, re-run on every scope change
Every scope-derived assign on this page — :can_access_admin_area?, the six
card gates, :show_statistics, the six statistics assigns — is computed in
assign_scope_gates/1 and NOWHERE else. Mount calls it through
assign_overview/3; a mid-session permission change calls it through
phoenix_kit_scope_changed/1, the callback use injects and
PhoenixKitWeb.Users.Auth's scope-refresh hook invokes right after it
reassigns :phoenix_kit_current_scope. A gate added to that function is
therefore recomputed for free, and cannot be the one that gets forgotten.
This matters because /admin is the guaranteed landing: a visitor is NOT
evicted from it when their rights change, so the page has to survive the
change in place. Without recomputation a revoked operator kept a card
pointing at a page that now rejects them, beside a sidebar (which re-derives
every render) that disagreed.
The statistics subscriptions move in both directions with the gate:
sync_statistics_subscription/2 subscribes a visitor who gains the rights
mid-session and unsubscribes one who loses them. An unsubscribe, not just a
hidden card — a hidden card still costs three aggregates per broadcast.
PhoenixKit.Admin.Events had no unsubscribe counterpart before this page
needed one; it does now (unsubscribe_from_stats/0 and friends), because
PhoenixKit runs its own PubSub instance and a caller cannot reach
Phoenix.PubSub.unsubscribe/2 for it.
refresh_statistics/1 re-derives the verdict from the CURRENT scope rather
than trusting :show_statistics, because phx-click events are
client-supplied — the button being hidden is not a guarantee that the event
cannot arrive.
Visibility rules
| Block | Gate |
|---|---|
| Users / Roles / Sessions / Live Activity / Add User cards | PhoenixKitWeb.Users.Auth.can_access_admin_view?/2 on each card's destination LiveView |
| Email card | the module being loaded and enabled, AND can_access_admin_view?/2 on its admin LiveView |
| Platform Statistics, System Information, Refresh | Scope.holds_all_enabled_permissions?/1 |
The card rule is derived, never restated: the card and the page it links to ask the same function, so "a card is visible iff the visitor can open it" holds by construction. The statistics have no destination LiveView to derive from, so they use the role-agnostic "can reach everything" check — the same posture the unmapped-admin-view fallback takes.
Usage
use PhoenixKitWeb.Live.Dashboard.Overview
def mount(_params, session, socket) do
{:ok, Overview.assign_overview(socket, session, Routes.path("/admin"))}
enduse injects three things: handle_event("refresh_stats", …), ONE
handle_info/2 clause guarded by is_overview_message/1, and the
phoenix_kit_scope_changed/1 callback described above.
That single guarded clause replaced nine shape-matched ones deliberately. A
socket can still be handed a message from a topic it has just left — the
unsubscribe and the broadcast race — and it will also see any FUTURE arity of
a message it does subscribe to. Nine exact-shape clauses turn either into an
unmatched handle_info and a crashed LiveView. The guard matches on the
message TAG alone and apply_statistics_message/2 ends in a catch-all, so an
in-flight or reshaped message is a no-op rather than a crash — and, because
that function re-checks the gate first, never an operator query on behalf of
someone who just lost the permission.
The guard is deliberately narrow: it names ten tags and nothing else, so a
host LiveView keeps full control of every other message. It does mean any
additional handle_event/handle_info clauses must be defined together with
these (Elixir warns when clauses of one function are not grouped, and
mix precommit compiles with --warnings-as-errors).
The attrs are passed to the component one by one rather than via a
Map.take/2 spread: an explicit attr keeps LiveView's change tracking, so a
presence update re-sends only the tile that changed instead of the whole
overview.
Summary
Functions
Injects the statistics event clause, the statistics message clause and the scope-change callback into a dashboard LiveView.
Applies one statistics/session/presence broadcast to the socket.
Mount-time entry point: tracks the visitor's presence on page_path, then
computes every gate through assign_scope_gates/1.
Computes EVERY scope-derived assign on the dashboard from
:phoenix_kit_current_scope, and reconciles the statistics subscriptions
with the verdict.
Whether message is a broadcast from one of the three statistics topics.
Re-runs the operator aggregates behind the Refresh button.
Whether scope may see the operator statistics — Platform Statistics, System
Information and the Refresh button.
Functions
Injects the statistics event clause, the statistics message clause and the scope-change callback into a dashboard LiveView.
See the module documentation for what this means for a host LiveView that
defines handle_event/3 or handle_info/2 clauses of its own.
@spec apply_statistics_message(Phoenix.LiveView.Socket.t(), tuple()) :: Phoenix.LiveView.Socket.t()
Applies one statistics/session/presence broadcast to the socket.
The body of the single handle_info/2 clause use injects. Two guarantees
a set of exact-shape handle_info clauses could not give:
- it never crashes. Any message the guard admits is handled — including one whose shape changed and one that arrived after this socket left the topic (unsubscribe races a broadcast already in flight). The fallback is a stale tile, not a dead LiveView.
- it never queries for someone who may not see the result. The gate is
re-checked before anything runs, so a revoked operator's in-flight
messages cost nothing, and the three
reload_*aggregates stop with the permission rather than with the socket.
@spec assign_overview(Phoenix.LiveView.Socket.t(), map(), String.t()) :: Phoenix.LiveView.Socket.t()
Mount-time entry point: tracks the visitor's presence on page_path, then
computes every gate through assign_scope_gates/1.
page_path is the page the caller actually serves — it is reported to
Presence as the visitor's current_page, and the Live Activity page reads
it back, so it belongs to the caller rather than being hardcoded here. Pass
an already-resolved path (PhoenixKit.Utils.Routes.path/1).
Presence tracking is the one thing here that happens ONCE per mount rather than on every scope change — it reports where the socket is, which a permission change does not alter.
@spec assign_scope_gates(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
Computes EVERY scope-derived assign on the dashboard from
:phoenix_kit_current_scope, and reconciles the statistics subscriptions
with the verdict.
The single gate site. Called from assign_overview/3 at mount and from
phoenix_kit_scope_changed/1 whenever a permission change reassigns the
scope under a live socket, so the page never renders a gate that predates the
visitor's current rights. Idempotent: calling it with an unchanged scope
re-derives the same values and leaves the subscriptions alone.
Whether message is a broadcast from one of the three statistics topics.
Guards the single handle_info/2 clause use injects. Tag-only by design:
see the "Usage" section of the module documentation for why matching the
full shape is a crash waiting for a downgrade or a schema change.
@spec refresh_statistics(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
Re-runs the operator aggregates behind the Refresh button.
Re-derives the verdict from the current scope rather than reading
:show_statistics: the button is hidden for everyone else, but a hidden
button does not stop a crafted phx-click from arriving, and these are
exactly the queries the gate exists to withhold. (The assign would answer the
same today — it is recomputed on every scope change — but a gate fed by
client-triggered input should not depend on that staying true.)
@spec statistics_visible?(PhoenixKit.Users.Auth.Scope.t() | nil) :: boolean()
Whether scope may see the operator statistics — Platform Statistics, System
Information and the Refresh button.
These blocks have no destination LiveView to derive a gate from, so they use
the role-agnostic "holds every grantable permission" check rather than a role
name. nil scope → false.