We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Chapter 9: Build a Distributed Dashboard
We will start to look at how the Phoenix distributed system is distributed because it can reflect the entire site. We will build a functionality that will show the events on other pages not triggered by the current user. In this way we can be passive views that can see the entire site.
<br>
LiveView and Phoenix Messaging Tools
Before we go any further lets go back to the life cycle of a Phoenix LiveView. Remember its a revolving circle that will always start with the render state. <br> render state -> receive event -> change state -> repeat
The receive event can do a lot of heavy lifting here as it does’t have to be something that comes from the current user an event can be triggered anywhere on the site. Messaging libraries will do a lot of the work for us we just need to understand how they work and where to implement them.
<br>
The Phoenix.PubSub will be a major part of this. We will also make use of Phoenix Presence to allow for some real time tracking.
<br>
Let’s take a second to talk about the approach and the path:
• Extend the dashboard with new requirements.
• Use __PubSub__ to make the dashboard reflect real-time results also add in a list of users that are viewing products, the second will require Phoenix Presence.
• Synchronizing _Admin.DashboardLive_ when new product ratings come in. Using __PubSub__ we will have the admin dashboard to subscribe to those messages.
• Real-time tracking feature that will use __Presence__ to show which users are viewing which products.
Track Real-Time Survey Results with PubSub
We can start with making sure that any new result is automatically sent to the dashboard. Right now users will enter demos and ratings with the RatingLive.Form.Component. We need to make sure that when this happens the Admin.DashboardLive is notified. If we didn’t have PubSub we would try to use a send/2 but we would need the PID of the Dashboard and if anything crashed or changed the PID would change.
<br>
Phoenix PubSub Implements the Publish/Subscribe Pattern
PubSub is a publish/subscribe implementation that is an intermediary that will take care of who should receive the message that a publisher has sent. Any process that wan’t access to a publishers messages can use the subscribe/1 function. In this way the 2 processes don’t need to know each other just the PubSub needs to know it players.
<br>
PubSub.broadcast/3
PubSub.subscribe/1
<br>
Plan the Feature
Admin.DashboardLive will subscribe to a topic. Then SurveyResultsLive will broadcast. Then we can teach the Admin.DashboardLive how to handle it.
Configure Phoenix PubSub
We don’t even need anything special for the PubSub. Check out the config.exs
config :pento, PentoWeb.Endpoint,
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: PentoWeb.ErrorHTML, json: PentoWeb.ErrorJSON],
layout: false
],
pubsub_server: Pento.PubSub,
live_view: [signing_salt: "********"]
Pento.PubSub is a named process that is an any Elixir application can make use of. The configuration uses that default adapter name or, PubSub.PG2.
<br>
Broadcast Survey Results
Okay so how do we use this. We will need to head to the survey_live.ex and add in some new code.
defmodule PentoWeb.SurveyLive do
use PentoWeb, :live_view
alias Pento.{Survey, Catalog}
alias PentoWeb.DemographicLive.{Show, Form}
alias PentoWeb.RatingLive.Index
alias __MODULE__.Component
alias PentoWeb.Endpoint
@survey_results_topic "survey_results"
...
defp handle_rating_created(socket, product, product_index) do
current_products = socket.assigns.products
products = List.replace_at(current_products, product_index, product)
# New here
Endpoint.broadcast(@survey_results_topic, "rating_created", %{})
socket
|> put_flash(:info, "Rating created successfully")
|> assign(:products, products)
end
...
end
<br>
We add in the new alias, the topic that we are trying to broadcast, and then we add in the Endpoint.broadcast/3. broadcast/3 will send the message “rating_created” message over the @survey_results_topic topic, with an empty payload.
<br>
Subscribe to Survey Results Messages
Now we need to makes sure that the SurveyResultsLive is subscribed to the “surveyresults” topic. But remember that we have _SurveyResultsLive with the Admin.DashboardLive. So with this we will need to:
• Subscribe Admin.DashboardLive to “surveyresults”
• Implement _handle_info/2 on Admin.DashboardLive for the “ratingcreated”
• Use that function to tell the _SurveyResultsLive to update the table. It will go so much faster than you think.
# dashboard_live.ex
defmodule PentoWeb.Admin.DashboardLive do
use PentoWeb, :live_view
alias PentoWeb.Endpoint
alias PentoWeb.Admin.SurveyResultsLive
@survey_results_topic "survey_results"
...
def mount(_params, _session, socket) do
if connected?(socket) do
Endpoint.subscribe(@survey_results_topic)
end
{:ok,
socket
|> assign(:survey_results_component_id, "survey-results")}
end
end
<br>
Just a quick reminder that mount/3 gets called twice, once to set the initial status of the render and html. then again with the WebSocket-connected process starts up. So we only subscribe when we are officially connected.
<br>
Okay so we are connected and when a the topic is broadcasted we will receive the message. Let’s create the handle_info/2 for that event.
# dashboard_live.ex
@impl true
def handle_info(%{event: "rating_created"}, socket) do
send_update(
SurveyResultsLive,
id: socket.assigns.survey_results_component_id
)
{:noreply, socket}
end
<br>
We now have the way to see the new message and then we will send an update from the parent (Admin.DashboardLive) to the child SurveyResultsLive.
<br>
Update the Component
Okay so back in the binging of the Dashboard we had a place in the assigns for the surveyresults_component_id, when we send the _send_update/2 we are asking it to update with any new assigns as the second argument. Thus the invoking of that function will ask the SurveyResultsLive to update the values for the chart.
We do have one issue though. We are hard coding the values for the filter within the update so we will have the chart go back to no filter every time that a new result is in. So we need to update the assign_*_filter to be smarter and use the value that is already within the assigns. I already did this when I had to add in the gender filter but I’ll put the code here.
# survey_results_live.ex
def assign_age_group_filter(
%{assigns: %{age_group_filter: age_group_filter}} =
socket
) do
assign(socket, :age_group_filter, age_group_filter)
end
def assign_age_group_filter(socket) do
socket
|> assign(:age_group_filter, "all")
end
...
def assign_gender_filter(
%{assigns: %{gender_filter: gender_filter}} = socket
) do
assign(socket, :gender_filter, gender_filter)
end
def assign_gender_filter(socket) do
assign(socket, :gender_filter, "all")
end
...
def assign_products_with_average_ratings(
%{assigns: %{age_group_filter: age_group_filter, gender_filter: gender_filter}} =
socket
) do
assign(
socket,
:products_with_average_ratings,
get_products_with_average_ratings(%{
age_group_filter: age_group_filter,
gender_filter: gender_filter
})
)
end
Track Real-Time User Activity with Presence
Phoenix uses processes and channels to do everything within the sites we build. In this way we are running a supervisor tree with children that are all know in some way. They might be separated but Presence is designed to help with this problem. It uses a CRDT (conflict-free Replicated Data Type)
<br>
In order to build this feature we will need to build the following:
__PentoWeb.Presence Module__
This will define our _presence model_. It will define our data structure that will track user activity.
__UserActivityLive__
This will render a static list of current users.
__handle_info/3 message handler__
This will be a function on the _Admin.DashboardLive_ that will tell the user activity component to update.
We will handle all this together with a PubSub-backed Presence workflow. When a user in viewing a product, Presence.track/4 to broadcast. Admin.Dashboard will subscribe to that topic and then handle_info/3 will tell the component to update.
<br>
Set Up Presence
Phoenix.Presence uses the OTP to notify application via PubSub when processes or channels come and go. go ahead and make this new file that will help us with Presence.
# pento/lib/pento_web/presence.ex
defmodule PentoWeb.Presence do
@moduledoc """
Provides presence tracking to channels and processes.
See the [`Phoenix.Presence`](https://hexdocs.pm/phoenix/Phoenix.Presence.html)
docs for more details.
"""
use Phoenix.Presence,
otp_app: :pento,
pubsub_server: Pento.PubSub
end
<br>
We are using the __using__ macro here on the Phoenix.Presence that will inject code into the module we are also passing in some params. otp_app: :pento is our app and we are also passing in the Pento.PubSub server so it know where to send the messages.
<br>
This is very bare bones at the moment but we can add more into it as our needs grow. We still need to make sure that we start this with every launching of the sites zenserver so let’s make sure that happens by heading into the application.ex
@impl true
def start(_type, _args) do
children = [
PentoWeb.Telemetry,
Pento.Repo,
{DNSCluster, query: Application.get_env(:pento, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Pento.PubSub},
PentoWeb.Presence,
# Start a worker by calling: Pento.Worker.start_link(arg)
# {Pento.Worker, arg},
# Start to serve requests, typically the last entry
PentoWeb.Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: Pento.Supervisor]
Supervisor.start_link(children, opts)
end
<br>
One of the few things we should talk about here is that you will need to start it after the PubSub process is started so it can reference something its already there.
<br>
Track User Activity
Okay so for us to start to track a user we need answer a few questions:
Who is the user?
We can use the user_token to fetch a user.id
When are they present?
We want to say that a user is present when they are viewing a product in the ProductLive.Show
<br>
Okay so going back to the route we can see that the products/:id is using the ProductLive.Show and that is within the live session block that requires a PentoWeb.UserAuth. Then the on_mount/4 populates the socket with a :current_scope.
live_session :require_authenticated_user,
root_layout: {PentoWeb.Layouts, :root},
on_mount: [{PentoWeb.UserAuth, :require_authenticated}] do
...
live("/questions/:id", QuestionLive.Show, :show)
...
end
<br> Now we can use that to start to track a user. We can head to that live page and add in some code that will track a user if they are connected.
alias PentoWeb.Presence
...
@impl true
def handle_params(%{"id" => id}, _, socket) do
product = Catalog.get_product!(socket.assigns.current_scope, id)
maybe_track_user(product, socket)
{:noreply,
socket
|> assign(:page_title, page_title(socket.assign.live_action))
|> assign(:product, product)}
end
def maybe_track_user(
product,
%{assigns: %{live_action: :show, current_scope: current_scope}} =
socket
) do
if connected?(socket) do
Presence.track_user(self(), product, current_scope.user.email)
end
end
def maybe_track_user(_product, _socket), do: nil
defp page_title(:show), do: "Show Product"
defp page_title(:edit), do: "Edit Product"
<br>
Now that we have this we can start to think about the final structure of the Presence data structure that we will display. But first let’s talk about what we did. We set it up that when we a user is on a show page for a product we are running the maybe_track_user/2 function that will check to see if the user is connected and if so will use Presence to send the product and the users.email. Keep in mind that is is simply calling a PentoWeb.Presence module function and the actual use of Presence.track/4 will be implemented there.
<br>
Real quick let’s talk about a use of Presence.track/4 and then show what it would send.
topic = "user_activity"
Presence.track(
some_pid,
topic,
"Chess",
%{users: [%{email: "bob@email.com"}]}
)
# This calling will turn into this message.
%{
"Chess" => %{
metas: [
%{phx_ref: "...", users: [%{email: "bob@email.com"}]}
]
}
}
<br> Now let’s head back the PentoWeb.Presence module and add in that function be tried to call earlier.
@user_activity_topic "user_activity"
def track_user(pid, product, user_email) do
track(
pid,
@user_activity_topic,
product.name,
%{users: [%{email: user_email}]}
)
end
<br>
Okay so we have a very small bit of code that will do a lot for us and get us the exact set of data that we want.
<br>
Display User Tracking
Now we get to move onto the display for this new bit of code and make sure that we are subscribed to the PubSub Topic. We will need to created a new module for the Admin.UserActivityLive component module. Let’s start there.
defmodule PentoWeb.Admin.UserActivityLive do
use PentoWeb, :live_component
alias PentoWeb.Presence
def update(_assigns, socket) do
{
:ok,
socket
# |> assign_user_activity()
}
end
end
<br>
This is just the start as just like the other PubSub setup this will need to deal with any updates that are sent from the Admin.DashboardLive module. We will need that module to subscribe to that topic. Right now we have the assign_user_activity() that will be responsible for assigning the data to the :user_activity key.
<br>
First let’s head back to presence.ex and make sure that we build the right maps, and then send them.
def list_products_and_users do
list(@user_activity_topic)
|> Enum.map(&extract_product_with_users/1)
end
defp extract_product_with_users({product_name, %{metas: metas}}) do
{product_name, users_from_metas_list(metas)}
end
defp users_from_metas_list(metas_list) do
Enum.map(metas_list, &users_from_meta_map/1)
|> List.flatten()
|> Enum.uniq()
end
def users_from_meta_map(meta_map) do
get_in(meta_map, [:users])
end
<br> This will be responsible for turning the message into something that we can use for the data table. It starts with list_products_and_users/0 and then proceeds to reduce that data like we see below.
%{
"Chess" => %{
metas: [
%{phx_ref: "...", users: [%{email: "bob@email.com"}]},
%{phx_ref: "...", users: [%{email: "terry@email.com"}]}
]
}
}
# into this
[{"Chess", [%{email: "bob@email.com"}, %{email: "terry@email.com"}]}]
<br> Now that we have that we can use that to write the code for the assign_user_activity/1.
def assign_user_activity(socket) do
assign(socket, :user_activity, Presence.list_products_and_users())
end
<br> Okay we now have a way to get the products and users into the socket, now we can make the user_activity_live.html.heex or define the render function I’ll use the render function.
@impl true
def render(assigns) do
~H"""
<div class="user-activity-component ml-8">
<h2>User Activity</h2>
<p>Active users currently viewing games</p>
<div :for={{product_name, users} <- @user_activity}>
<h3>{product_name}</h3>
<ul>
<li :for={user <- users}>{user.email}</li>
</ul>
</div>
</div>
"""
end
<br> Now that we have the component set for the Admin.UserActivityLive we need to be sure that we can add in the component to the Admin.DashboardLive so add in this assign to the mount/4, also add in the alias for the new module, and the global variable for the module.
alias PentoWeb.Admin.{SurveyResultsLive, UserActivityLive}
...
@user_activity_topic "user_activity"
...
{:ok,
socket
|> assign(:survey_results_component_id, "survey-results")
|> assign(:user_activity_component_id, "user-activity")}
...
Now we can add in the html to render the new live_component
<div class="container mx-auto p-6">
<h1 class="text-3xl font-bold mb-6">Admin Dashboard</h1>
<.live_component
module={PentoWeb.Admin.SurveyResultsLive}
id={@survey_results_component_id}
/>
<.live_component
module={PentoWeb.Admin.UserActivityLive}
id={@user_activity_component_id}
/>
</div>
<br>
Again simple code but it goes a long way to making the site work as intended. We still have a problem that we are not actively updating the table and when a user moves away we are not taking care of that as well.
<br>
Subscribe to Presence Changes
We are almost there we just need to subscribe (Presence.track/4) and then respond to the changes, with a handle_info/2. Then the component will use the update/2 to update the table. First the subscribe
def mount(_params, _session, socket) do
if connected?(socket) do
Endpoint.subscribe(@survey_results_topic)
Endpoint.subscribe(@user_activity_topic)
end
{:ok,
socket
|> assign(:survey_results_component_id, "survey-results")
|> assign(:user_activity_component_id, "user-activity")}
end
Respond to Presence Events
Lastly we need to make sure that we are updating the data with the handle_info/2
def handle_info(%{event: "presence_diff"}, socket) do
send_update(
UserActivityLive,
id: socket.assigns.user_activity_component_id
)
{:noreply, socket}
end