1376 lines
43 KiB
Elixir
1376 lines
43 KiB
Elixir
defmodule ToweropsWeb.GraphLive.Show do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Agents
|
|
alias Towerops.Devices
|
|
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 = Jason.encode!(%{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)
|
|
}
|
|
|
|
Jason.encode!(%{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 = Devices.get_device!(device_id)
|
|
{chart_data, title_suffix} = load_chart_data_for_type(socket.assigns, range)
|
|
|
|
{title, unit, auto_scale} = get_chart_config(sensor_type)
|
|
chart_title = if title_suffix, do: "#{title} - #{title_suffix}", else: title
|
|
show_zero_line = sensor_type == "traffic"
|
|
|
|
# Calculate max/min values from chart data
|
|
{max_value, min_value} = calculate_chart_stats(chart_data)
|
|
|
|
socket
|
|
|> assign(:device, device)
|
|
|> assign(:page_title, "#{device.name} - #{chart_title}")
|
|
|> assign(:chart_title, chart_title)
|
|
|> assign(:chart_data, chart_data)
|
|
|> assign(:unit, unit)
|
|
|> assign(:auto_scale, auto_scale)
|
|
|> assign(: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 = Devices.get_device!(device_id)
|
|
{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, Jason.encode!(%{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] == "traffic" -> 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: "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("count"), do: {"Count", "", true}
|
|
defp get_chart_config("pppoe_sessions"), do: {"PPPoE Sessions", "", true}
|
|
defp get_chart_config("connections"), do: {"Connections", "", true}
|
|
defp get_chart_config("wireless"), do: {"Wireless Metrics", "", true}
|
|
# Humanize unknown sensor types for title
|
|
defp get_chart_config(sensor_type), do: {humanize_sensor_type(sensor_type), "", true}
|
|
|
|
defp humanize_sensor_type(sensor_type) do
|
|
sensor_type
|
|
|> String.replace("_", " ")
|
|
|> String.split()
|
|
|> Enum.map_join(" ", &String.capitalize/1)
|
|
end
|
|
|
|
defp load_sensor_chart_data(device_id, sensor_types, range) do
|
|
case Snmp.get_device_with_associations(device_id) do
|
|
nil ->
|
|
nil
|
|
|
|
device ->
|
|
build_sensor_chart_json(device, sensor_types, range)
|
|
end
|
|
end
|
|
|
|
defp load_single_sensor_chart_data(sensor_id, range) do
|
|
case Snmp.get_sensor(sensor_id) do
|
|
nil ->
|
|
nil
|
|
|
|
sensor ->
|
|
since = get_datetime_from_range(range)
|
|
limit = get_limit_for_range(range)
|
|
dataset = sensor_to_dataset(sensor, since, limit)
|
|
|
|
# Return nil if no data points (empty readings)
|
|
if Enum.empty?(dataset.data) do
|
|
nil
|
|
else
|
|
Jason.encode!(%{datasets: [dataset]})
|
|
end
|
|
end
|
|
end
|
|
|
|
defp get_sensor_name(sensor_id) do
|
|
case Snmp.get_sensor(sensor_id) do
|
|
nil -> nil
|
|
sensor -> sensor.sensor_descr
|
|
end
|
|
end
|
|
|
|
defp load_storage_chart_data(storage_id, range) do
|
|
case Snmp.get_storage(storage_id) do
|
|
nil ->
|
|
nil
|
|
|
|
storage ->
|
|
since = get_datetime_from_range(range)
|
|
limit = get_limit_for_range(range)
|
|
dataset = storage_to_dataset(storage, since, limit)
|
|
Jason.encode!(%{datasets: [dataset]})
|
|
end
|
|
end
|
|
|
|
defp get_storage_name(storage_id) do
|
|
case Snmp.get_storage(storage_id) do
|
|
nil -> nil
|
|
storage -> storage.description || "Storage #{storage.storage_index}"
|
|
end
|
|
end
|
|
|
|
defp storage_to_dataset(storage, since, limit) do
|
|
readings =
|
|
storage.id
|
|
|> Snmp.get_storage_readings(since: since, limit: limit)
|
|
|> Enum.reverse()
|
|
|
|
%{
|
|
label: storage.description || "Storage #{storage.storage_index}",
|
|
data: Enum.map(readings, &storage_reading_to_chart_point/1)
|
|
}
|
|
end
|
|
|
|
defp storage_reading_to_chart_point(reading) do
|
|
%{
|
|
x: DateTime.to_unix(reading.checked_at, :millisecond),
|
|
y: if(reading.usage_percent, do: Float.round(reading.usage_percent, 1))
|
|
}
|
|
end
|
|
|
|
defp build_sensor_chart_json(device, sensor_types, range) do
|
|
sensors =
|
|
device.sensors
|
|
|> Enum.filter(&(&1.sensor_type in sensor_types))
|
|
|> Enum.sort_by(& &1.sensor_index)
|
|
|
|
if Enum.empty?(sensors) do
|
|
nil
|
|
else
|
|
since = get_datetime_from_range(range)
|
|
limit = get_limit_for_range(range)
|
|
datasets = Enum.map(sensors, &sensor_to_dataset(&1, since, limit))
|
|
Jason.encode!(%{datasets: datasets})
|
|
end
|
|
end
|
|
|
|
defp sensor_to_dataset(sensor, since, limit) do
|
|
readings =
|
|
sensor.id
|
|
|> Snmp.get_sensor_readings(since: since, limit: limit)
|
|
|> Enum.reverse()
|
|
|
|
%{
|
|
label: sensor.sensor_descr,
|
|
data: Enum.map(readings, &reading_to_chart_point/1)
|
|
}
|
|
end
|
|
|
|
defp reading_to_chart_point(reading) do
|
|
%{
|
|
x: DateTime.to_unix(reading.checked_at, :millisecond),
|
|
y: if(reading.value, do: Float.round(reading.value, 1))
|
|
}
|
|
end
|
|
|
|
defp get_datetime_from_range("1h"), do: DateTime.add(DateTime.utc_now(), -1, :hour)
|
|
defp get_datetime_from_range("6h"), do: DateTime.add(DateTime.utc_now(), -6, :hour)
|
|
defp get_datetime_from_range("12h"), do: DateTime.add(DateTime.utc_now(), -12, :hour)
|
|
defp get_datetime_from_range("24h"), do: DateTime.add(DateTime.utc_now(), -24, :hour)
|
|
defp get_datetime_from_range("7d"), do: DateTime.add(DateTime.utc_now(), -7, :day)
|
|
defp get_datetime_from_range("30d"), do: DateTime.add(DateTime.utc_now(), -30, :day)
|
|
defp get_datetime_from_range(_), do: DateTime.add(DateTime.utc_now(), -24, :hour)
|
|
|
|
defp get_limit_for_range("1h"), do: 360
|
|
defp get_limit_for_range("6h"), do: 720
|
|
defp get_limit_for_range("12h"), do: 1440
|
|
defp get_limit_for_range("24h"), do: 2880
|
|
defp get_limit_for_range("7d"), do: 10_080
|
|
defp get_limit_for_range("30d"), do: 43_200
|
|
defp get_limit_for_range(_), do: 2880
|
|
|
|
defp load_traffic_chart_data(device_id, range) do
|
|
case Snmp.get_device_with_associations(device_id) do
|
|
nil -> nil
|
|
device -> build_traffic_chart_json(device, range)
|
|
end
|
|
end
|
|
|
|
defp load_interface_traffic_chart_data(interface_id, range) do
|
|
case Snmp.get_interface(interface_id) do
|
|
nil -> nil
|
|
interface -> build_interface_traffic_chart_json(interface, range)
|
|
end
|
|
end
|
|
|
|
defp get_interface_name(interface_id) do
|
|
case Snmp.get_interface(interface_id) do
|
|
nil -> "Unknown Interface"
|
|
interface -> interface.if_name || interface.if_descr || "Interface #{interface.if_index}"
|
|
end
|
|
end
|
|
|
|
defp load_latency_chart_data(device_id, range) do
|
|
since = get_datetime_from_range(range)
|
|
limit = get_limit_for_range(range)
|
|
|
|
checks =
|
|
device_id
|
|
|> Monitoring.get_latency_data(since: since, limit: limit)
|
|
|> Enum.reverse()
|
|
|
|
if Enum.empty?(checks) do
|
|
nil
|
|
else
|
|
dataset = %{
|
|
label: "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)
|
|
else
|
|
datasets
|
|
end
|
|
|
|
if Enum.empty?(datasets), do: nil, else: Jason.encode!(%{datasets: datasets})
|
|
end
|
|
|
|
defp build_per_interface_datasets(interfaces, since, limit) do
|
|
Enum.flat_map(interfaces, fn interface ->
|
|
stats =
|
|
interface.id
|
|
|> Snmp.get_interface_stats(since: since, limit: limit)
|
|
|> Enum.reverse()
|
|
|
|
if Enum.empty?(stats) do
|
|
[]
|
|
else
|
|
interface_name = interface.if_name || interface.if_descr
|
|
in_data = calculate_interface_traffic_data(stats, :inbound)
|
|
out_data = calculate_interface_traffic_data(stats, :outbound)
|
|
|
|
[
|
|
%{label: "#{interface_name} Out", data: out_data},
|
|
%{label: "#{interface_name} In", data: in_data}
|
|
]
|
|
end
|
|
end)
|
|
end
|
|
|
|
# Add capacity reference lines for backhaul devices on full-page graph
|
|
# Calculates capacity per timestamp based on which interfaces have data at that point
|
|
defp add_capacity_to_full_graph(datasets, interfaces, since, limit) do
|
|
# Build a map of interface_id -> capacity for quick lookup
|
|
# Includes all interfaces to match the total device traffic
|
|
interface_capacity_map =
|
|
Map.new(interfaces, fn interface ->
|
|
capacity = interface.configured_capacity_bps || interface.if_speed || 0
|
|
{interface.id, capacity}
|
|
end)
|
|
|
|
# Track which interfaces have data at each timestamp
|
|
interface_activity_by_timestamp =
|
|
interfaces
|
|
|> Enum.flat_map(fn interface ->
|
|
interface.id
|
|
|> Snmp.get_interface_stats(since: since, limit: limit)
|
|
|> 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)
|
|
|
|
# Get all unique timestamps from the datasets
|
|
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()
|
|
|
|
# Calculate capacity at each timestamp based on active interfaces
|
|
capacity_data =
|
|
Enum.map(all_timestamps, fn ts ->
|
|
# Get interfaces that had data at this timestamp (within 1 minute tolerance)
|
|
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()
|
|
|
|
# Sum capacity of active interfaces
|
|
total_capacity =
|
|
Enum.reduce(active_interfaces, 0, fn iface_id, acc ->
|
|
capacity = Map.get(interface_capacity_map, iface_id, 0)
|
|
acc + capacity
|
|
end)
|
|
|
|
{ts, total_capacity}
|
|
end)
|
|
|
|
# Only add capacity datasets if we have at least some capacity data
|
|
max_capacity = capacity_data |> Enum.map(fn {_, cap} -> cap end) |> Enum.max(fn -> 0 end)
|
|
|
|
if max_capacity > 0 do
|
|
capacity_in_data =
|
|
Enum.map(capacity_data, fn {ts, cap} ->
|
|
%{x: ts, y: Float.round(cap * 1.0, 2)}
|
|
end)
|
|
|
|
capacity_out_data =
|
|
Enum.map(capacity_data, fn {ts, cap} ->
|
|
%{x: ts, y: -Float.round(cap * 1.0, 2)}
|
|
end)
|
|
|
|
# Add capacity datasets with dashed styling
|
|
datasets ++
|
|
[
|
|
%{
|
|
label: "Capacity (In)",
|
|
data: capacity_in_data,
|
|
borderDash: [5, 5],
|
|
borderWidth: 2,
|
|
pointRadius: 0,
|
|
fill: false
|
|
},
|
|
%{
|
|
label: "Capacity (Out)",
|
|
data: capacity_out_data,
|
|
borderDash: [5, 5],
|
|
borderWidth: 2,
|
|
pointRadius: 0,
|
|
fill: false
|
|
}
|
|
]
|
|
else
|
|
datasets
|
|
end
|
|
end
|
|
|
|
defp build_interface_traffic_chart_json(interface, range) do
|
|
since = get_datetime_from_range(range)
|
|
limit = get_limit_for_range(range)
|
|
|
|
stats =
|
|
interface.id
|
|
|> Snmp.get_interface_stats(since: since, limit: limit)
|
|
|> Enum.reverse()
|
|
|
|
if Enum.empty?(stats) do
|
|
nil
|
|
else
|
|
in_data = calculate_interface_traffic_data(stats, :inbound)
|
|
out_data = calculate_interface_traffic_data(stats, :outbound)
|
|
|
|
datasets = [
|
|
%{label: "Outbound", data: out_data},
|
|
%{label: "Inbound", data: in_data}
|
|
]
|
|
|
|
Jason.encode!(%{datasets: datasets})
|
|
end
|
|
end
|
|
|
|
defp calculate_interface_traffic_data(stats, :inbound) do
|
|
stats
|
|
|> Enum.chunk_every(2, 1, :discard)
|
|
|> Enum.map(fn [stat1, stat2] ->
|
|
# Skip if octets are nil
|
|
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 =
|
|
((stat2.if_in_octets - stat1.if_in_octets) * 8 / time_diff)
|
|
|> max(0)
|
|
|> :erlang.float()
|
|
|> Float.round(2)
|
|
|
|
%{
|
|
x: DateTime.to_unix(stat2.checked_at, :millisecond),
|
|
y: -bps
|
|
}
|
|
end
|
|
end)
|
|
|> 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] ->
|
|
# Skip if octets are nil
|
|
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 =
|
|
((stat2.if_out_octets - stat1.if_out_octets) * 8 / time_diff)
|
|
|> max(0)
|
|
|> :erlang.float()
|
|
|> Float.round(2)
|
|
|
|
%{
|
|
x: DateTime.to_unix(stat2.checked_at, :millisecond),
|
|
y: bps
|
|
}
|
|
end
|
|
end)
|
|
|> 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
|
|
{:some, f} -> f
|
|
:none -> 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
|
|
# Try 64-bit counters first
|
|
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(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
|
|
_ -> 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_bps(current.if_in_octets, previous.if_in_octets, time_diff)
|
|
out_bps = calculate_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
|
|
|
|
# Calculate BPS from octets difference
|
|
defp calculate_bps(current_octets, previous_octets, time_diff) do
|
|
if current_octets && previous_octets do
|
|
max((current_octets - previous_octets) * 8 / time_diff, 0)
|
|
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
|
|
end
|