towerops/lib/towerops_web/live/graph_live/show.ex
Graham McIntire 3bf21c746f 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
2026-07-22 11:01:00 -05:00

1118 lines
35 KiB
Elixir

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
alias ToweropsWeb.GraphLive.Polling
require Logger
@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_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)
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
@doc false
def get_time_range_for_graph("1h"), do: {DateTime.add(DateTime.utc_now(), -1, :hour), DateTime.utc_now()}
def get_time_range_for_graph("6h"), do: {DateTime.add(DateTime.utc_now(), -6, :hour), DateTime.utc_now()}
def get_time_range_for_graph("12h"), do: {DateTime.add(DateTime.utc_now(), -12, :hour), DateTime.utc_now()}
def get_time_range_for_graph("24h"), do: {DateTime.add(DateTime.utc_now(), -24, :hour), DateTime.utc_now()}
def get_time_range_for_graph("7d"), do: {DateTime.add(DateTime.utc_now(), -7, :day), DateTime.utc_now()}
def get_time_range_for_graph("30d"), do: {DateTime.add(DateTime.utc_now(), -30, :day), DateTime.utc_now()}
def get_time_range_for_graph(_), do: {DateTime.add(DateTime.utc_now(), -24, :hour), DateTime.utc_now()}
@doc false
def get_check_unit_and_scale(%{check_type: "snmp_sensor", config: config}) do
unit = config["sensor_unit"] || ""
{unit, true}
end
def get_check_unit_and_scale(%{check_type: "snmp_processor"}), do: {"%", false}
def get_check_unit_and_scale(%{check_type: "snmp_storage"}), do: {"%", false}
def get_check_unit_and_scale(%{check_type: "http"}), do: {"ms", true}
def get_check_unit_and_scale(%{check_type: "tcp"}), do: {"ms", true}
def get_check_unit_and_scale(%{check_type: "dns"}), do: {"ms", true}
def 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")
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")
|> 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
@doc false
def 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
@doc false
def get_sensor_types_for_chart("processors"), do: ["cpu_load"]
def get_sensor_types_for_chart("memory"), do: ["memory_usage"]
def get_sensor_types_for_chart("storage"), do: ["disk_usage"]
def get_sensor_types_for_chart("temperature"), do: ["temperature", "cpu_temperature", "celsius"]
def get_sensor_types_for_chart("voltage"), do: ["voltage", "volts"]
def get_sensor_types_for_chart("traffic"), do: nil
# For unknown sensor types, use the type directly (supports count, pppoe_sessions, etc.)
def get_sensor_types_for_chart(sensor_type), do: [sensor_type]
@doc false
def get_chart_config("latency"), do: {"Ping Latency", "ms", true}
def get_chart_config("processors"), do: {"Processor Usage", "%", false}
def get_chart_config("memory"), do: {"Memory Usage", "%", false}
def get_chart_config("storage"), do: {"Storage Usage", "%", false}
def get_chart_config("storage_volume"), do: {"Storage Usage", "%", false}
def get_chart_config("temperature"), do: {"Temperature", "°C", true}
def get_chart_config("voltage"), do: {"Voltage", "V", true}
def get_chart_config("traffic"), do: {"Overall Traffic", "bps", true}
def get_chart_config("interface_errors"), do: {"Interface Errors & Discards", "", true}
def get_chart_config("count"), do: {"Count", "", true}
def get_chart_config("pppoe_sessions"), do: {"PPPoE Sessions", "", true}
def get_chart_config("connections"), do: {"Connections", "", true}
def get_chart_config("wireless"), do: {"Wireless Metrics", "", true}
# Humanize unknown sensor types for title
def get_chart_config(sensor_type), do: {humanize_sensor_type(sensor_type), "", true}
@doc false
def 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
@doc false
def calculate_chart_stats(nil), do: {nil, nil}
@doc false
def 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
@doc false
def 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
def format_value(value, "") do
"#{round(value)}"
end
def format_value(value, unit) when is_float(value) do
"#{Float.round(value, 1)} #{unit}"
end
def 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)
agent_token_id = Agents.get_effective_agent_token(device)
{new_points, updated_stats} =
if agent_token_id do
points = Polling.request_agent_live_poll(device.id, agent_token_id, socket.assigns)
{points, nil}
else
client_opts = Polling.build_snmp_client_opts(device)
if socket.assigns.sensor_type == "traffic" do
{points, stats} = Polling.poll_traffic_with_state(socket.assigns, client_opts, timestamp_ms)
{points, stats}
else
points = Polling.poll_sensors(socket.assigns, client_opts, timestamp_ms)
{points, nil}
end
end
{updated_buffer, point_count} =
Polling.update_buffer(
socket.assigns.live_data_buffer,
socket.assigns.live_data_points,
new_points
)
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
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
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