Packages

phoenix_kit

2.2.0
2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web live users live_sessions.ex
Raw

lib/phoenix_kit_web/live/users/live_sessions.ex

defmodule PhoenixKitWeb.Live.Users.LiveSessions do
@moduledoc """
Real-time session monitoring dashboard for the PhoenixKit admin panel.
This module provides comprehensive real-time monitoring of:
- All active sessions (anonymous + authenticated)
- Live session statistics and metrics
- Real-time connection/disconnection events
- Session geographic distribution
- Page activity heatmaps
- User behavior patterns
Features real-time updates via Phoenix LiveView and WebSocket connectivity.
Only accessible to users with Owner or Admin roles.
"""
use PhoenixKitWeb, :live_view
# Search, filter, sort and page live in the query string, so a filtered list
# is a real URL: shareable, reload-proof, and Back returns to the previous
# query instead of leaving the page. `filter_type` defaults to "all", which
# is therefore what gets omitted from the URL.
#
# Sort is carried as two flat params and recombined into the compound
# `%{by:, dir:}` map that <.sort_header_cell sort={@sort}> expects — see
# handle_url_state/2. The template keeps reading @sort and needs no edit.
use PhoenixKitWeb.Live.UrlState,
params: [
search_query: [default: "", url_key: "q"],
filter_type: [default: "all", url_key: "type"],
sort_by: [default: :connected_at, cast: :atom, in: [:type, :connected_at], url_key: "sort"],
sort_dir: [default: :desc, cast: :atom, in: [:asc, :desc], url_key: "dir"],
page: [default: 1, cast: :integer, min: 1]
]
alias PhoenixKit.Admin.{Events, Presence}
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth
alias PhoenixKit.Users.Auth.Scope
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.IpAddress
alias PhoenixKit.Utils.Routes
# Refresh every 5 seconds
@refresh_interval 5_000
@per_page 20
def mount(_params, session, socket) do
# Subscribe to presence events for real-time updates
if connected?(socket) do
Events.subscribe_to_presence()
Events.subscribe_to_stats()
# Start auto-refresh timer
schedule_refresh()
# Track authenticated user session if logged in
track_authenticated_session(socket, session)
end
# Get project title from settings
project_title = Settings.get_project_title()
# :page, :search_query and :filter_type are assigned from the query string
# by UrlState before mount/3 runs — re-assigning them here would overwrite
# a shared link's state with the defaults.
socket =
socket
|> assign(:per_page, @per_page)
|> assign(:page_title, gettext("Live Sessions"))
|> assign(:project_title, project_title)
|> assign(:auto_refresh, true)
|> assign(:last_updated, UtilsDate.utc_now())
|> load_stats()
{:ok, socket}
end
# The list is loaded here rather than in mount/3: UrlState calls this after
# mount and on every change to the query string, so one code path serves the
# first render, a shared link, and the Back button alike.
#
# Deliberately not annotated with @impl — a single @impl anywhere in a module
# makes Elixir demand it on every other callback too, and this LiveView's
# mount/handle_event/handle_params/handle_info carry none.
def handle_url_state(state, socket) do
socket
|> assign(:sort, %{by: state.sort_by, dir: state.sort_dir})
|> load_sessions()
end
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
# `replace: true` — the box is debounced, so a typed-out query would
# otherwise leave one history entry per pause and Back would walk the search
# string backwards instead of leaving the page.
def handle_event("search", %{"search" => search_query}, socket) do
{:noreply, push_url_state(socket, [search_query: search_query], replace: true)}
end
def handle_event("filter_by", %{"type" => filter_type}, socket) do
{:noreply, push_url_state(socket, filter_type: filter_type)}
end
def handle_event("toggle_sort", %{"by" => by}, socket) do
%{by: sort_by, dir: sort_dir} = toggle_sort(socket.assigns.sort, parse_sort_by(by))
{:noreply, push_url_state(socket, sort_by: sort_by, sort_dir: sort_dir)}
end
def handle_event("change_page", %{"page" => page}, socket) do
case Integer.parse(page) do
{page, ""} when page > 0 -> {:noreply, push_url_state(socket, page: page)}
_ -> {:noreply, socket}
end
end
def handle_event("toggle_auto_refresh", _params, socket) do
auto_refresh = !socket.assigns.auto_refresh
if auto_refresh do
schedule_refresh()
end
socket = assign(socket, :auto_refresh, auto_refresh)
{:noreply, socket}
end
def handle_event("refresh_now", _params, socket) do
socket =
socket
|> assign(:last_updated, UtilsDate.utc_now())
|> load_sessions()
|> load_stats()
{:noreply, socket}
end
def handle_info(:refresh, socket) do
if socket.assigns.auto_refresh do
socket =
socket
|> assign(:last_updated, UtilsDate.utc_now())
|> load_sessions()
|> load_stats()
schedule_refresh()
{:noreply, socket}
else
{:noreply, socket}
end
end
# Handle real-time presence events
def handle_info({:anonymous_session_connected, _session_id, _metadata}, socket) do
socket =
socket
|> assign(:last_updated, UtilsDate.utc_now())
|> load_sessions()
|> load_stats()
{:noreply, socket}
end
def handle_info({:anonymous_session_disconnected, _session_id}, socket) do
socket =
socket
|> assign(:last_updated, UtilsDate.utc_now())
|> load_sessions()
|> load_stats()
{:noreply, socket}
end
def handle_info({:user_session_connected, _user_uuid, _metadata}, socket) do
socket =
socket
|> assign(:last_updated, UtilsDate.utc_now())
|> load_sessions()
|> load_stats()
{:noreply, socket}
end
def handle_info({:user_session_disconnected, _user_uuid, _session_id}, socket) do
socket =
socket
|> assign(:last_updated, UtilsDate.utc_now())
|> load_sessions()
|> load_stats()
{:noreply, socket}
end
def handle_info({:presence_stats_updated, stats}, socket) do
socket =
socket
|> assign(:presence_stats, stats)
|> assign(:last_updated, UtilsDate.utc_now())
{:noreply, socket}
end
# Ignore other messages
def handle_info(_msg, socket), do: {:noreply, socket}
defp load_sessions(socket) do
sessions = Presence.list_active_sessions()
# Apply filters
filtered_sessions =
sessions
|> filter_by_search(socket.assigns.search_query)
|> filter_by_type(socket.assigns.filter_type)
|> sort_sessions(socket.assigns.sort.by, socket.assigns.sort.dir)
# Calculate pagination
total_count = length(filtered_sessions)
total_pages = max(1, ceil(total_count / socket.assigns.per_page))
page = min(socket.assigns.page, total_pages)
# Get page sessions
page_sessions =
filtered_sessions
|> Enum.drop((page - 1) * socket.assigns.per_page)
|> Enum.take(socket.assigns.per_page)
# Preload users for avatar display
users_map = preload_users_for_sessions(page_sessions)
socket
|> assign(:sessions, page_sessions)
|> assign(:users_map, users_map)
|> assign(:total_sessions, total_count)
|> assign(:total_pages, total_pages)
|> assign(:page, page)
end
defp preload_users_for_sessions(sessions) do
user_uuids =
sessions
|> Enum.filter(&(&1.type == :authenticated))
|> Enum.map(& &1.user_uuid)
|> Enum.uniq()
case user_uuids do
[] -> %{}
uuids -> Auth.get_users_by_uuids(uuids) |> Map.new(&{&1.uuid, &1})
end
end
defp load_stats(socket) do
stats = Presence.get_presence_stats()
assign(socket, :presence_stats, stats)
end
defp filter_by_search(sessions, "") do
sessions
end
defp filter_by_search(sessions, search_query) do
query = String.downcase(search_query)
Enum.filter(sessions, fn session ->
String.contains?(String.downcase(session.ip_address || ""), query) ||
String.contains?(String.downcase(session.user_agent || ""), query) ||
String.contains?(String.downcase(session.current_page || ""), query) ||
(session.user_email && String.contains?(String.downcase(session.user_email), query))
end)
end
defp filter_by_type(sessions, "all"), do: sessions
defp filter_by_type(sessions, "anonymous"), do: Enum.filter(sessions, &(&1.type == :anonymous))
defp filter_by_type(sessions, "authenticated"),
do: Enum.filter(sessions, &(&1.type == :authenticated))
defp sort_sessions(sessions, sort_by, sort_order) do
sessions
|> Enum.sort_by(fn session -> Map.get(session, sort_by) end, sort_order)
end
defp parse_sort_by("type"), do: :type
defp parse_sort_by("connected_at"), do: :connected_at
defp parse_sort_by(_), do: :connected_at
defp toggle_sort(%{by: by, dir: dir}, by), do: %{by: by, dir: flip_dir(dir)}
defp toggle_sort(_, new_by), do: %{by: new_by, dir: :asc}
defp flip_dir(:asc), do: :desc
defp flip_dir(:desc), do: :asc
defp schedule_refresh do
Process.send_after(self(), :refresh, @refresh_interval)
end
defp track_authenticated_session(socket, session) do
scope = socket.assigns[:phoenix_kit_current_scope]
if scope && Scope.authenticated?(scope) do
user_email = Scope.user_email(scope)
# Create a user map for tracking (uuid required by SimplePresence)
user = %{uuid: scope.user.uuid, email: user_email}
session_id = session["live_socket_id"] || generate_session_id()
Presence.track_user(user, %{
connected_at: UtilsDate.utc_now(),
session_id: session_id,
ip_address: IpAddress.extract_from_socket(socket),
user_agent: get_connect_info(socket, :user_agent),
current_page: Routes.path("/admin/users/live_sessions")
})
end
end
defp generate_session_id do
:crypto.strong_rand_bytes(16) |> Base.encode64()
end
end