206 lines
6.3 KiB
Elixir
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
|