Add real-time MQTT-powered LiveView dashboard

Features:
- MQTT subscriber GenServer connects to mqtt.home
- Real-time host discovery via systant/+/stats topic
- LiveView with Phoenix PubSub for instant updates
- Host cards showing live data and last seen timestamps
- Clean UI with Tailwind styling
- Proper OTP supervision tree

Dashboard ready to receive live data from systant hosts!
Visit /hosts to see real-time monitoring.
This commit is contained in:
2025-08-02 21:57:59 -07:00
parent 9ae6a15970
commit 96de648bca
7 changed files with 249 additions and 137 deletions
+122
View File
@@ -0,0 +1,122 @@
defmodule Dashboard.MqttSubscriber do
@moduledoc """
MQTT subscriber that listens to systant host messages and broadcasts updates via Phoenix PubSub.
"""
use GenServer
require Logger
alias Phoenix.PubSub
@mqtt_topic "systant/+/stats"
@pubsub_topic "systant:hosts"
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def get_hosts do
GenServer.call(__MODULE__, :get_hosts)
end
@impl true
def init(_opts) do
# Start MQTT connection
client_id = "systant_dashboard_#{System.unique_integer([:positive])}"
connection_opts = [
client_id: client_id,
server: {Tortoise.Transport.Tcp, host: "mqtt.home", port: 1883},
handler: {__MODULE__, []}
]
case Tortoise.Supervisor.start_child(client_id, connection_opts) do
{:ok, _pid} ->
Logger.info("MQTT subscriber connected as #{client_id}")
# Subscribe to systant stats topic
Tortoise.Connection.subscribe(client_id, @mqtt_topic, qos: 0)
{:ok, %{client_id: client_id, hosts: %{}}}
{:error, reason} ->
Logger.error("Failed to start MQTT connection: #{inspect(reason)}")
{:stop, reason}
end
end
@impl true
def handle_call(:get_hosts, _from, state) do
{:reply, state.hosts, state}
end
@impl true
def handle_info({:tortoise, {:publish, @mqtt_topic, payload, _opts}}, state) do
case Jason.decode(payload) do
{:ok, data} ->
# Extract hostname from topic
hostname = extract_hostname_from_topic(@mqtt_topic)
# Update host data with timestamp
host_data = Map.put(data, "last_seen", DateTime.utc_now())
updated_hosts = Map.put(state.hosts, hostname, host_data)
# Broadcast update via PubSub
PubSub.broadcast(Dashboard.PubSub, @pubsub_topic, {:host_update, hostname, host_data})
Logger.debug("Received update from #{hostname}: #{inspect(data)}")
{:noreply, %{state | hosts: updated_hosts}}
{:error, reason} ->
Logger.warning("Failed to decode JSON payload: #{inspect(reason)}")
{:noreply, state}
end
end
def handle_info({:tortoise, {:publish, topic, payload, _opts}}, state) do
# Extract hostname from the actual topic
case String.split(topic, "/") do
["systant", hostname, "stats"] ->
case Jason.decode(payload) do
{:ok, data} ->
# Update host data with timestamp
host_data = Map.put(data, "last_seen", DateTime.utc_now())
updated_hosts = Map.put(state.hosts, hostname, host_data)
# Broadcast update via PubSub
PubSub.broadcast(Dashboard.PubSub, @pubsub_topic, {:host_update, hostname, host_data})
Logger.debug("Received update from #{hostname}: #{inspect(data)}")
{:noreply, %{state | hosts: updated_hosts}}
{:error, reason} ->
Logger.warning("Failed to decode JSON payload: #{inspect(reason)}")
{:noreply, state}
end
_ ->
Logger.debug("Received message on unexpected topic: #{topic}")
{:noreply, state}
end
end
def handle_info({:tortoise, _msg}, state) do
# Handle other tortoise messages (connection status, etc.)
{:noreply, state}
end
# Private functions
defp extract_hostname_from_topic(topic) do
case String.split(topic, "/") do
["systant", hostname, "stats"] -> hostname
_ -> "unknown"
end
end
# Tortoise handler callbacks (required when using handler: {module, args})
def connection(_status, _state), do: []
def subscription(_status, _topic_filter, _state), do: []
def terminate(_reason, _state), do: []
end