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