defmodule ToweropsWeb.GraphLive.Show do @moduledoc false use ToweropsWeb, :live_view alias Towerops.Agents alias Towerops.Devices alias Towerops.Devices.DeviceSchema alias Towerops.Monitoring alias Towerops.Snmp require Logger # Maximum live data points (5 minutes at 1 second intervals) @max_live_points 300 @impl true def mount(_params, _session, socket) do {:ok, socket} end @impl true def handle_params(%{"id" => device_id, "check_id" => check_id} = params, _, socket) do organization = socket.assigns.current_scope.organization with {:ok, device} <- find_device(device_id), :ok <- verify_device_access(device, organization), {:ok, check} <- find_check(check_id), :ok <- verify_check_device(check, device_id) do socket = initialize_check_graph_view(socket, device, check, params) {:noreply, socket} else {:error, :not_found} -> {:noreply, socket |> put_flash(:error, t("Device not found")) |> push_navigate(to: ~p"/devices")} {:error, :check_not_found} -> {:noreply, socket |> put_flash(:error, t("Check not found")) |> push_navigate(to: ~p"/devices/#{device_id}?tab=checks")} {:error, :check_device_mismatch} -> {:noreply, socket |> put_flash(:error, t("Check does not belong to this device")) |> push_navigate(to: ~p"/devices/#{device_id}?tab=checks")} {:error, :access_denied} -> {:noreply, socket |> put_flash(:error, t("You don't have access to this device")) |> push_navigate(to: ~p"/devices")} end end @impl true def handle_params(%{"id" => device_id, "sensor_type" => sensor_type} = params, _, socket) do organization = socket.assigns.current_scope.organization with {:ok, device} <- find_device(device_id), :ok <- verify_device_access(device, organization) do socket = initialize_graph_view(socket, device_id, sensor_type, params) {:noreply, socket} else {:error, :not_found} -> {:noreply, socket |> put_flash(:error, t("Device not found")) |> push_navigate(to: ~p"/devices")} {:error, :access_denied} -> {:noreply, socket |> put_flash(:error, t("You don't have access to this device")) |> push_navigate(to: ~p"/devices")} end end defp find_device(device_id) do case Devices.get_device(device_id) do nil -> {:error, :not_found} device -> {:ok, device} end end defp verify_device_access(device, organization) do if device.organization_id == organization.id do :ok else {:error, :access_denied} end end defp find_check(check_id) do case Monitoring.get_check(check_id) do nil -> {:error, :check_not_found} check -> {:ok, check} end end defp verify_check_device(check, device_id) do if check.device_id == device_id do :ok else {:error, :check_device_mismatch} end end defp initialize_check_graph_view(socket, device, check, params) do range = Map.get(params, "range", "24h") {from_time, to_time} = get_time_range_for_graph(range) {chart_data, unit, auto_scale, dual_axis} = build_check_chart_data(check, from_time, to_time) {max_value, min_value} = calculate_chart_stats(chart_data) socket |> assign(:device, device) |> assign(:device_id, device.id) |> assign(:check, check) |> assign(:check_id, check.id) |> assign(:range, range) |> assign(:page_title, "#{device.name} - #{check.name}") |> assign(:chart_title, check.name) |> assign(:chart_data, chart_data) |> assign(:unit, unit) |> assign(:auto_scale, auto_scale) |> assign(:dual_axis, dual_axis) |> assign(:show_zero_line, false) |> assign(:max_value, max_value) |> assign(:min_value, min_value) |> assign(:is_live_mode, false) |> assign(:has_agent_assignment, false) end defp build_check_chart_data(%{check_type: "dns"} = check, from_time, to_time) do points = Monitoring.get_check_graph_data_with_status(check.id, from_time, to_time) if Enum.empty?(points) do {nil, "ms", true, false} else response_time_dataset = %{ label: "Response Time (ms)", yAxisID: "y", data: points |> Enum.filter(fn p -> p.value != nil end) |> Enum.map(fn p -> %{x: DateTime.to_unix(p.timestamp, :millisecond), y: Float.round(p.value, 1)} end) } status_dataset = %{ label: "Status", yAxisID: "y1", data: Enum.map(points, fn p -> %{ x: DateTime.to_unix(p.timestamp, :millisecond), y: if(p.status == 0, do: 1, else: 0) } end) } chart_data = safe_encode_chart_data(%{datasets: [response_time_dataset, status_dataset]}) {chart_data, "ms", true, true} end end defp build_check_chart_data(check, from_time, to_time) do graph_data_points = Monitoring.get_check_graph_data(check.id, from_time, to_time) {unit, auto_scale} = get_check_unit_and_scale(check) chart_data = if Enum.empty?(graph_data_points) do nil else dataset = %{ label: check.name, data: Enum.map(graph_data_points, fn point -> %{ x: DateTime.to_unix(point.timestamp, :millisecond), y: Float.round(point.value, 1) } end) } safe_encode_chart_data(%{datasets: [dataset]}) end {chart_data, unit, auto_scale, false} end defp get_time_range_for_graph("1h"), do: {DateTime.add(DateTime.utc_now(), -1, :hour), DateTime.utc_now()} defp get_time_range_for_graph("6h"), do: {DateTime.add(DateTime.utc_now(), -6, :hour), DateTime.utc_now()} defp get_time_range_for_graph("12h"), do: {DateTime.add(DateTime.utc_now(), -12, :hour), DateTime.utc_now()} defp get_time_range_for_graph("24h"), do: {DateTime.add(DateTime.utc_now(), -24, :hour), DateTime.utc_now()} defp get_time_range_for_graph("7d"), do: {DateTime.add(DateTime.utc_now(), -7, :day), DateTime.utc_now()} defp get_time_range_for_graph("30d"), do: {DateTime.add(DateTime.utc_now(), -30, :day), DateTime.utc_now()} defp get_time_range_for_graph(_), do: {DateTime.add(DateTime.utc_now(), -24, :hour), DateTime.utc_now()} defp get_check_unit_and_scale(%{check_type: "snmp_sensor", config: config}) do # Get unit from sensor config unit = config["sensor_unit"] || "" {unit, true} end defp get_check_unit_and_scale(%{check_type: "snmp_processor"}), do: {"%", false} defp get_check_unit_and_scale(%{check_type: "snmp_storage"}), do: {"%", false} defp get_check_unit_and_scale(%{check_type: "http"}), do: {"ms", true} defp get_check_unit_and_scale(%{check_type: "tcp"}), do: {"ms", true} defp get_check_unit_and_scale(%{check_type: "dns"}), do: {"ms", true} defp get_check_unit_and_scale(_), do: {"", true} defp initialize_graph_view(socket, device_id, sensor_type, params) 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") # Check if device has agent assignment has_agent = Agents.get_device_assignment(device_id) != nil 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) |> assign(:is_live_mode, range == "live") |> assign(:has_agent_assignment, has_agent) |> maybe_start_live_polling() |> load_graph_data() 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 # Check if this is a check-based graph or sensor-based graph if Map.has_key?(socket.assigns, :check_id) do # Check-based graph params = %{"range" => range, "check_id" => socket.assigns.check_id} {:noreply, push_patch(socket, to: ~p"/devices/#{socket.assigns.device_id}/graph/check?#{params}" )} else # Sensor-based graph (existing behavior) 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 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 if socket.assigns.is_live_mode do load_live_mode_graph_data(socket) else # Historical mode - existing logic device_id = socket.assigns.device_id sensor_type = socket.assigns.sensor_type range = socket.assigns.range device = case Devices.get_device(device_id) do nil -> raise Ecto.NoResultsError, queryable: DeviceSchema device -> device end {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(:dual_axis, false) |> assign(:show_zero_line, show_zero_line) |> assign(:max_value, max_value) |> assign(:min_value, min_value) end end defp load_live_mode_graph_data(socket) do device_id = socket.assigns.device_id sensor_type = socket.assigns.sensor_type device = case Devices.get_device(device_id) do nil -> raise Ecto.NoResultsError, queryable: DeviceSchema device -> device end {title, unit, auto_scale} = get_chart_config(sensor_type) title_suffix = get_title_suffix(socket.assigns) chart_title = if title_suffix, do: "#{title} - #{title_suffix}", else: title socket |> assign(:device, device) |> assign(:page_title, "#{device.name} - #{chart_title} (Live)") |> assign(:chart_title, chart_title) |> assign(:chart_data, safe_encode_chart_data(%{datasets: []})) |> assign(:unit, unit) |> assign(:auto_scale, auto_scale) |> assign(:dual_axis, false) |> assign(:show_zero_line, sensor_type == "traffic") |> assign(:max_value, nil) |> assign(:min_value, nil) |> assign(:previous_interface_stats, nil) end defp get_title_suffix(assigns) do cond do assigns[:sensor_id] -> get_sensor_name(assigns[:sensor_id]) assigns[:interface_id] && assigns[:sensor_type] in ["traffic", "interface_errors"] -> get_interface_name(assigns[:interface_id]) assigns[:storage_id] && assigns[:sensor_type] == "storage_volume" -> get_storage_name(assigns[:storage_id]) true -> nil end 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: "interface_errors", interface_id: interface_id}, range) when not is_nil(interface_id) do {load_interface_errors_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: {"Ping 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("interface_errors"), do: {"Interface Errors & Discards", "", 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 safe_encode_chart_data(%{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) safe_encode_chart_data(%{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)) safe_encode_chart_data(%{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 load_interface_errors_chart_data(interface_id, range) do case Snmp.get_interface(interface_id) do nil -> nil interface -> build_interface_errors_chart_json(interface, range) end end defp build_interface_errors_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 datasets = Enum.reject( [ %{label: "In Errors", data: build_error_rate_data(stats, :if_in_errors)}, %{label: "Out Errors", data: build_error_rate_data(stats, :if_out_errors)}, %{label: "In Discards", data: build_error_rate_data(stats, :if_in_discards)}, %{label: "Out Discards", data: build_error_rate_data(stats, :if_out_discards)} ], fn ds -> Enum.empty?(ds.data) end ) if Enum.empty?(datasets), do: nil, else: safe_encode_chart_data(%{datasets: datasets}) end end # Calculates rate-of-change per minute for a cumulative error/discard counter defp build_error_rate_data(stats, field) do stats |> Enum.chunk_every(2, 1, :discard) |> Enum.flat_map(fn [stat1, stat2] -> v1 = Map.get(stat1, field) v2 = Map.get(stat2, field) if is_nil(v1) or is_nil(v2) do [] else time_diff = stat2.checked_at |> DateTime.diff(stat1.checked_at, :second) |> max(1) rate = ((v2 - v1) / time_diff * 60) |> max(0) |> Float.round(2) [%{x: DateTime.to_unix(stat2.checked_at, :millisecond), y: rate}] end 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: "Ping 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: Float.round(check.response_time_ms, 1) } 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) # Process all interfaces (regardless of operational status) to show total device traffic if Enum.empty?(device.interfaces), do: nil, else: encode_interface_datasets(device.interfaces, since, limit, device) end # Get device for adding capacity lines to full-page traffic view defp encode_interface_datasets(interfaces, since, limit, device) do datasets = build_per_interface_datasets(interfaces, since, limit) # Add capacity datasets for backhaul devices datasets = if device.device.device_role == "backhaul" && !Enum.empty?(datasets) do add_capacity_to_full_graph(datasets, interfaces, since, limit, device.sensors) else datasets end if Enum.empty?(datasets), do: nil, else: safe_encode_chart_data(%{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 # Add capacity reference lines for backhaul devices on full-page graph. # Uses Rx/Tx Capacity sensors when available (e.g. AirFiber adaptive modulation), # otherwise falls back to interface configured_capacity_bps or if_speed. defp add_capacity_to_full_graph(datasets, interfaces, since, _limit, sensors) do rx_sensor = Enum.find(sensors, &(&1.sensor_descr == "Rx Capacity")) tx_sensor = Enum.find(sensors, &(&1.sensor_descr == "Tx Capacity")) capacity_data = resolve_capacity_data(datasets, interfaces, since, rx_sensor, tx_sensor) max_capacity = capacity_data |> Enum.map(fn {_, rx, _} -> rx end) |> Enum.max(fn -> 0 end) if max_capacity > 0 do datasets ++ [ %{ label: "Capacity (In)", data: Enum.map(capacity_data, fn {ts, rx, _} -> %{x: ts, y: Float.round(rx * 1.0, 2)} end), borderDash: [5, 5], borderWidth: 2, pointRadius: 0, fill: false }, %{ label: "Capacity (Out)", data: Enum.map(capacity_data, fn {ts, _, tx} -> %{x: ts, y: -Float.round(tx * 1.0, 2)} end), borderDash: [5, 5], borderWidth: 2, pointRadius: 0, fill: false } ] else datasets end end defp resolve_capacity_data(datasets, interfaces, since, rx_sensor, tx_sensor) when rx_sensor != nil or tx_sensor != nil do rx_readings = load_capacity_sensor_readings_for_graph(rx_sensor, since) tx_readings = load_capacity_sensor_readings_for_graph(tx_sensor, since) if map_size(rx_readings) > 0 || map_size(tx_readings) > 0 do all_ts = (Map.keys(rx_readings) ++ Map.keys(tx_readings)) |> MapSet.new() |> Enum.sort() Enum.map(all_ts, fn ts -> rx = Map.get(rx_readings, ts, capacity_sensor_fallback_bps(rx_sensor)) tx = Map.get(tx_readings, ts, capacity_sensor_fallback_bps(tx_sensor)) {ts, rx, tx} end) else build_interface_speed_capacity(datasets, interfaces, since) end end defp resolve_capacity_data(datasets, interfaces, since, _rx_sensor, _tx_sensor) do build_interface_speed_capacity(datasets, interfaces, since) end defp load_capacity_sensor_readings_for_graph(nil, _since), do: %{} defp load_capacity_sensor_readings_for_graph(sensor, since) do sensor.id |> Snmp.get_sensor_readings(since: since, limit: 1000) |> Map.new(fn r -> bps = if r.value, do: round(r.value * 1_000_000), else: 0 {DateTime.to_unix(r.checked_at, :millisecond), bps} end) end defp capacity_sensor_fallback_bps(nil), do: 0 defp capacity_sensor_fallback_bps(sensor), do: if(sensor.last_value, do: round(sensor.last_value * 1_000_000), else: 0) # Fallback: derive capacity from interface configured_capacity_bps or if_speed defp build_interface_speed_capacity(datasets, interfaces, since) do interface_capacity_map = Map.new(interfaces, fn interface -> capacity = interface.configured_capacity_bps || interface.if_speed || 0 {interface.id, capacity} end) interface_activity_by_timestamp = interfaces |> Enum.flat_map(fn interface -> interface.id |> Snmp.get_interface_stats(since: since, limit: 1000) |> Enum.map(fn stat -> ts_ms = DateTime.to_unix(stat.checked_at, :millisecond) {ts_ms, interface.id} end) end) |> Enum.group_by(fn {ts, _} -> ts end, fn {_, iface_id} -> iface_id end) all_timestamps = datasets |> Enum.flat_map(fn dataset -> case dataset.data do nil -> [] data -> Enum.map(data, & &1.x) end end) |> Enum.uniq() |> Enum.sort() Enum.map(all_timestamps, fn ts -> active_interfaces = interface_activity_by_timestamp |> Enum.filter(fn {stat_ts, _} -> abs(stat_ts - ts) < 60_000 end) |> Enum.flat_map(fn {_, iface_ids} -> iface_ids end) |> Enum.uniq() total_capacity = Enum.reduce(active_interfaces, 0, fn iface_id, acc -> acc + Map.get(interface_capacity_map, iface_id, 0) end) {ts, total_capacity, total_capacity} 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} ] safe_encode_chart_data(%{datasets: datasets}) end end defp calculate_interface_traffic_data(stats, :inbound) do stats |> Enum.chunk_every(2, 1, :discard) |> Enum.map(fn [stat1, stat2] -> if is_nil(stat1.if_in_octets) or is_nil(stat2.if_in_octets) do nil else time_diff = stat2.checked_at |> DateTime.diff(stat1.checked_at, :second) |> max(1) bps = Towerops.Capacity.calculate_bps(stat1.if_in_octets, stat2.if_in_octets, time_diff) %{ x: DateTime.to_unix(stat2.checked_at, :millisecond), y: Float.round(bps, 2) } end end) |> Enum.reject(&is_nil/1) end defp calculate_interface_traffic_data(stats, :outbound) do stats |> Enum.chunk_every(2, 1, :discard) |> Enum.map(fn [stat1, stat2] -> if is_nil(stat1.if_out_octets) or is_nil(stat2.if_out_octets) do nil else time_diff = stat2.checked_at |> DateTime.diff(stat1.checked_at, :second) |> max(1) bps = Towerops.Capacity.calculate_bps(stat1.if_out_octets, stat2.if_out_octets, time_diff) %{ x: DateTime.to_unix(stat2.checked_at, :millisecond), y: -Float.round(bps, 2) } end end) |> Enum.reject(&is_nil/1) 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) when is_float(value) do "#{Float.round(value, 1)} #{unit}" end defp format_value(value, unit) when is_integer(value) do "#{Float.round(value / 1, 1)} #{unit}" end # Live polling functions # Start/stop live polling based on mode defp maybe_start_live_polling(socket) do if socket.assigns[:is_live_mode] && connected?(socket) do cancel_live_polling_timer(socket) socket |> assign(:live_data_buffer, %{}) |> assign(:live_data_points, 0) |> schedule_live_poll() else cancel_live_polling_timer(socket) end end # Schedule next poll in 1 second defp schedule_live_poll(socket) do timer_ref = Process.send_after(self(), :live_poll, 1_000) assign(socket, :live_poll_timer, timer_ref) end # Cancel existing timer defp cancel_live_polling_timer(socket) do case Map.get(socket.assigns, :live_poll_timer) do nil -> socket timer_ref -> Process.cancel_timer(timer_ref) assign(socket, :live_poll_timer, nil) end end # Handle device status changes from PubSub @impl true def handle_info({:device_status_changed, _device_id, _new_status, _response_time}, socket) do # Reload device to get updated status device = Devices.get_device(socket.assigns.device_id) {:noreply, assign(socket, :device, device)} end # Handle sensor updates from PubSub @impl true def handle_info({:state_sensors_updated, _device_id}, socket) do {:noreply, load_graph_data(socket)} end # Handle neighbor updates from PubSub (ignored - not relevant to graphs) @impl true def handle_info({:neighbors_updated, _device_id}, socket) do {:noreply, socket} end # Handle live poll event @impl true def handle_info(:live_poll, socket) do if socket.assigns[:is_live_mode] do socket = perform_live_poll(socket) {:noreply, schedule_live_poll(socket)} else {:noreply, socket} end end # Catch-all for unexpected messages @impl true def handle_info(_msg, socket), do: {:noreply, socket} # Perform SNMP polling and update buffer defp perform_live_poll(socket) do device_id = socket.assigns.device_id # Verify device still exists and access is valid case Devices.get_device(device_id) do nil -> socket |> put_flash(:error, t("Device no longer exists")) |> push_navigate(to: ~p"/devices") device -> organization = socket.assigns.current_scope.organization if device.organization_id == organization.id do poll_and_update_buffer(socket, device) else socket |> put_flash(:error, t("Access to device revoked")) |> push_navigate(to: ~p"/devices") end end end # Poll sensors and update buffer defp poll_and_update_buffer(socket, device) do now = DateTime.utc_now() timestamp_ms = DateTime.to_unix(now, :millisecond) # Check effective agent (device → site → org cascade) agent_token_id = Agents.get_effective_agent_token(device) Logger.info("Live poll for device #{device.name}: agent_token_id=#{inspect(agent_token_id)}") {new_points, updated_socket} = if agent_token_id do # Request agent to poll NOW and wait for result Logger.info("Requesting live poll from agent") points = request_agent_live_poll_and_wait(device.id, agent_token_id, socket.assigns) {points, socket} else # Do direct SNMP polling Logger.info("Using direct SNMP for live polling") client_opts = build_snmp_client_opts(device) # For traffic graphs, we need to store interface stats for next poll if socket.assigns.sensor_type == "traffic" do poll_traffic_with_state_update(socket, client_opts, timestamp_ms) else points = poll_sensors_for_live_mode(socket.assigns, client_opts, timestamp_ms) {points, socket} end end Logger.info("Live poll fetched #{length(new_points)} data points: #{inspect(new_points)}") # Update buffer with new points (rolling window) {updated_buffer, point_count} = update_live_data_buffer( updated_socket.assigns.live_data_buffer, updated_socket.assigns.live_data_points, new_points ) # Push update to client updated_socket |> assign(:live_data_buffer, updated_buffer) |> assign(:live_data_points, point_count) |> push_event("live_data_update", %{ timestamp: timestamp_ms, points: new_points }) end # Request live poll from agent and wait for response defp request_agent_live_poll_and_wait(device_id, agent_token_id, assigns) do # Generate unique reply topic for this request reply_topic = "live_poll_reply:#{:erlang.unique_integer([:positive])}" # Subscribe to reply topic :ok = Phoenix.PubSub.subscribe(Towerops.PubSub, reply_topic) # Get sensors we're interested in sensors = get_sensors_for_live_mode(assigns) sensor_oids = Enum.map(sensors, & &1.sensor_oid) # Broadcast live poll request with reply topic and sensor OIDs Phoenix.PubSub.broadcast( Towerops.PubSub, "agent:#{agent_token_id}:live_poll", {:live_poll_requested, device_id, sensor_oids, reply_topic} ) # Wait for response with timeout receive do {:live_poll_result, oid_values} -> # Process the OID values into data points process_live_poll_oids(sensors, oid_values) after 3000 -> Logger.warning("Live poll timeout waiting for agent response") [] end end # Convert OID values from agent into data points defp process_live_poll_oids(sensors, oid_values) do timestamp_ms = DateTime.to_unix(DateTime.utc_now(), :millisecond) sensors |> Enum.map(&build_live_poll_data_point(&1, oid_values, timestamp_ms)) |> Enum.reject(&is_nil/1) end defp build_live_poll_data_point(sensor, oid_values, timestamp_ms) do with value when not is_nil(value) <- Map.get(oid_values, sensor.sensor_oid), parsed_value when not is_nil(parsed_value) <- parse_sensor_value(value) do final_value = parsed_value / sensor.sensor_divisor %{ sensor_id: sensor.id, label: sensor.sensor_descr, value: Float.round(final_value, 1), timestamp: timestamp_ms } else _ -> nil end end defp parse_sensor_value(value) do case Towerops.Numeric.parse_float(value) do {:ok, f} -> f :error -> nil end end # Build SNMP client options defp build_snmp_client_opts(device) do snmp_config = Devices.get_snmp_config(device) [ ip: device.ip_address, community: snmp_config.community, version: snmp_config.version, port: device.snmp_port || 161, # Increased for live polling - devices may be slow to respond timeout: 3000 ] end # Poll sensors in parallel defp poll_sensors_for_live_mode(assigns, client_opts, timestamp_ms) do # Special handling for traffic graphs if assigns.sensor_type == "traffic" do poll_traffic_for_live_mode(assigns, client_opts, timestamp_ms) else poll_regular_sensors_for_live_mode(assigns, client_opts, timestamp_ms) end end # Poll regular sensors (non-traffic) in parallel defp poll_regular_sensors_for_live_mode(assigns, client_opts, timestamp_ms) do sensors = get_sensors_for_live_mode(assigns) sensors |> Task.async_stream(&poll_sensor_to_data_point(&1, client_opts, timestamp_ms), max_concurrency: 10, timeout: 4000, on_timeout: :kill_task ) |> Enum.reduce([], fn {:ok, nil}, acc -> acc {:ok, point}, acc -> [point | acc] {:exit, _reason}, acc -> acc end) end # Poll a single sensor and convert to data point defp poll_sensor_to_data_point(sensor, client_opts, timestamp_ms) do case poll_single_sensor(sensor, client_opts) do {:ok, value} -> %{ sensor_id: sensor.id, label: sensor.sensor_descr, value: Float.round(value, 1), timestamp: timestamp_ms } {:error, _reason} -> nil end end # Poll traffic stats and update socket with current stats for next poll # Returns {data_points, updated_socket} defp poll_traffic_with_state_update(socket, client_opts, timestamp_ms) do device = Snmp.get_device_with_associations(socket.assigns.device_id) if is_nil(device) || Enum.empty?(device.interfaces) do {[], socket} else # Poll all interface stats current_stats = poll_interface_stats_parallel(device.interfaces, client_opts) # If we have previous stats, calculate BPS data_points = if socket.assigns.previous_interface_stats do calculate_traffic_bps_from_stats( socket.assigns.previous_interface_stats, current_stats, timestamp_ms ) else # First poll - no previous stats, so no BPS to calculate yet [] end # Update socket with current stats for next poll updated_socket = assign(socket, :previous_interface_stats, current_stats) {data_points, updated_socket} end end # Poll traffic stats for live mode (legacy - used for non-state-updating path) # Requires two readings to calculate BPS, so uses previous_interface_stats from socket assigns defp poll_traffic_for_live_mode(assigns, client_opts, timestamp_ms) do device = Snmp.get_device_with_associations(assigns.device_id) if is_nil(device) || Enum.empty?(device.interfaces) do [] else # Poll all interface stats current_stats = poll_interface_stats_parallel(device.interfaces, client_opts) # If we have previous stats, calculate BPS if assigns.previous_interface_stats do calculate_traffic_bps_from_stats( assigns.previous_interface_stats, current_stats, timestamp_ms ) else # First poll - no previous stats, so no BPS to calculate yet [] end end end # Poll interface octets for all interfaces in parallel # Uses 64-bit counters (ifHCInOctets/ifHCOutOctets) for better accuracy on high-speed interfaces defp poll_interface_stats_parallel(interfaces, client_opts) do interfaces |> Task.async_stream(&poll_single_interface_octets(&1, client_opts), max_concurrency: 10, timeout: 4000, on_timeout: :kill_task ) |> Enum.reduce([], fn {:ok, nil}, acc -> acc {:ok, stat}, acc -> [stat | acc] {:exit, _}, acc -> acc end) end # Poll octets for a single interface (try 64-bit, fall back to 32-bit) defp poll_single_interface_octets(interface, client_opts) do # Counter64 (HC) can't be encoded in SNMPv1 — upgrade to v2c for HC queries hc_opts = case Keyword.get(client_opts, :version) do v when v in ["1", :v1] -> Keyword.put(client_opts, :version, "2c") _ -> client_opts end in_octets_oid = "1.3.6.1.2.1.31.1.1.1.6.#{interface.if_index}" out_octets_oid = "1.3.6.1.2.1.31.1.1.1.10.#{interface.if_index}" with {:ok, in_octets} <- Snmp.Client.get(hc_opts, in_octets_oid), {:ok, out_octets} <- Snmp.Client.get(hc_opts, out_octets_oid), true <- is_number(in_octets) and is_number(out_octets) do build_interface_stat(interface, in_octets, out_octets) else _ -> poll_single_interface_octets_32bit(interface, client_opts) end end # Fall back to 32-bit counters defp poll_single_interface_octets_32bit(interface, client_opts) do in_octets_oid = "1.3.6.1.2.1.2.2.1.10.#{interface.if_index}" out_octets_oid = "1.3.6.1.2.1.2.2.1.16.#{interface.if_index}" with {:ok, in_octets} <- Snmp.Client.get(client_opts, in_octets_oid), {:ok, out_octets} <- Snmp.Client.get(client_opts, out_octets_oid), true <- is_number(in_octets) and is_number(out_octets) do build_interface_stat(interface, in_octets, out_octets) else _ -> nil end end # Build interface stat map defp build_interface_stat(interface, in_octets, out_octets) do %{ interface_id: interface.id, interface_name: interface.if_name || interface.if_descr, if_in_octets: in_octets, if_out_octets: out_octets, polled_at: DateTime.utc_now() } end # Calculate BPS from two sets of interface stats defp calculate_traffic_bps_from_stats(previous_stats, current_stats, timestamp_ms) do # Build map of previous stats for quick lookup previous_map = Map.new(previous_stats, &{&1.interface_id, &1}) # Calculate BPS for each interface that has both previous and current stats bps_per_interface = Enum.flat_map(current_stats, fn current -> calculate_interface_bps_if_previous_exists(current, previous_map) end) # Aggregate total traffic across all interfaces total_in = Enum.reduce(bps_per_interface, 0.0, fn x, acc -> acc + x.in_bps end) total_out = Enum.reduce(bps_per_interface, 0.0, fn x, acc -> acc + x.out_bps end) # Return two data points: inbound and outbound [ %{ sensor_id: "traffic_in", label: "Inbound", value: Float.round(total_in, 2), timestamp: timestamp_ms }, %{ sensor_id: "traffic_out", label: "Outbound", value: Float.round(total_out, 2), timestamp: timestamp_ms } ] end # Calculate BPS for an interface if previous stat exists defp calculate_interface_bps_if_previous_exists(current, previous_map) do case Map.get(previous_map, current.interface_id) do nil -> [] previous -> time_diff = current.polled_at |> DateTime.diff(previous.polled_at, :second) |> max(1) in_bps = calculate_live_bps(current.if_in_octets, previous.if_in_octets, time_diff) out_bps = calculate_live_bps(current.if_out_octets, previous.if_out_octets, time_diff) [%{interface_id: current.interface_id, in_bps: in_bps, out_bps: out_bps}] end end defp calculate_live_bps(current_octets, previous_octets, time_diff) do if current_octets && previous_octets do Towerops.Capacity.calculate_bps(previous_octets, current_octets, time_diff) else 0.0 end end # Get sensors based on graph type defp get_sensors_for_live_mode(%{sensor_id: sensor_id}) when not is_nil(sensor_id) do case Snmp.get_sensor(sensor_id) do nil -> [] sensor -> [sensor] end end defp get_sensors_for_live_mode(%{sensor_type: sensor_type, device_id: device_id}) do sensor_types = get_sensor_types_for_chart(sensor_type) device = Snmp.get_device_with_associations(device_id) # Traffic graphs don't have sensors (return empty list) if is_nil(sensor_types) || is_nil(device) do [] else device.sensors |> Enum.filter(&(&1.sensor_type in sensor_types)) |> Enum.sort_by(& &1.sensor_index) end end # Poll single sensor via SNMP defp poll_single_sensor(sensor, client_opts) do case Snmp.Client.get(client_opts, sensor.sensor_oid) do {:ok, raw_value} when is_number(raw_value) -> value = raw_value / sensor.sensor_divisor {:ok, value} {:ok, _non_numeric} -> {:error, :non_numeric} {:error, reason} -> {:error, reason} end end # Update live data buffer with rolling window defp update_live_data_buffer(buffer, current_count, new_points) do updated_buffer = Enum.reduce(new_points, buffer, fn point, acc -> sensor_buffer = Map.get(acc, point.sensor_id, []) # Add new point at the front new_buffer = [ %{x: point.timestamp, y: point.value} | sensor_buffer ] # Trim to max size trimmed_buffer = if current_count >= @max_live_points do Enum.take(new_buffer, @max_live_points) else new_buffer end Map.put(acc, point.sensor_id, trimmed_buffer) end) new_count = min(current_count + 1, @max_live_points) {updated_buffer, new_count} end # Cleanup on LiveView termination @impl true def terminate(_reason, socket) do cancel_live_polling_timer(socket) :ok end defp safe_encode_chart_data(data) do case Jason.encode(data) do {:ok, encoded} -> encoded {:error, reason} -> Logger.error("Failed to encode chart data: #{inspect(reason)}") Jason.encode!(%{datasets: []}) end end end