towerops/lib/towerops_web/live/graph_live/show.ex
2026-01-28 16:27:49 -06:00

489 lines
15 KiB
Elixir

defmodule ToweropsWeb.GraphLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Devices
alias Towerops.Monitoring
alias Towerops.Snmp
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => device_id, "sensor_type" => sensor_type} = params, _, socket) do
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}")
end
range = Map.get(params, "range", "24h")
interface_id = Map.get(params, "interface_id")
sensor_id = Map.get(params, "sensor_id")
storage_id = Map.get(params, "storage_id")
socket =
socket
|> assign(:device_id, device_id)
|> assign(:sensor_type, sensor_type)
|> assign(:range, range)
|> assign(:interface_id, interface_id)
|> assign(:sensor_id, sensor_id)
|> assign(:storage_id, storage_id)
|> load_graph_data()
{:noreply, socket}
end
@impl true
def handle_event("change_range", %{"range" => range}, socket) do
params = build_graph_params(socket.assigns, range)
{:noreply,
push_patch(socket,
to: ~p"/devices/#{socket.assigns.device_id}/graph/#{socket.assigns.sensor_type}?#{params}"
)}
end
defp build_graph_params(assigns, range) do
base_params = %{"range" => range}
base_params =
if assigns.interface_id do
Map.put(base_params, "interface_id", assigns.interface_id)
else
base_params
end
base_params =
if assigns.sensor_id do
Map.put(base_params, "sensor_id", assigns.sensor_id)
else
base_params
end
if assigns.storage_id do
Map.put(base_params, "storage_id", assigns.storage_id)
else
base_params
end
end
# Private functions
defp load_graph_data(socket) do
device_id = socket.assigns.device_id
sensor_type = socket.assigns.sensor_type
range = socket.assigns.range
device = Devices.get_device!(device_id)
{chart_data, title_suffix} = load_chart_data_for_type(socket.assigns, range)
{title, unit, auto_scale} = get_chart_config(sensor_type)
chart_title = if title_suffix, do: "#{title} - #{title_suffix}", else: title
show_zero_line = sensor_type == "traffic"
# Calculate max/min values from chart data
{max_value, min_value} = calculate_chart_stats(chart_data)
socket
|> assign(:device, device)
|> assign(:page_title, "#{device.name} - #{chart_title}")
|> assign(:chart_title, chart_title)
|> assign(:chart_data, chart_data)
|> assign(:unit, unit)
|> assign(:auto_scale, auto_scale)
|> assign(:show_zero_line, show_zero_line)
|> assign(:max_value, max_value)
|> assign(:min_value, min_value)
end
defp load_chart_data_for_type(%{sensor_type: "latency", device_id: device_id}, range) do
{load_latency_chart_data(device_id, range), nil}
end
defp load_chart_data_for_type(%{sensor_type: "traffic", interface_id: interface_id}, range)
when not is_nil(interface_id) do
{load_interface_traffic_chart_data(interface_id, range), get_interface_name(interface_id)}
end
defp load_chart_data_for_type(%{sensor_type: "traffic", device_id: device_id}, range) do
{load_traffic_chart_data(device_id, range), nil}
end
defp load_chart_data_for_type(%{sensor_type: "storage_volume", storage_id: storage_id}, range)
when not is_nil(storage_id) do
{load_storage_chart_data(storage_id, range), get_storage_name(storage_id)}
end
defp load_chart_data_for_type(%{sensor_id: sensor_id}, range) when not is_nil(sensor_id) do
{load_single_sensor_chart_data(sensor_id, range), get_sensor_name(sensor_id)}
end
defp load_chart_data_for_type(%{sensor_type: sensor_type, device_id: device_id}, range) do
sensor_types = get_sensor_types_for_chart(sensor_type)
{load_sensor_chart_data(device_id, sensor_types, range), nil}
end
defp get_sensor_types_for_chart("processors"), do: ["cpu_load"]
defp get_sensor_types_for_chart("memory"), do: ["memory_usage"]
defp get_sensor_types_for_chart("storage"), do: ["disk_usage"]
defp get_sensor_types_for_chart("temperature"), do: ["temperature", "cpu_temperature", "celsius"]
defp get_sensor_types_for_chart("voltage"), do: ["voltage", "volts"]
defp get_sensor_types_for_chart("traffic"), do: nil
# For unknown sensor types, use the type directly (supports count, pppoe_sessions, etc.)
defp get_sensor_types_for_chart(sensor_type), do: [sensor_type]
defp get_chart_config("latency"), do: {"ICMP Latency", "ms", true}
defp get_chart_config("processors"), do: {"Processor Usage", "%", false}
defp get_chart_config("memory"), do: {"Memory Usage", "%", false}
defp get_chart_config("storage"), do: {"Storage Usage", "%", false}
defp get_chart_config("storage_volume"), do: {"Storage Usage", "%", false}
defp get_chart_config("temperature"), do: {"Temperature", "°C", true}
defp get_chart_config("voltage"), do: {"Voltage", "V", true}
defp get_chart_config("traffic"), do: {"Overall Traffic", "bps", true}
defp get_chart_config("count"), do: {"Count", "", true}
defp get_chart_config("pppoe_sessions"), do: {"PPPoE Sessions", "", true}
defp get_chart_config("connections"), do: {"Connections", "", true}
defp get_chart_config("wireless"), do: {"Wireless Metrics", "", true}
# Humanize unknown sensor types for title
defp get_chart_config(sensor_type), do: {humanize_sensor_type(sensor_type), "", true}
defp humanize_sensor_type(sensor_type) do
sensor_type
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
defp load_sensor_chart_data(device_id, sensor_types, range) do
case Snmp.get_device_with_associations(device_id) do
nil ->
nil
device ->
build_sensor_chart_json(device, sensor_types, range)
end
end
defp load_single_sensor_chart_data(sensor_id, range) do
case Snmp.get_sensor(sensor_id) do
nil ->
nil
sensor ->
since = get_datetime_from_range(range)
limit = get_limit_for_range(range)
dataset = sensor_to_dataset(sensor, since, limit)
Jason.encode!(%{datasets: [dataset]})
end
end
defp get_sensor_name(sensor_id) do
case Snmp.get_sensor(sensor_id) do
nil -> nil
sensor -> sensor.sensor_descr
end
end
defp load_storage_chart_data(storage_id, range) do
case Snmp.get_storage(storage_id) do
nil ->
nil
storage ->
since = get_datetime_from_range(range)
limit = get_limit_for_range(range)
dataset = storage_to_dataset(storage, since, limit)
Jason.encode!(%{datasets: [dataset]})
end
end
defp get_storage_name(storage_id) do
case Snmp.get_storage(storage_id) do
nil -> nil
storage -> storage.description || "Storage #{storage.storage_index}"
end
end
defp storage_to_dataset(storage, since, limit) do
readings =
storage.id
|> Snmp.get_storage_readings(since: since, limit: limit)
|> Enum.reverse()
%{
label: storage.description || "Storage #{storage.storage_index}",
data: Enum.map(readings, &storage_reading_to_chart_point/1)
}
end
defp storage_reading_to_chart_point(reading) do
%{
x: DateTime.to_unix(reading.checked_at, :millisecond),
y: if(reading.usage_percent, do: Float.round(reading.usage_percent, 1))
}
end
defp build_sensor_chart_json(device, sensor_types, range) do
sensors =
device.sensors
|> Enum.filter(&(&1.sensor_type in sensor_types))
|> Enum.sort_by(& &1.sensor_index)
if Enum.empty?(sensors) do
nil
else
since = get_datetime_from_range(range)
limit = get_limit_for_range(range)
datasets = Enum.map(sensors, &sensor_to_dataset(&1, since, limit))
Jason.encode!(%{datasets: datasets})
end
end
defp sensor_to_dataset(sensor, since, limit) do
readings =
sensor.id
|> Snmp.get_sensor_readings(since: since, limit: limit)
|> Enum.reverse()
%{
label: sensor.sensor_descr,
data: Enum.map(readings, &reading_to_chart_point/1)
}
end
defp reading_to_chart_point(reading) do
%{
x: DateTime.to_unix(reading.checked_at, :millisecond),
y: if(reading.value, do: Float.round(reading.value, 1))
}
end
defp get_datetime_from_range("1h"), do: DateTime.add(DateTime.utc_now(), -1, :hour)
defp get_datetime_from_range("6h"), do: DateTime.add(DateTime.utc_now(), -6, :hour)
defp get_datetime_from_range("12h"), do: DateTime.add(DateTime.utc_now(), -12, :hour)
defp get_datetime_from_range("24h"), do: DateTime.add(DateTime.utc_now(), -24, :hour)
defp get_datetime_from_range("7d"), do: DateTime.add(DateTime.utc_now(), -7, :day)
defp get_datetime_from_range("30d"), do: DateTime.add(DateTime.utc_now(), -30, :day)
defp get_datetime_from_range(_), do: DateTime.add(DateTime.utc_now(), -24, :hour)
defp get_limit_for_range("1h"), do: 360
defp get_limit_for_range("6h"), do: 720
defp get_limit_for_range("12h"), do: 1440
defp get_limit_for_range("24h"), do: 2880
defp get_limit_for_range("7d"), do: 10_080
defp get_limit_for_range("30d"), do: 43_200
defp get_limit_for_range(_), do: 2880
defp load_traffic_chart_data(device_id, range) do
case Snmp.get_device_with_associations(device_id) do
nil -> nil
device -> build_traffic_chart_json(device, range)
end
end
defp load_interface_traffic_chart_data(interface_id, range) do
case Snmp.get_interface(interface_id) do
nil -> nil
interface -> build_interface_traffic_chart_json(interface, range)
end
end
defp get_interface_name(interface_id) do
case Snmp.get_interface(interface_id) do
nil -> "Unknown Interface"
interface -> interface.if_name || interface.if_descr || "Interface #{interface.if_index}"
end
end
defp load_latency_chart_data(device_id, range) do
since = get_datetime_from_range(range)
limit = get_limit_for_range(range)
checks =
device_id
|> Monitoring.get_latency_data(since: since, limit: limit)
|> Enum.reverse()
if Enum.empty?(checks) do
nil
else
dataset = %{
label: "ICMP Latency",
data: Enum.map(checks, &latency_check_to_chart_point/1)
}
Jason.encode!(%{datasets: [dataset]})
end
end
defp latency_check_to_chart_point(check) do
%{
x: DateTime.to_unix(check.checked_at, :millisecond),
y: check.response_time_ms
}
end
# Calculate max and min values from chart data
defp calculate_chart_stats(nil), do: {nil, nil}
defp calculate_chart_stats(chart_data) when is_binary(chart_data) do
case Jason.decode(chart_data) do
{:ok, %{"datasets" => datasets}} ->
extract_min_max_from_datasets(datasets)
_ ->
{nil, nil}
end
end
defp extract_min_max_from_datasets(datasets) do
values = Enum.flat_map(datasets, &extract_values_from_dataset/1)
case values do
[] -> {nil, nil}
_ -> {Enum.max(values), Enum.min(values)}
end
end
defp extract_values_from_dataset(%{"data" => data}) when is_list(data) do
Enum.flat_map(data, &extract_y_value/1)
end
defp extract_values_from_dataset(_), do: []
defp extract_y_value(%{"y" => y}) when is_number(y), do: [y]
defp extract_y_value(_), do: []
defp build_traffic_chart_json(device, range) do
if Enum.empty?(device.interfaces), do: nil, else: build_traffic_datasets(device, range)
end
defp build_traffic_datasets(device, range) do
since = get_datetime_from_range(range)
limit = get_limit_for_range(range)
active_interfaces = Enum.filter(device.interfaces, &(&1.if_oper_status == "up"))
if Enum.empty?(active_interfaces), do: nil, else: encode_interface_datasets(active_interfaces, since, limit)
end
defp encode_interface_datasets(interfaces, since, limit) do
datasets = build_per_interface_datasets(interfaces, since, limit)
if Enum.empty?(datasets), do: nil, else: Jason.encode!(%{datasets: datasets})
end
defp build_per_interface_datasets(interfaces, since, limit) do
Enum.flat_map(interfaces, fn interface ->
stats =
interface.id
|> Snmp.get_interface_stats(since: since, limit: limit)
|> Enum.reverse()
if Enum.empty?(stats) do
[]
else
interface_name = interface.if_name || interface.if_descr
in_data = calculate_interface_traffic_data(stats, :inbound)
out_data = calculate_interface_traffic_data(stats, :outbound)
[
%{label: "#{interface_name} Out", data: out_data},
%{label: "#{interface_name} In", data: in_data}
]
end
end)
end
defp build_interface_traffic_chart_json(interface, range) do
since = get_datetime_from_range(range)
limit = get_limit_for_range(range)
stats =
interface.id
|> Snmp.get_interface_stats(since: since, limit: limit)
|> Enum.reverse()
if Enum.empty?(stats) do
nil
else
in_data = calculate_interface_traffic_data(stats, :inbound)
out_data = calculate_interface_traffic_data(stats, :outbound)
datasets = [
%{label: "Outbound", data: out_data},
%{label: "Inbound", data: in_data}
]
Jason.encode!(%{datasets: datasets})
end
end
defp calculate_interface_traffic_data(stats, :inbound) do
stats
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [stat1, stat2] ->
time_diff = stat2.checked_at |> DateTime.diff(stat1.checked_at, :second) |> max(1)
bps =
((stat2.if_in_octets - stat1.if_in_octets) * 8 / time_diff)
|> max(0)
|> :erlang.float()
|> Float.round(2)
%{
x: DateTime.to_unix(stat2.checked_at, :millisecond),
y: -bps
}
end)
end
defp calculate_interface_traffic_data(stats, :outbound) do
stats
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [stat1, stat2] ->
time_diff = stat2.checked_at |> DateTime.diff(stat1.checked_at, :second) |> max(1)
bps =
((stat2.if_out_octets - stat1.if_out_octets) * 8 / time_diff)
|> max(0)
|> :erlang.float()
|> Float.round(2)
%{
x: DateTime.to_unix(stat2.checked_at, :millisecond),
y: bps
}
end)
end
# Format value with unit for display
defp format_value(value, "bps") do
abs_value = abs(value)
cond do
abs_value >= 1_000_000_000 ->
"#{Float.round(abs_value / 1_000_000_000, 2)} Gbps"
abs_value >= 1_000_000 ->
"#{Float.round(abs_value / 1_000_000, 2)} Mbps"
abs_value >= 1_000 ->
"#{Float.round(abs_value / 1_000, 2)} Kbps"
true ->
"#{Float.round(abs_value, 2)} bps"
end
end
# For counts (empty unit), format as integer
defp format_value(value, "") do
"#{round(value)}"
end
# For other units, format with one decimal place
defp format_value(value, unit) do
"#{Float.round(value, 1)}#{unit}"
end
end