towerops/lib/towerops_web/live/rf_link_health_live.ex

206 lines
6.3 KiB
Elixir

defmodule ToweropsWeb.RfLinkHealthLive do
@moduledoc """
RF Link Health Dashboard.
Displays wireless client link quality for NOC triage:
- Summary stat cards (total, healthy, degraded, critical)
- Weakest links table sorted by signal strength
- Expandable detail rows with signal bars, SNR, capacity, sparklines
- Real-time updates via PubSub
"""
use ToweropsWeb, :live_view
alias Towerops.RfLinks
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
_ =
if connected?(socket) do
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "wireless_clients:org:#{organization.id}")
end
summary = RfLinks.get_summary(organization.id)
links = RfLinks.list_rf_links(organization.id)
{:ok,
socket
|> assign(:page_title, "RF Links")
|> assign(:organization_id, organization.id)
|> assign(:summary, summary)
|> assign(:links, links)
|> assign(:filter, :all)
|> assign(:expanded_id, nil)
|> assign(:sparkline_data, %{})}
end
@impl true
def handle_params(params, _uri, socket) do
filter =
case params["filter"] do
"healthy" -> :healthy
"degraded" -> :degraded
"critical" -> :critical
_ -> :all
end
links = RfLinks.list_rf_links(socket.assigns.organization_id, filter: filter)
{:noreply, assign(socket, filter: filter, links: links)}
end
@impl true
def handle_event("filter", %{"filter" => filter}, socket) do
{:noreply, push_patch(socket, to: ~p"/rf-links?#{%{filter: filter}}")}
end
@impl true
def handle_event("toggle_expand", %{"id" => id}, socket) do
{expanded_id, sparkline_data} =
if socket.assigns.expanded_id == id do
{nil, socket.assigns.sparkline_data}
else
readings = RfLinks.get_readings_24h(id)
sparkline = build_sparkline_points(readings)
{id, Map.put(socket.assigns.sparkline_data, id, sparkline)}
end
{:noreply, assign(socket, expanded_id: expanded_id, sparkline_data: sparkline_data)}
end
@impl true
def handle_info({:wireless_client_updated, _payload}, socket) do
summary = RfLinks.get_summary(socket.assigns.organization_id)
links = RfLinks.list_rf_links(socket.assigns.organization_id, filter: socket.assigns.filter)
{:noreply, assign(socket, summary: summary, links: links)}
end
def handle_info(_, socket), do: {:noreply, socket}
# -- Helpers --
@doc false
def health_color(:healthy), do: "text-green-600 dark:text-green-400"
def health_color(:degraded), do: "text-yellow-600 dark:text-yellow-400"
def health_color(:critical), do: "text-red-600 dark:text-red-400"
def health_color(_), do: "text-gray-400 dark:text-gray-500"
@doc false
def health_bg(:healthy), do: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"
def health_bg(:degraded), do: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"
def health_bg(:critical), do: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
def health_bg(_), do: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
@doc false
def health_label(:healthy), do: "Healthy"
def health_label(:degraded), do: "Degraded"
def health_label(:critical), do: "Critical"
def health_label(_), do: "Unknown"
@doc false
def signal_bar_width(nil), do: 0
def signal_bar_width(signal) do
# Map signal from -100..-30 dBm to 0..100%
clamped = max(-100, min(-30, signal))
round((clamped + 100) / 70 * 100)
end
@doc false
def signal_bar_color(nil), do: "bg-gray-300 dark:bg-gray-600"
def signal_bar_color(signal) when signal > -65, do: "bg-green-500"
def signal_bar_color(signal) when signal > -75, do: "bg-yellow-500"
def signal_bar_color(_), do: "bg-red-500"
@doc false
def snr_bar_width(nil), do: 0
def snr_bar_width(snr) do
# Map SNR from 0..40 dB to 0..100%
clamped = max(0, min(40, snr))
round(clamped / 40 * 100)
end
@doc false
def snr_bar_color(nil), do: "bg-gray-300 dark:bg-gray-600"
def snr_bar_color(snr) when snr > 25, do: "bg-green-500"
def snr_bar_color(snr) when snr > 15, do: "bg-yellow-500"
def snr_bar_color(_), do: "bg-red-500"
@doc false
def format_signal(nil), do: "-"
def format_signal(val), do: "#{val} dBm"
@doc false
def format_snr(nil), do: "-"
def format_snr(val), do: "#{val} dB"
@doc false
def format_rate(nil), do: "-"
def format_rate(rate) when rate >= 1000, do: "#{Float.round(rate / 1000, 1)} Gbps"
def format_rate(rate), do: "#{rate} Mbps"
@doc false
def format_distance(nil), do: "-"
def format_distance(m) when m >= 1000, do: "#{Float.round(m / 1000, 1)} km"
def format_distance(m), do: "#{m} m"
@doc false
def format_uptime(nil), do: "-"
def format_uptime(seconds) do
days = div(seconds, 86_400)
hours = div(rem(seconds, 86_400), 3600)
format_uptime_parts(days, hours)
end
defp format_uptime_parts(days, hours) when days > 0, do: "#{days}d #{hours}h"
defp format_uptime_parts(_days, hours) when hours > 0, do: "#{hours}h"
defp format_uptime_parts(_, _), do: "< 1h"
@doc false
def device_name(%{device: %{name: name}}) when not is_nil(name), do: name
def device_name(%{device: %{ip_address: ip}}) when not is_nil(ip), do: ip
def device_name(_), do: "-"
@doc false
def link_name(link) do
link.hostname || link.ip_address || link.mac_address || "-"
end
@doc false
def stat_card_class(:all, _), do: "ring-2 ring-blue-500"
def stat_card_class(filter, filter), do: "ring-2 ring-blue-500"
def stat_card_class(_, _), do: ""
@doc false
def build_sparkline_points(readings) when length(readings) < 2, do: nil
def build_sparkline_points(readings) do
signals = readings |> Enum.map(& &1.signal_strength) |> Enum.reject(&is_nil/1)
if signals == [] do
nil
else
min_val = Enum.min(signals) - 5
max_val = Enum.max(signals) + 5
range = max(max_val - min_val, 1)
width = 200
height = 40
points =
signals
|> Enum.with_index()
|> Enum.map_join(" ", fn {val, i} ->
x = round(i / max(length(signals) - 1, 1) * width)
y = round((1 - (val - min_val) / range) * height)
"#{x},#{y}"
end)
%{points: points, width: width, height: height}
end
end
end