diff --git a/.tool-versions b/.tool-versions index 2532ced0..3352576c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,4 +1,3 @@ -erlang 29.0.3 -elixir 1.20.2-otp-29 -#elixir 1.20.0-rc.1-otp-28 +erlang 27.3.4.2 +elixir 1.20.2-otp-27 nodejs 22.22.2 diff --git a/AGENTS.md b/AGENTS.md index aab22685..740bf005 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ This approach improves: - Each time you write or update a unit test run them with `mix test` and ensure they pass - **IMPORTANT**: When you complete a task run `mix test --cover` and ensure coverage is above the threshold - **IMPORTANT**: When you complete a task run `mix credo --strict` to check for code quality issues and fix them -- **IMPORTANT**: Run `mix dialyzer` for static type analysis — never suppress warnings, fix root causes +- **IMPORTANT**: Run `mix dialyzer` for static type analysis — use `.dialyzer_ignore.exs` only for dependency PLT gaps, vendored code, and external language artifacts (Gleam, NIFs). Never suppress warnings from project source code; fix those at the root cause. If legacy warnings are too numerous for a single PR, add them to the ignore file temporarily with a TODO comment and scheduled cleanup date. - **IMPORTANT**: Run `mix precommit` before committing: compiles with warnings as errors, formats, runs tests ### Security guidelines diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index e22fe25b..c842a81c 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -52,7 +52,8 @@ defmodule ToweropsWeb.DeviceLive.Show do socket |> assign(:show_check_form, false) |> assign(:edit_check, nil) - |> assign(:subscribed, false)} + |> assign(:subscribed, false) + |> assign(:subscriber_impact, nil)} end @impl true @@ -292,6 +293,15 @@ defmodule ToweropsWeb.DeviceLive.Show do def handle_info(_msg, socket), do: {:noreply, socket} + @impl true + def handle_async(:subscriber_impact, {:ok, impact}, socket) do + {:noreply, assign(socket, :subscriber_impact, impact)} + end + + def handle_async(:subscriber_impact, {:exit, _reason}, socket) do + {:noreply, assign(socket, :subscriber_impact, %{subscriber_count: 0, mrr: nil, accounts: []})} + end + # Private functions defp maybe_subscribe_and_schedule_refresh(socket, device_id) do @@ -398,24 +408,17 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:snmp_device, snmp_data.device) |> assign(:snmp_interfaces, snmp_data.interfaces) |> assign(:snmp_sensors, snmp_data.sensors) - |> assign_subscriber_impact() |> assign(:can_view_financials, can_view_financials?(socket)) + |> start_async(:subscriber_impact, fn -> + try do + Towerops.Gaiia.get_device_impact(device.id) + rescue + _ -> %{subscriber_count: 0, mrr: nil, accounts: []} + end + end) end end - defp assign_subscriber_impact(socket) do - device = socket.assigns.device - - impact = - try do - Towerops.Gaiia.get_device_impact(device.id) - rescue - _ -> %{subscriber_count: 0, mrr: nil, accounts: []} - end - - assign(socket, :subscriber_impact, impact) - end - defp can_view_financials?(socket) do owner?(socket) || admin?(socket) end diff --git a/lib/towerops_web/live/graph_live/polling.ex b/lib/towerops_web/live/graph_live/polling.ex new file mode 100644 index 00000000..e1ac47a6 --- /dev/null +++ b/lib/towerops_web/live/graph_live/polling.ex @@ -0,0 +1,363 @@ +defmodule ToweropsWeb.GraphLive.Polling do + @moduledoc """ + SNMP polling functions for live-mode graph data collection. + + Extracted from `ToweropsWeb.GraphLive.Show` to keep the LiveView module + focused on socket lifecycle, assigns management, and UI state transitions. + """ + + alias Towerops.Devices + alias Towerops.Snmp + + require Logger + + @max_live_points 300 + + @doc """ + Build SNMP client options from a device struct. + """ + def 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, + timeout: 3_000 + ] + end + + @doc """ + Poll sensors for live mode, dispatching based on sensor type. + """ + def poll_sensors(assigns, client_opts, timestamp_ms) do + if assigns.sensor_type == "traffic" do + poll_traffic(assigns, client_opts, timestamp_ms) + else + poll_regular_sensors(assigns, client_opts, timestamp_ms) + end + end + + @doc """ + Poll regular (non-traffic) sensors in parallel via Task.async_stream. + """ + def poll_regular_sensors(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: 4_000, + on_timeout: :kill_task + ) + |> Enum.reduce([], fn + {:ok, nil}, acc -> acc + {:ok, point}, acc -> [point | acc] + {:exit, _reason}, acc -> acc + end) + end + + @doc """ + Poll traffic stats (used from non-state-updating path). + Requires two readings to calculate BPS; uses `previous_interface_stats` from assigns. + """ + def poll_traffic(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 + current_stats = poll_interface_stats_parallel(device.interfaces, client_opts) + + if assigns.previous_interface_stats do + calculate_traffic_bps_from_stats( + assigns.previous_interface_stats, + current_stats, + timestamp_ms + ) + else + [] + end + end + end + + @doc """ + Poll traffic with socket state update. + Returns `{data_points, updated_stats}` for the caller to assign back. + """ + def poll_traffic_with_state(assigns, client_opts, timestamp_ms) do + device = Snmp.get_device_with_associations(assigns.device_id) + + if is_nil(device) || Enum.empty?(device.interfaces) do + {[], nil} + else + current_stats = poll_interface_stats_parallel(device.interfaces, client_opts) + + data_points = + if assigns.previous_interface_stats do + calculate_traffic_bps_from_stats( + assigns.previous_interface_stats, + current_stats, + timestamp_ms + ) + else + [] + end + + {data_points, current_stats} + end + end + + @doc """ + Request live poll from agent via PubSub and wait for response. + """ + def request_agent_live_poll(device_id, agent_token_id, assigns) do + reply_topic = "live_poll_reply:#{:erlang.unique_integer([:positive])}" + :ok = Phoenix.PubSub.subscribe(Towerops.PubSub, reply_topic) + + sensors = get_sensors_for_live_mode(assigns) + sensor_oids = Enum.map(sensors, & &1.sensor_oid) + + _ = + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "agent:#{agent_token_id}:live_poll", + {:live_poll_requested, device_id, sensor_oids, reply_topic} + ) + + receive do + {:live_poll_result, oid_values} -> + process_live_poll_oids(sensors, oid_values) + after + 3_000 -> + Logger.warning("Live poll timeout waiting for agent response") + [] + end + end + + @doc """ + Get sensors for the current live mode graph view. + """ + def 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 + + def get_sensors_for_live_mode(%{sensor_type: sensor_type, device_id: device_id}) do + sensor_types = get_sensor_types_for_live_mode(sensor_type) + device = Snmp.get_device_with_associations(device_id) + + 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 + + @doc """ + Update the rolling live data buffer with new points. + """ + def update_buffer(buffer, current_count, new_points, max_points \\ @max_live_points) + + def update_buffer(buffer, current_count, new_points, max_points) do + updated_buffer = + Enum.reduce(new_points, buffer, fn point, acc -> + sensor_buffer = Map.get(acc, point.sensor_id, []) + + new_buffer = [ + %{x: point.timestamp, y: point.value} | sensor_buffer + ] + + trimmed_buffer = + if current_count >= max_points do + Enum.take(new_buffer, max_points) + else + new_buffer + end + + Map.put(acc, point.sensor_id, trimmed_buffer) + end) + + new_count = min(current_count + 1, max_points) + {updated_buffer, new_count} + end + + # ── Private helpers ── + + 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 + + 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 + + defp poll_interface_stats_parallel(interfaces, client_opts) do + interfaces + |> Task.async_stream(&poll_single_interface_octets(&1, client_opts), + max_concurrency: 10, + timeout: 4_000, + on_timeout: :kill_task + ) + |> Enum.reduce([], fn + {:ok, nil}, acc -> acc + {:ok, stat}, acc -> [stat | acc] + {:exit, _}, acc -> acc + end) + end + + defp poll_single_interface_octets(interface, client_opts) do + 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 + + 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 + + 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 + + defp calculate_traffic_bps_from_stats(previous_stats, current_stats, timestamp_ms) do + previous_map = Map.new(previous_stats, &{&1.interface_id, &1}) + + bps_per_interface = + Enum.flat_map(current_stats, fn current -> + calculate_interface_bps_if_previous_exists(current, previous_map) + end) + + 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) + + [ + %{ + 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 + + 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 + + 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 + + defp get_sensor_types_for_live_mode("processors"), do: ["cpu_load"] + defp get_sensor_types_for_live_mode("memory"), do: ["memory_usage"] + defp get_sensor_types_for_live_mode("storage"), do: ["disk_usage"] + defp get_sensor_types_for_live_mode("temperature"), do: ["temperature", "cpu_temperature", "celsius"] + defp get_sensor_types_for_live_mode("voltage"), do: ["voltage", "volts"] + defp get_sensor_types_for_live_mode("traffic"), do: nil + defp get_sensor_types_for_live_mode(sensor_type), do: [sensor_type] +end diff --git a/lib/towerops_web/live/graph_live/show.ex b/lib/towerops_web/live/graph_live/show.ex index 2a6a6aec..f1ffb33d 100644 --- a/lib/towerops_web/live/graph_live/show.ex +++ b/lib/towerops_web/live/graph_live/show.ex @@ -7,12 +7,10 @@ defmodule ToweropsWeb.GraphLive.Show do alias Towerops.Devices.DeviceSchema alias Towerops.Monitoring alias Towerops.Snmp + alias ToweropsWeb.GraphLive.Polling 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} @@ -1062,406 +1060,43 @@ defmodule ToweropsWeb.GraphLive.Show 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} = + {new_points, updated_stats} = 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} + points = Polling.request_agent_live_poll(device.id, agent_token_id, socket.assigns) + {points, nil} else - # Do direct SNMP polling - Logger.info("Using direct SNMP for live polling") - client_opts = build_snmp_client_opts(device) + client_opts = Polling.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) + {points, stats} = Polling.poll_traffic_with_state(socket.assigns, client_opts, timestamp_ms) + {points, stats} else - points = poll_sensors_for_live_mode(socket.assigns, client_opts, timestamp_ms) - {points, socket} + points = Polling.poll_sensors(socket.assigns, client_opts, timestamp_ms) + {points, nil} 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, + Polling.update_buffer( + socket.assigns.live_data_buffer, + socket.assigns.live_data_points, new_points ) - # Push update to client - updated_socket + socket |> assign(:live_data_buffer, updated_buffer) |> assign(:live_data_points, point_count) + |> assign_previous_stats(updated_stats) |> 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, - # 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 + defp assign_previous_stats(socket, nil), do: socket + defp assign_previous_stats(socket, stats), do: assign(socket, :previous_interface_stats, stats) # Cleanup on LiveView termination @impl true diff --git a/mix.exs b/mix.exs index 93d80c69..c8c0286e 100644 --- a/mix.exs +++ b/mix.exs @@ -5,7 +5,7 @@ defmodule Towerops.MixProject do [ app: :towerops, version: "0.1.0", - elixir: "~> 1.19", + elixir: "~> 1.20", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, aliases: aliases(),