My new company AgendaScope is building it’s product using the Elixir/Phoenix/LiveView stack and while I have spent some time in recent months learning Elixir and Phoenix, LiveView was entirely new to me. This weekend I decided it was time to crack it.
I had a little project in mind which is creating a dashboard of thermal info from my Mac. There’s a reason for that which is another post but suffice to say I needed information displayed on a second computer, preferably my iPad.
The way I chose to tackle this problem was to create a GenServer instance that would monitor the output of a few commands on a periodic basis (at the moment once every 5 seconds). The commands are:
thermal levels
powermetrics --samplers smc | grep -i "die temperature"
uptime
The Elixir Port module makes this a pretty trivial thing to do with the GenServer handle_info
callback receiving the command output that I parse and store in the GenServer state.
On the other end it turns out LiveView is pretty simple. A LiveView template is an alternative to a regular static template (this part had not been clear to me before and I thought they sat, side-by-side).
The LiveView module in my case ThermalsLive
stores things like CPU pressure, GPU pressure, & CPU die temp, in the Socket
assigns. And a template is simple to create. LiveView takes care of all the magic of making things work over a web socket.
For example, in a LiveView template the markup <a href='#' phx-click='guess' phx-value-number='1'>1</a>
generates a link that results in the running LiveView module receiving the handle_event
callback with the value 1
. Like this:
def handle_event("guess", %{"number" => guess} = data, %{assigns: %{score: score, answer: answer}} = socket) do
…
end
Something I learned about LiveView is that each browser session gets its own stateful server side LiveView module that lasts throughout the session. When a LiveView event handler changes the assigns
in the socket
structure LiveView responds by re-rendering the parts of the template that depended on those particular assigns. This is good magic!
Now the question was: How does my GenServer (ThermalsService
) notify the LiveView (ThermalsLive
) when one of the variables it’s monitoring (cpu_pressure
) has changed? This event is not coming from the browser so the regular LiveView magic isn’t enough.
I couldn’t find a good simple example and so what I’ve learned was gleaned from some books and Google searching. There short answer is Phoenix.PubSub
, here’s the longer answer.
First we need a topic. When three friends are at a table for lunch their topic might be “politics” and one of them might tune out and not be listening. But when the topic changes to “cheese” they tune back in and hear those messages.
For our GenServer & LiveView the topic (defined as a module attribute @thermals_topic
) is going to be thermals_update
because I want them to talk about these and I don’t plan to use individual message types for different variables like cpu_pressure
and gpu_die_temp
.
On the LiveView end we need to subscribe to the same topic. The right place to do this appears to be in the mount
callback where the socket
assigns are first set.
def mount(_params, _session, socket) do
if connected?(socket) do
DashletWeb.Endpoint.subscribe(@thermals_topic)
end
…
end
We test if the socket is connected because it turns out a LiveView module calls mount
twice. Once in response to the initial browser request where it returns the HTML page that starts a web socket connection back to the server. The second call to mount
is in response to setting up the web socket and long-term communication between client and server. This seems to be the best point to subscribe to the topic.
It wasn’t obvious that the DashletWeb.EndPoint
module had a subscribe
method ready made for this purpose and I was mucking about the the Phoenix.PubSub
module for a bit before I hit on this.
My GenServer process ThermalsService
receives handle_info
callbacks from the Port
that contain the terminal output of the running command as a string. With a little bit of parsing & conversion we end up with something like cpu_pressure: 56
and as well as storing this in the GenServer state we need the LiveView module to know about it. We need to broadcast a message to the thermals_update
topic.
This turned out to be pretty simple:
alias Phoenix.PubSub
@thermals_topic "thermals_update"
def handle_info({_port, {:data, text_line}}, state) do
…
PubSub.broadcast(Dashlet.PubSub, @thermals_topic, {"cpu_pressure", cpu_pressure)
…
end
I confess I am still not sure what Dashlet.Pubsub
is. By my Elixir knowledge it should be a module but I can’t find it defined anywhere. Anyway, it works.
The last piece of the puzzle that I couldn’t find stated but inferred from examples is that, for each broadcast, there is a call to the handle_info
callback on all topic subscribers (in this case our LiveView module).
So, in my live view ThermalsLive
I add:
def handle_info({"cpu_pressure", pressure}, socket) do
{:noreply, assign(socket, cpu_pressure: pressure)}
end
This is super simple. It recevies the cpu_pressure
message from the GenServer along with the pressure value and stores it in the LiveView socket
assigns (the same way the handle_event
callback would do in respond to clicking a browser link). This is enough for LiveView to take the hint and trigger a client update.
What foxed me was that regular LiveView events arrive via the handle_event
callback that receives the LiveView socket
instance. You need the socket
to change its assigns
to trigger an update. It didn’t occur to me that PubSub might ensure that the handle_info
path would be equivalent.
And, there you have it, sending data from a GenServer via Phoenix PubSub to a LiveView module. I learned quite a bit via this exercise and I hope this might help anyone following the same path.