PhoenixKitWeb.Live.Dashboard.Overview (phoenix_kit v2.5.0)

Copy Markdown View Source

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/0 or Presence.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

BlockGate
Users / Roles / Sessions / Live Activity / Add User cardsPhoenixKitWeb.Users.Auth.can_access_admin_view?/2 on each card's destination LiveView
Email cardthe module being loaded and enabled, AND can_access_admin_view?/2 on its admin LiveView
Platform Statistics, System Information, RefreshScope.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"))}
end

use 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

__using__(opts)

(macro)

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.

apply_statistics_message(socket, message)

@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.

assign_overview(socket, session, page_path)

@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.

assign_scope_gates(socket)

@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.

is_overview_message(message)

(macro)

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.

refresh_statistics(socket)

@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.)

statistics_visible?(scope)

@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.