defmodule DashboardWeb.HostsLive do
@moduledoc """
LiveView for real-time systant host monitoring.
"""
use DashboardWeb, :live_view
alias Phoenix.PubSub
@pubsub_topic "systant:hosts"
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
# Subscribe to host updates from MQTT
PubSub.subscribe(Dashboard.PubSub, @pubsub_topic)
end
# Start with empty hosts - will be populated by MQTT
hosts = %{}
socket =
socket
|> assign(:hosts, hosts)
|> assign(:show_raw_data, %{}) # Track which hosts show raw data
|> assign(:page_title, "Systant Hosts")
{:ok, socket}
end
@impl true
def handle_info({:host_update, hostname, host_data}, socket) do
require Logger
Logger.info("LiveView received host update for #{hostname}: #{inspect(host_data)}")
updated_hosts = Map.put(socket.assigns.hosts, hostname, host_data)
{:noreply, assign(socket, :hosts, updated_hosts)}
end
@impl true
def handle_event("toggle_raw", %{"hostname" => hostname}, socket) do
current_state = Map.get(socket.assigns.show_raw_data, hostname, false)
updated_raw_data = Map.put(socket.assigns.show_raw_data, hostname, !current_state)
{:noreply, assign(socket, :show_raw_data, updated_raw_data)}
end
@impl true
def render(assigns) do
~H"""
<.icon name="hero-computer-desktop" class="h-4 w-4" />
Systant Host Monitor
Real-time system monitoring across all hosts
Live MQTT-powered dashboard showing statistics from all your systant-enabled hosts.
<%= if Enum.empty?(@hosts) do %>
<.icon name="hero-signal-slash" class="mx-auto h-12 w-12 text-zinc-400" />
No hosts detected
Waiting for systant hosts to publish data via MQTT...
<% else %>
<%= for {hostname, host_data} <- @hosts do %>
<.host_card
hostname={hostname}
data={host_data}
show_raw={Map.get(@show_raw_data, hostname, false)}
/>
<% end %>
<% end %>
"""
end
attr :hostname, :string, required: true
attr :data, :map, required: true
attr :show_raw, :boolean, default: false
defp host_card(assigns) do
assigns = assign(assigns, :show_raw, assigns[:show_raw] || false)
~H"""
<.icon name="hero-server" class="h-5 w-5 text-green-600" />
<%= @hostname %>
Last seen: <%= format_datetime(@data["last_seen"]) %>
Online
<%= if @show_raw do %>
Raw Data:
<%= Jason.encode!(@data, pretty: true) %>
<% else %>
<.metric_card
title="CPU Load Average"
icon="hero-cpu-chip"
data={@data["cpu"]}
type={:load_average}
/>
<.metric_card
title="Memory Usage"
icon="hero-circle-stack"
data={@data["memory"]}
type={:memory}
/>
<.metric_card
title="Disk Usage"
icon="hero-hard-drive"
data={@data["disk"]}
type={:disk}
/>
<%= if @data["gpu"] do %>
<.metric_card
title="GPU Status"
icon="hero-tv"
data={@data["gpu"]}
type={:gpu}
/>
<% end %>
<%= if @data["network"] && length(@data["network"]) > 0 do %>
<.metric_card
title="Network Interfaces"
icon="hero-signal"
data={@data["network"]}
type={:network}
/>
<% end %>
<%= if @data["temperature"] do %>
<.metric_card
title="Temperature"
icon="hero-fire"
data={@data["temperature"]}
type={:temperature}
/>
<% end %>
<%= if @data["processes"] do %>
<.metric_card
title="Top Processes"
icon="hero-list-bullet"
data={@data["processes"]}
type={:processes}
/>
<% end %>
System Information
Uptime:
<%= format_uptime(@data["system"]["uptime_seconds"]) %>
Erlang:
<%= @data["system"]["erlang_version"] %>
OTP:
<%= @data["system"]["otp_release"] %>
Schedulers:
<%= @data["system"]["schedulers"] %>
<% end %>
"""
end
defp format_datetime(%DateTime{} = datetime) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M:%S UTC")
end
defp format_datetime(_), do: "Unknown"
defp format_uptime(nil), do: "Unknown"
defp format_uptime(seconds) when is_integer(seconds) do
days = div(seconds, 86400)
hours = div(rem(seconds, 86400), 3600)
minutes = div(rem(seconds, 3600), 60)
cond do
days > 0 -> "#{days}d #{hours}h #{minutes}m"
hours > 0 -> "#{hours}h #{minutes}m"
true -> "#{minutes}m"
end
end
defp format_uptime(_), do: "Unknown"
attr :title, :string, required: true
attr :icon, :string, required: true
attr :data, :map, required: true
attr :type, :atom, required: true
defp metric_card(assigns) do
~H"""
<.icon name={@icon} class="h-5 w-5 text-zinc-600" />
<%= @title %>
<%= case @type do %>
<% :load_average -> %>
<.load_average_display data={@data} />
<% :memory -> %>
<.memory_display data={@data} />
<% :disk -> %>
<.disk_display data={@data} />
<% :gpu -> %>
<.gpu_display data={@data} />
<% :network -> %>
<.network_display data={@data} />
<% :temperature -> %>
<.temperature_display data={@data} />
<% :processes -> %>
<.processes_display data={@data} />
<% _ -> %>
No data available
<% end %>
"""
end
defp load_average_display(assigns) do
~H"""
<%= if @data do %>
1 min
<%= format_float(@data["avg1"]) %>
<.progress_bar value={@data["avg1"]} max={4.0} color={load_color(@data["avg1"])} />
5 min
<%= format_float(@data["avg5"]) %>
<.progress_bar value={@data["avg5"]} max={4.0} color={load_color(@data["avg5"])} />
15 min
<%= format_float(@data["avg15"]) %>
<.progress_bar value={@data["avg15"]} max={4.0} color={load_color(@data["avg15"])} />
<% else %>
No load data available
<% end %>
"""
end
defp memory_display(assigns) do
~H"""
<%= if @data && @data["total_kb"] do %>
Used
<%= @data["used_percent"] %>%
<.progress_bar value={@data["used_percent"]} max={100} color={memory_color(@data["used_percent"])} />
Total:
<%= format_kb(@data["total_kb"]) %>
Used:
<%= format_kb(@data["used_kb"]) %>
Available:
<%= format_kb(@data["available_kb"]) %>
<% else %>
No memory data available
<% end %>
"""
end
defp disk_display(assigns) do
~H"""
<%= if @data && @data["disks"] do %>
<%= for disk <- @data["disks"] do %>
<%= disk["mounted_on"] %>
<%= disk["use_percent"] %>%
<.progress_bar value={disk["use_percent"]} max={100} color={disk_color(disk["use_percent"])} />
<%= disk["used"] %> used
<%= disk["available"] %> free
<% end %>
<% else %>
No disk data available
<% end %>
"""
end
attr :value, :any, required: true
attr :max, :any, required: true
attr :color, :string, default: "bg-blue-500"
defp progress_bar(assigns) do
assigns = assign(assigns, :percentage, min(assigns.value / assigns.max * 100, 100))
~H"""
"""
end
# Helper functions for formatting and colors
defp format_float(nil), do: "N/A"
defp format_float(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 2)
defp format_float(value), do: to_string(value)
defp format_kb(nil), do: "N/A"
defp format_kb(kb) when is_integer(kb) do
cond do
kb >= 1_048_576 -> "#{Float.round(kb / 1_048_576, 1)} GB"
kb >= 1_024 -> "#{Float.round(kb / 1_024, 1)} MB"
true -> "#{kb} KB"
end
end
defp load_color(load) when is_float(load) do
cond do
load >= 2.0 -> "bg-red-500"
load >= 1.0 -> "bg-yellow-500"
true -> "bg-green-500"
end
end
defp load_color(_), do: "bg-zinc-400"
defp memory_color(percent) when is_float(percent) do
cond do
percent >= 90 -> "bg-red-500"
percent >= 75 -> "bg-yellow-500"
true -> "bg-blue-500"
end
end
defp memory_color(_), do: "bg-zinc-400"
defp disk_color(percent) when is_integer(percent) do
cond do
percent >= 90 -> "bg-red-500"
percent >= 80 -> "bg-yellow-500"
true -> "bg-green-500"
end
end
defp disk_color(_), do: "bg-zinc-400"
# GPU Display Component
defp gpu_display(assigns) do
~H"""
<%= if @data do %>
<%= if @data["nvidia"] && length(@data["nvidia"]) > 0 do %>
NVIDIA
<%= for gpu <- @data["nvidia"] do %>
<%= gpu["name"] %>
<%= gpu["utilization_percent"] %>%
<.progress_bar value={gpu["utilization_percent"]} max={100} color={gpu_color(gpu["utilization_percent"])} />
<%= gpu["temperature_c"] %>°C
<%= format_mb(gpu["memory_used_mb"]) %>/<%= format_mb(gpu["memory_total_mb"]) %>
<% end %>
<% end %>
<%= if @data["amd"] && length(@data["amd"]) > 0 do %>
AMD
<%= for gpu <- @data["amd"] do %>
<%= gpu["name"] %>
<%= gpu["utilization_percent"] || "N/A" %>%
<.progress_bar value={gpu["utilization_percent"] || 0} max={100} color={gpu_color(gpu["utilization_percent"])} />
<%= format_float(gpu["temperature_c"]) %>°C
<% end %>
<% end %>
<%= if (length(@data["nvidia"] || []) + length(@data["amd"] || [])) == 0 do %>
No GPUs detected
<% end %>
<% else %>
No GPU data available
<% end %>
"""
end
# Network Display Component
defp network_display(assigns) do
~H"""
<%= if @data && length(@data) > 0 do %>
<%= for interface <- Enum.take(@data, 3) do %>
<%= interface["interface"] %>
↓ <%= format_bytes(interface["rx_bytes"]) %>
↑ <%= format_bytes(interface["tx_bytes"]) %>
<%= if (interface["rx_errors"] + interface["tx_errors"]) > 0 do %>
Errors: RX <%= interface["rx_errors"] %>, TX <%= interface["tx_errors"] %>
<% end %>
<% end %>
<% else %>
No network interfaces
<% end %>
"""
end
# Temperature Display Component
defp temperature_display(assigns) do
~H"""
<%= if @data do %>
<%= if @data["cpu"] do %>
CPU
<%= format_float(@data["cpu"]) %>°C
<% end %>
<%= if @data["sensors"] && map_size(@data["sensors"]) > 0 do %>
<%= for {chip_name, temps} <- Enum.take(@data["sensors"], 3) do %>
<%= chip_name %>
<%= for {sensor, temp} <- Enum.take(temps, 2) do %>
<%= sensor %>
<%= format_float(temp) %>°C
<% end %>
<% end %>
<% end %>
<%= if !@data["cpu"] && (!@data["sensors"] || map_size(@data["sensors"]) == 0) do %>
No temperature sensors
<% end %>
<% else %>
No temperature data
<% end %>
"""
end
# Processes Display Component
defp processes_display(assigns) do
~H"""
<%= if @data && length(@data) > 0 do %>
<%= for process <- Enum.take(@data, 8) do %>
<%= process["command"] %>
<%= process["user"] %> (PID <%= process["pid"] %>)
<%= format_float(process["cpu_percent"]) %>%
<%= format_float(process["memory_percent"]) %>%
<% end %>
<% else %>
No process data
<% end %>
"""
end
# Additional helper functions
defp format_mb(nil), do: "N/A"
defp format_mb(mb) when is_integer(mb) do
cond do
mb >= 1024 -> "#{Float.round(mb / 1024, 1)} GB"
true -> "#{mb} MB"
end
end
defp format_bytes(bytes) when is_integer(bytes) do
cond do
bytes >= 1_073_741_824 -> "#{Float.round(bytes / 1_073_741_824, 1)} GB"
bytes >= 1_048_576 -> "#{Float.round(bytes / 1_048_576, 1)} MB"
bytes >= 1_024 -> "#{Float.round(bytes / 1_024, 1)} KB"
true -> "#{bytes} B"
end
end
defp format_bytes(_), do: "N/A"
defp gpu_color(util) when is_integer(util) do
cond do
util >= 80 -> "bg-red-500"
util >= 50 -> "bg-yellow-500"
true -> "bg-green-500"
end
end
defp gpu_color(_), do: "bg-zinc-400"
defp temp_color(temp) when is_number(temp) do
cond do
temp >= 80 -> "red-600"
temp >= 70 -> "yellow-600"
temp >= 60 -> "yellow-500"
true -> "green-600"
end
end
defp temp_color(_), do: "zinc-500"
end