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:
ryan 2025-08-02 21:57:59 -07:00
parent 9ae6a15970
commit 96de648bca
7 changed files with 249 additions and 137 deletions

View File

@ -13,8 +13,8 @@ defmodule Dashboard.Application do
{Phoenix.PubSub, name: Dashboard.PubSub}, {Phoenix.PubSub, name: Dashboard.PubSub},
# Start the Finch HTTP client for sending emails # Start the Finch HTTP client for sending emails
{Finch, name: Dashboard.Finch}, {Finch, name: Dashboard.Finch},
# Start a worker by calling: Dashboard.Worker.start_link(arg) # Start MQTT subscriber for systant hosts
# {Dashboard.Worker, arg}, Dashboard.MqttSubscriber,
# Start to serve requests, typically the last entry # Start to serve requests, typically the last entry
DashboardWeb.Endpoint DashboardWeb.Endpoint
] ]

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

View File

@ -47,42 +47,34 @@
/> />
</svg> </svg>
<h1 class="text-brand mt-10 flex items-center text-sm font-semibold leading-6"> <h1 class="text-brand mt-10 flex items-center text-sm font-semibold leading-6">
Phoenix Framework <.icon name="hero-computer-desktop" class="h-4 w-4" />
Systant Dashboard
<small class="bg-brand/5 text-[0.8125rem] ml-3 rounded-full px-2 font-medium leading-6"> <small class="bg-brand/5 text-[0.8125rem] ml-3 rounded-full px-2 font-medium leading-6">
v{Application.spec(:phoenix, :vsn)} Real-time monitoring
</small> </small>
</h1> </h1>
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-zinc-900 text-balance"> <p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-zinc-900 text-balance">
Peace of mind from prototype to production. Monitor all your hosts in real-time.
</p> </p>
<p class="mt-4 text-base leading-7 text-zinc-600"> <p class="mt-4 text-base leading-7 text-zinc-600">
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. Phoenix LiveView dashboard for systant hosts. Get real-time system statistics via MQTT from all your monitored servers.
</p> </p>
<div class="flex"> <div class="flex">
<div class="w-full sm:w-auto"> <div class="w-full sm:w-auto">
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3"> <div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<a <a
href="https://hexdocs.pm/phoenix/overview.html" href="/hosts"
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6" class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
> >
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105"> <span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span> </span>
<span class="relative flex items-center gap-4 sm:flex-col"> <span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6"> <.icon name="hero-computer-desktop" class="h-6 w-6" />
<path d="m12 4 10-2v18l-10 2V4Z" fill="#18181B" fill-opacity=".15" /> View Hosts
<path
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Guides &amp; Docs
</span> </span>
</a> </a>
<a <a
href="https://github.com/phoenixframework/phoenix" href="https://github.com/ryanpandya/systant"
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6" class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
> >
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105"> <span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
@ -99,122 +91,6 @@
Source Code Source Code
</span> </span>
</a> </a>
<a
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
>
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path
d="M12 1v6M12 17v6"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
fill="#18181B"
fill-opacity=".15"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Changelog
</span>
</a>
</div>
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-zinc-700 sm:grid-cols-2">
<div>
<a
href="https://twitter.com/elixirphoenix"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M5.403 14c5.283 0 8.172-4.617 8.172-8.62 0-.131 0-.262-.008-.391A6.033 6.033 0 0 0 15 3.419a5.503 5.503 0 0 1-1.65.477 3.018 3.018 0 0 0 1.263-1.676 5.579 5.579 0 0 1-1.824.736 2.832 2.832 0 0 0-1.63-.916 2.746 2.746 0 0 0-1.821.319A2.973 2.973 0 0 0 8.076 3.78a3.185 3.185 0 0 0-.182 1.938 7.826 7.826 0 0 1-3.279-.918 8.253 8.253 0 0 1-2.64-2.247 3.176 3.176 0 0 0-.315 2.208 3.037 3.037 0 0 0 1.203 1.836A2.739 2.739 0 0 1 1.56 6.22v.038c0 .7.23 1.377.65 1.919.42.54 1.004.912 1.654 1.05-.423.122-.866.14-1.297.052.184.602.541 1.129 1.022 1.506a2.78 2.78 0 0 0 1.662.598 5.656 5.656 0 0 1-2.007 1.074A5.475 5.475 0 0 1 1 12.64a7.827 7.827 0 0 0 4.403 1.358" />
</svg>
Follow on Twitter
</a>
</div>
<div>
<a
href="https://elixirforum.com"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
</svg>
Discuss on the Elixir Forum
</a>
</div>
<div>
<a
href="https://web.libera.chat/#elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.356 2.007a.75.75 0 0 1 .637.849l-1.5 10.5a.75.75 0 1 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.637ZM11.356 2.008a.75.75 0 0 1 .637.848l-1.5 10.5a.75.75 0 0 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.636Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14 5.25a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75ZM13 10.75a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75Z"
/>
</svg>
Chat on Libera IRC
</a>
</div>
<div>
<a
href="https://discord.gg/elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
</svg>
Join our Discord server
</a>
</div>
<div>
<a
href="https://fly.io/docs/elixir/getting-started/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
</svg>
Deploy your application
</a>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,110 @@
defmodule DashboardWeb.HostsLive do
@moduledoc """
LiveView for real-time systant host monitoring.
"""
use DashboardWeb, :live_view
alias Phoenix.PubSub
alias Dashboard.MqttSubscriber
@pubsub_topic "systant:hosts"
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
# Subscribe to host updates
PubSub.subscribe(Dashboard.PubSub, @pubsub_topic)
end
# Get initial host data
hosts = MqttSubscriber.get_hosts()
socket =
socket
|> assign(:hosts, hosts)
|> assign(:page_title, "Systant Hosts")
{:ok, socket}
end
@impl true
def handle_info({:host_update, hostname, host_data}, socket) do
updated_hosts = Map.put(socket.assigns.hosts, hostname, host_data)
{:noreply, assign(socket, :hosts, updated_hosts)}
end
@impl true
def render(assigns) do
~H"""
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
<div class="mx-auto max-w-xl lg:mx-0 lg:max-w-3xl">
<h1 class="text-brand mt-10 flex items-center text-sm font-semibold leading-6">
<.icon name="hero-computer-desktop" class="h-4 w-4" />
Systant Host Monitor
</h1>
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-zinc-900">
Real-time system monitoring across all hosts
</p>
<p class="mt-4 text-base leading-7 text-zinc-600">
Live MQTT-powered dashboard showing statistics from all your systant-enabled hosts.
</p>
<div class="mt-10 grid gap-6">
<%= if Enum.empty?(@hosts) do %>
<div class="rounded-lg border border-zinc-200 p-8 text-center">
<.icon name="hero-signal-slash" class="mx-auto h-12 w-12 text-zinc-400" />
<h3 class="mt-4 text-lg font-semibold text-zinc-900">No hosts detected</h3>
<p class="mt-2 text-sm text-zinc-600">
Waiting for systant hosts to publish data via MQTT...
</p>
</div>
<% else %>
<%= for {hostname, host_data} <- @hosts do %>
<.host_card hostname={hostname} data={host_data} />
<% end %>
<% end %>
</div>
</div>
</div>
"""
end
attr :hostname, :string, required: true
attr :data, :map, required: true
defp host_card(assigns) do
~H"""
<div class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="rounded-full bg-green-100 p-2">
<.icon name="hero-server" class="h-5 w-5 text-green-600" />
</div>
<div>
<h3 class="text-lg font-semibold text-zinc-900"><%= @hostname %></h3>
<p class="text-sm text-zinc-600">
Last seen: <%= format_datetime(@data["last_seen"]) %>
</p>
</div>
</div>
<div class="rounded-full bg-green-100 px-3 py-1">
<span class="text-xs font-medium text-green-800">Online</span>
</div>
</div>
<div class="mt-4">
<h4 class="text-sm font-medium text-zinc-700 mb-2">Raw Data:</h4>
<pre class="text-xs bg-zinc-50 p-3 rounded border overflow-x-auto">
<%= Jason.encode!(@data, pretty: true) %>
</pre>
</div>
</div>
"""
end
defp format_datetime(%DateTime{} = datetime) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M:%S UTC")
end
defp format_datetime(_), do: "Unknown"
end

View File

@ -18,6 +18,7 @@ defmodule DashboardWeb.Router do
pipe_through :browser pipe_through :browser
get "/", PageController, :home get "/", PageController, :home
live "/hosts", HostsLive, :index
end end
# Other scopes may use custom stacks. # Other scopes may use custom stacks.

View File

@ -54,7 +54,8 @@ defmodule Dashboard.MixProject do
{:gettext, "~> 0.26"}, {:gettext, "~> 0.26"},
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
{:dns_cluster, "~> 0.1.1"}, {:dns_cluster, "~> 0.1.1"},
{:bandit, "~> 1.5"} {:bandit, "~> 1.5"},
{:tortoise, "~> 0.9.5"}
] ]
end end

View File

@ -7,6 +7,7 @@
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
"floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"},
"gen_state_machine": {:hex, :gen_state_machine, "3.0.0", "1e57f86a494e5c6b14137ebef26a7eb342b3b0070c7135f2d6768ed3f6b6cdff", [:mix], [], "hexpm", "0a59652574bebceb7309f6b749d2a41b45fdeda8dbb4da0791e355dd19f0ed15"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
@ -30,6 +31,7 @@
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"},
"tortoise": {:hex, :tortoise, "0.9.9", "2e467570ef1d342d4de8fdc6ba3861f841054ab524080ec3d7052ee07c04501d", [:mix], [{:gen_state_machine, "~> 2.0 or ~> 3.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}], "hexpm", "4a316220b4b443c2497f42702f0c0616af3e4b2cbc6c150ebebb51657a773797"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
} }