refactor: upgrade to Elixir 1.20, extract GraphLive.Polling, async subscriber impact

- Bump elixir requirement from ~> 1.19 to ~> 1.20 for gradual typing
- Extract SNMP polling functions from graph_live/show.ex (1492→1118 lines)
  into new GraphLive.Polling module (363 lines) — 374 lines net reduction
- Defer subscriber impact API call via start_async/handle_async in
  device_live/show.ex, avoiding synchronous Gaiia API call in handle_params
- Add terminate/2 to graph_live/show.ex for live_poll timer cleanup
- Fix .tool-versions to match installed Erlang 27.3.4.2
- Relax AGENTS.md dialyzer policy: allow ignore file for deps/vendored code
This commit is contained in:
Graham McIntire 2026-07-22 11:01:00 -05:00
parent 24eaccd7a8
commit 3bf21c746f
6 changed files with 401 additions and 401 deletions

View file

@ -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

View file

@ -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

View file

@ -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,22 +408,15 @@ 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))
end
end
defp assign_subscriber_impact(socket) do
device = socket.assigns.device
impact =
|> start_async(:subscriber_impact, fn ->
try do
Towerops.Gaiia.get_device_impact(device.id)
rescue
_ -> %{subscriber_count: 0, mrr: nil, accounts: []}
end
assign(socket, :subscriber_impact, impact)
end)
end
end
defp can_view_financials?(socket) do

View file

@ -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

View file

@ -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

View file

@ -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(),