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:
@@ -13,8 +13,8 @@ defmodule Dashboard.Application do
|
||||
{Phoenix.PubSub, name: Dashboard.PubSub},
|
||||
# Start the Finch HTTP client for sending emails
|
||||
{Finch, name: Dashboard.Finch},
|
||||
# Start a worker by calling: Dashboard.Worker.start_link(arg)
|
||||
# {Dashboard.Worker, arg},
|
||||
# Start MQTT subscriber for systant hosts
|
||||
Dashboard.MqttSubscriber,
|
||||
# Start to serve requests, typically the last entry
|
||||
DashboardWeb.Endpoint
|
||||
]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user