diff --git a/lib/towerops/snmp/profiles/vendors/routeros.ex b/lib/towerops/snmp/profiles/vendors/routeros.ex index 7d168393..7e9b6838 100644 --- a/lib/towerops/snmp/profiles/vendors/routeros.ex +++ b/lib/towerops/snmp/profiles/vendors/routeros.ex @@ -606,10 +606,11 @@ defmodule Towerops.Snmp.Profiles.Vendors.Routeros do end # Map mtxrGaugeUnit value to sensor type, unit string, and divisor - defp gauge_unit_to_sensor_type(@gauge_unit_celsius), do: {"temperature", "°C", 10} + # Based on LibreNMS routeros.yaml - temperature is in whole degrees, voltage/current/power in deci-units + defp gauge_unit_to_sensor_type(@gauge_unit_celsius), do: {"temperature", "°C", 1} defp gauge_unit_to_sensor_type(@gauge_unit_rpm), do: {"fanspeed", "RPM", 1} defp gauge_unit_to_sensor_type(@gauge_unit_dv), do: {"voltage", "V", 10} - defp gauge_unit_to_sensor_type(@gauge_unit_da), do: {"current", "A", 1000} + defp gauge_unit_to_sensor_type(@gauge_unit_da), do: {"current", "mA", 1} defp gauge_unit_to_sensor_type(@gauge_unit_dw), do: {"power", "W", 10} defp gauge_unit_to_sensor_type(@gauge_unit_status), do: {"state", "", 1} defp gauge_unit_to_sensor_type(_), do: {"unknown", "", 1} @@ -643,10 +644,10 @@ defmodule Towerops.Snmp.Profiles.Vendors.Routeros do end end - # Sensor type configurations for overrides + # Sensor type configurations for overrides (must match gauge_unit_to_sensor_type divisors) defp sensor_type_config("voltage"), do: {"voltage", "V", 10} - defp sensor_type_config("temperature"), do: {"temperature", "°C", 10} - defp sensor_type_config("current"), do: {"current", "A", 1000} + defp sensor_type_config("temperature"), do: {"temperature", "°C", 1} + defp sensor_type_config("current"), do: {"current", "mA", 1} defp sensor_type_config("power"), do: {"power", "W", 10} defp sensor_type_config("fanspeed"), do: {"fanspeed", "RPM", 1} diff --git a/lib/towerops_web/live/device_live/form.ex b/lib/towerops_web/live/device_live/form.ex index 1f7c5717..fe626056 100644 --- a/lib/towerops_web/live/device_live/form.ex +++ b/lib/towerops_web/live/device_live/form.ex @@ -516,17 +516,34 @@ defmodule ToweropsWeb.DeviceLive.Form do end # Check if a non-routable IP is being used with cloud poller + # Skip this check in dev mode or for specific users defp check_non_routable_ip_cloud_error(device_params, assigns) do - ip_address = device_params["ip_address"] + # Skip check in dev mode or for specific users + if skip_non_routable_check?(assigns) do + false + else + ip_address = device_params["ip_address"] - # Only check if we have a valid IP address - with true <- is_binary(ip_address) and String.trim(ip_address) != "", - trimmed_ip = String.trim(ip_address), - true <- non_routable_ip?(trimmed_ip), - true <- using_cloud_poller?(device_params, assigns) do + # Only check if we have a valid IP address + with true <- is_binary(ip_address) and String.trim(ip_address) != "", + trimmed_ip = String.trim(ip_address), + true <- non_routable_ip?(trimmed_ip), + true <- using_cloud_poller?(device_params, assigns) do + true + else + _ -> false + end + end + end + + defp skip_non_routable_check?(assigns) do + # Skip in dev mode + if Mix.env() == :dev do true else - _ -> false + # Skip for specific users + user = assigns[:current_scope] && assigns.current_scope.user + user && user.email == "graham@mcintire.me" end end diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index 25f3191b..4c9ebb83 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -179,9 +179,9 @@ defmodule ToweropsWeb.DeviceLive.Show do end) temperature_sensors = - Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["temperature", "cpu_temperature", "celsius"])) + Enum.filter(non_transceiver_sensors, &temperature_sensor?/1) - voltage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["voltage", "volts"])) + voltage_sensors = Enum.filter(non_transceiver_sensors, &voltage_sensor?/1) storage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["disk_usage"])) @@ -626,34 +626,57 @@ defmodule ToweropsWeb.DeviceLive.Show do end end + # Calculate BPS per interface first, then aggregate by timestamp + # This avoids issues with misaligned timestamps between interfaces defp aggregate_interface_traffic_stats(interfaces, since) do interfaces |> Enum.flat_map(fn interface -> - stats = - interface.id - |> Snmp.get_interface_stats(since: since, limit: 1000) - |> Enum.reverse() - - Enum.map(stats, &interface_stat_to_tuple/1) + interface.id + |> Snmp.get_interface_stats(since: since, limit: 1000) + |> Enum.reverse() + |> calculate_interface_bps() end) - |> Enum.group_by(&elem(&1, 0)) - |> Enum.map(&sum_traffic_stats_by_timestamp/1) - |> Enum.sort_by(&elem(&1, 0), DateTime) + |> Enum.group_by(& &1.timestamp_ms) + |> Enum.map(fn {ts, bps_list} -> + total_in = Enum.reduce(bps_list, 0.0, fn x, acc -> acc + x.in_bps end) + total_out = Enum.reduce(bps_list, 0.0, fn x, acc -> acc + x.out_bps end) + %{timestamp_ms: ts, in_bps: total_in, out_bps: total_out} + end) + |> Enum.sort_by(& &1.timestamp_ms) end - defp interface_stat_to_tuple(stat) do - {stat.checked_at, stat.if_in_octets, stat.if_out_octets} - end + # Calculate BPS for a single interface's stats + defp calculate_interface_bps(stats) do + stats + |> Enum.chunk_every(2, 1, :discard) + |> Enum.map(fn [s1, s2] -> + time_diff = s2.checked_at |> DateTime.diff(s1.checked_at, :second) |> max(1) - defp sum_traffic_stats_by_timestamp({timestamp, stats}) do - total_in = Enum.reduce(stats, 0, fn {_, in_octets, _}, acc -> acc + (in_octets || 0) end) - total_out = Enum.reduce(stats, 0, fn {_, _, out_octets}, acc -> acc + (out_octets || 0) end) - {timestamp, total_in, total_out} + in_bps = + if s2.if_in_octets && s1.if_in_octets do + max((s2.if_in_octets - s1.if_in_octets) * 8 / time_diff, 0) + else + 0.0 + end + + out_bps = + if s2.if_out_octets && s1.if_out_octets do + max((s2.if_out_octets - s1.if_out_octets) * 8 / time_diff, 0) + else + 0.0 + end + + # Round timestamp to nearest minute for aggregation (avoids microsecond misalignment) + ts_seconds = DateTime.to_unix(s2.checked_at) + rounded_ts_ms = div(ts_seconds, 60) * 60 * 1000 + + %{timestamp_ms: rounded_ts_ms, in_bps: in_bps, out_bps: out_bps} + end) end defp build_traffic_json_datasets(all_stats) do - in_data = calculate_bps_data(all_stats, :inbound) - out_data = calculate_bps_data(all_stats, :outbound) + in_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: Float.round(s.in_bps, 2)} end) + out_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: -Float.round(s.out_bps, 2)} end) datasets = [ %{label: "Outbound", data: out_data}, @@ -663,34 +686,6 @@ defmodule ToweropsWeb.DeviceLive.Show do Jason.encode!(%{datasets: datasets}) end - defp calculate_bps_data(all_stats, :inbound) do - all_stats - |> Enum.chunk_every(2, 1, :discard) - |> Enum.map(fn [{t1, in1, _out1}, {t2, in2, _out2}] -> - time_diff = t2 |> DateTime.diff(t1, :second) |> max(1) - bps = ((in2 - in1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2) - - %{ - x: DateTime.to_unix(t2, :millisecond), - y: bps - } - end) - end - - defp calculate_bps_data(all_stats, :outbound) do - all_stats - |> Enum.chunk_every(2, 1, :discard) - |> Enum.map(fn [{t1, _in1, out1}, {t2, _in2, out2}] -> - time_diff = t2 |> DateTime.diff(t1, :second) |> max(1) - bps = ((out2 - out1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2) - - %{ - x: DateTime.to_unix(t2, :millisecond), - y: -bps - } - end) - end - # Group interfaces by type for organized display defp group_interfaces_by_type(interfaces) do interfaces @@ -744,4 +739,33 @@ defmodule ToweropsWeb.DeviceLive.Show do defp group_ip_addresses_by_interface(ip_addresses) do Enum.group_by(ip_addresses, & &1.snmp_interface_id) end + + # Sensor type classification helpers + # These check multiple signals (type, unit, description) to correctly categorize sensors + # since some devices (especially Mikrotik) report incorrect unit types + defp temperature_sensor?(sensor) do + type_match = sensor.sensor_type in ["temperature", "cpu_temperature", "celsius"] + unit_match = sensor.sensor_unit in ["°C", "C", "celsius"] + + descr_match = + sensor.sensor_descr && + String.contains?(String.downcase(sensor.sensor_descr), ["temperature", "temp"]) && + !String.contains?(String.downcase(sensor.sensor_descr), "voltage") + + # Match by type OR unit, but exclude if description says "voltage" + (type_match || unit_match || descr_match) && !voltage_by_description?(sensor) + end + + defp voltage_sensor?(sensor) do + type_match = sensor.sensor_type in ["voltage", "volts"] + unit_match = sensor.sensor_unit in ["V", "mV", "volts"] + descr_match = voltage_by_description?(sensor) + + type_match || unit_match || descr_match + end + + defp voltage_by_description?(sensor) do + sensor.sensor_descr && + String.contains?(String.downcase(sensor.sensor_descr), "voltage") + end end diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index d2822441..55826d14 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -5,7 +5,7 @@ active_page="devices" timezone={@timezone} > -
+