defmodule ToweropsWeb.GraphLive.Show do @moduledoc false use ToweropsWeb, :live_view alias Towerops.Devices alias Towerops.Monitoring alias Towerops.Repo 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 organization = socket.assigns.current_scope.organization # Verify device exists and user has access case Devices.get_device(device_id) do nil -> {:noreply, socket |> put_flash(:error, "Device not found") |> push_navigate(to: ~p"/devices")} device -> # Preload site and organization to check access device = Repo.preload(device, site: :organization) if device.site.organization_id == organization.id do maybe_subscribe_to_device(socket, device_id) 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} else {:noreply, socket |> put_flash(:error, "You don't have access to this device") |> push_navigate(to: ~p"/devices")} end end end defp maybe_subscribe_to_device(socket, device_id) do if connected?(socket) do _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}") end :ok 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) # Return nil if no data points (empty readings) if Enum.empty?(dataset.data) do nil else Jason.encode!(%{datasets: [dataset]}) end 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