Home Posts Post Search Tag Search

LiveView 25 - Chapter 9: Build a Distributed Dashboard
Published on: 2026-01-24 Tags: elixir, Blog, Side Project, LiveView, Phoenix

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