Added back navigation links at the top of all edit/new pages to improve UX and make it easier to navigate back to previous pages. Changes: - Added back link to equipment edit/new pages - Edit: goes back to equipment detail page - New: goes back to equipment list - Added back link to site edit/new pages - Edit: goes back to site detail page - New: goes back to sites list - Links styled with arrow icon and subtle hover effect - Added error handling to Counter64 decoder with try/rescue The back links appear above the page header and provide clear navigation context for users.
326 lines
9.5 KiB
Elixir
326 lines
9.5 KiB
Elixir
defmodule Towerops.Snmp.PollerWorker do
|
|
@moduledoc """
|
|
GenServer that regularly polls SNMP sensors and interfaces for a device.
|
|
|
|
This worker continuously collects time-series data for:
|
|
- Sensor readings (temperature, voltage, CPU, memory, etc.)
|
|
- Interface statistics (bandwidth, errors, discards, etc.)
|
|
|
|
Runs independently of the connectivity monitoring in EquipmentMonitor.
|
|
"""
|
|
use GenServer
|
|
|
|
alias Towerops.Equipment
|
|
alias Towerops.Snmp
|
|
alias Towerops.Snmp.Client
|
|
alias Towerops.Snmp.PollerRegistry
|
|
|
|
require Logger
|
|
|
|
@default_poll_interval 60
|
|
|
|
# Client API
|
|
|
|
@doc """
|
|
Starts a poller for the given equipment ID.
|
|
"""
|
|
def start_link(equipment_id) do
|
|
GenServer.start_link(__MODULE__, equipment_id, name: via_tuple(equipment_id))
|
|
end
|
|
|
|
@doc """
|
|
Triggers an immediate poll for the equipment.
|
|
"""
|
|
def trigger_poll(equipment_id) do
|
|
case Registry.lookup(PollerRegistry, equipment_id) do
|
|
[{_pid, _}] ->
|
|
GenServer.cast(via_tuple(equipment_id), :poll_now)
|
|
|
|
[] ->
|
|
# Process doesn't exist, perform poll directly in background
|
|
Task.start(fn -> perform_poll(equipment_id) end)
|
|
end
|
|
end
|
|
|
|
# Server Callbacks
|
|
|
|
@impl true
|
|
def init(equipment_id) do
|
|
equipment = Equipment.get_equipment!(equipment_id)
|
|
|
|
if equipment.snmp_enabled do
|
|
# Get the device to check if it has sensors/interfaces
|
|
device = Snmp.get_device_with_associations(equipment_id)
|
|
|
|
if device && (length(device.sensors) > 0 || length(device.interfaces) > 0) do
|
|
# Perform immediate poll when starting
|
|
send(self(), :poll_data)
|
|
end
|
|
end
|
|
|
|
{:ok, %{equipment_id: equipment_id}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:poll_data, state) do
|
|
_ = perform_poll(state.equipment_id)
|
|
{:noreply, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_cast(:poll_now, state) do
|
|
_ = perform_poll(state.equipment_id)
|
|
{:noreply, state}
|
|
end
|
|
|
|
# Private Functions
|
|
|
|
defp perform_poll(equipment_id) do
|
|
equipment = Equipment.get_equipment!(equipment_id)
|
|
|
|
if equipment.snmp_enabled do
|
|
device = Snmp.get_device_with_associations(equipment_id)
|
|
|
|
if device do
|
|
# Check if recently polled by another pod (distributed coordination)
|
|
poll_interval = get_poll_interval(equipment)
|
|
grace_period = 5
|
|
|
|
if !should_skip_poll?(equipment, poll_interval, grace_period) do
|
|
# Update poll timestamp before polling (optimistic locking)
|
|
Equipment.update_snmp_poll_time(equipment)
|
|
|
|
client_opts = build_client_opts(equipment)
|
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
|
|
|
# Poll all sensors
|
|
try do
|
|
poll_sensors(device.sensors, client_opts, now)
|
|
Logger.debug("Polled #{length(device.sensors)} sensors for #{equipment.name}")
|
|
rescue
|
|
error ->
|
|
Logger.error(
|
|
"Error polling sensors for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}"
|
|
)
|
|
end
|
|
|
|
# Poll all interfaces
|
|
try do
|
|
poll_interfaces(device.interfaces, client_opts, now)
|
|
Logger.debug("Polled #{length(device.interfaces)} interfaces for #{equipment.name}")
|
|
rescue
|
|
error ->
|
|
Logger.error(
|
|
"Error polling interfaces for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}"
|
|
)
|
|
end
|
|
else
|
|
Logger.debug(
|
|
"Skipping poll for #{equipment.name} - recently polled by another process"
|
|
)
|
|
end
|
|
|
|
# Schedule next poll regardless of whether we polled or skipped
|
|
schedule_next_poll(poll_interval)
|
|
end
|
|
end
|
|
end
|
|
|
|
defp poll_sensors(sensors, client_opts, timestamp) do
|
|
Enum.each(sensors, fn sensor ->
|
|
# Check if sensor requires special calculation (e.g., storage percentage)
|
|
result =
|
|
if sensor.metadata["calculation"] == "percentage" do
|
|
poll_percentage_sensor(sensor, client_opts)
|
|
else
|
|
poll_simple_sensor(sensor, client_opts)
|
|
end
|
|
|
|
case result do
|
|
{:ok, value} ->
|
|
Snmp.create_sensor_reading(%{
|
|
sensor_id: sensor.id,
|
|
value: value,
|
|
status: "ok",
|
|
checked_at: timestamp
|
|
})
|
|
|
|
{:error, :non_numeric} ->
|
|
Logger.debug("Non-numeric SNMP value for sensor #{sensor.sensor_descr}")
|
|
|
|
Snmp.create_sensor_reading(%{
|
|
sensor_id: sensor.id,
|
|
value: nil,
|
|
status: "unknown",
|
|
checked_at: timestamp
|
|
})
|
|
|
|
{:error, reason} ->
|
|
Logger.warning("Failed to poll sensor #{sensor.sensor_descr}: #{inspect(reason)}")
|
|
|
|
Snmp.create_sensor_reading(%{
|
|
sensor_id: sensor.id,
|
|
value: nil,
|
|
status: "error",
|
|
checked_at: timestamp
|
|
})
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp poll_simple_sensor(sensor, client_opts) do
|
|
case Client.get(client_opts, sensor.sensor_oid) do
|
|
{:ok, raw_value} ->
|
|
decoded_value = decode_snmp_value(raw_value)
|
|
|
|
if is_number(decoded_value) do
|
|
# Calculate actual value using divisor
|
|
value = decoded_value / sensor.sensor_divisor
|
|
{:ok, value}
|
|
else
|
|
{:error, :non_numeric}
|
|
end
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
defp poll_percentage_sensor(sensor, client_opts) do
|
|
# For percentage sensors (like storage), fetch both used and size
|
|
size_oid = sensor.metadata["size_oid"]
|
|
used_oid = sensor.sensor_oid
|
|
|
|
with {:ok, used_raw} <- Client.get(client_opts, used_oid),
|
|
{:ok, size_raw} <- Client.get(client_opts, size_oid),
|
|
used when is_number(used) <- decode_snmp_value(used_raw),
|
|
size when is_number(size) <- decode_snmp_value(size_raw),
|
|
true <- size > 0 do
|
|
percentage = used / size * 100
|
|
{:ok, percentage}
|
|
else
|
|
{:error, reason} -> {:error, reason}
|
|
false -> {:error, :division_by_zero}
|
|
_ -> {:error, :non_numeric}
|
|
end
|
|
end
|
|
|
|
defp poll_interfaces(interfaces, client_opts, timestamp) do
|
|
Enum.each(interfaces, fn interface ->
|
|
# Poll interface stats: ifInOctets, ifOutOctets, ifInErrors, ifOutErrors
|
|
{:ok, stat_data} = get_interface_stats(client_opts, interface.if_index)
|
|
|
|
stats =
|
|
Map.merge(stat_data, %{
|
|
interface_id: interface.id,
|
|
checked_at: timestamp
|
|
})
|
|
|
|
Snmp.create_interface_stat(stats)
|
|
end)
|
|
end
|
|
|
|
defp get_interface_stats(client_opts, if_index) do
|
|
oids = [
|
|
in_octets: "1.3.6.1.2.1.2.2.1.10.#{if_index}",
|
|
out_octets: "1.3.6.1.2.1.2.2.1.16.#{if_index}",
|
|
in_errors: "1.3.6.1.2.1.2.2.1.14.#{if_index}",
|
|
out_errors: "1.3.6.1.2.1.2.2.1.20.#{if_index}",
|
|
in_discards: "1.3.6.1.2.1.2.2.1.13.#{if_index}",
|
|
out_discards: "1.3.6.1.2.1.2.2.1.19.#{if_index}"
|
|
]
|
|
|
|
results =
|
|
Enum.map(oids, fn {key, oid} ->
|
|
case Client.get(client_opts, oid) do
|
|
{:ok, value} -> {key, decode_snmp_value(value)}
|
|
_ -> {key, nil}
|
|
end
|
|
end)
|
|
|
|
{:ok, Map.new(results)}
|
|
end
|
|
|
|
# Decode SNMP values, handling Counter64 and other binary types
|
|
defp decode_snmp_value(value) when is_number(value), do: value
|
|
|
|
defp decode_snmp_value(value) when is_binary(value) do
|
|
# Try to decode based on byte size
|
|
try do
|
|
case byte_size(value) do
|
|
8 ->
|
|
# Standard Counter64 (8 bytes, big-endian unsigned integer)
|
|
<<counter::unsigned-big-integer-size(64)>> = value
|
|
counter
|
|
|
|
16 ->
|
|
# Some SNMP implementations return 16-byte values
|
|
# Try reading last 8 bytes as Counter64
|
|
<<_prefix::binary-size(8), counter::unsigned-big-integer-size(64)>> = value
|
|
counter
|
|
|
|
size when size > 8 ->
|
|
# Large binary, try reading last 8 bytes
|
|
offset = size - 8
|
|
<<_prefix::binary-size(offset), counter::unsigned-big-integer-size(64)>> = value
|
|
Logger.debug("Decoded #{size}-byte SNMP value as Counter64 from last 8 bytes: #{counter}")
|
|
counter
|
|
|
|
_ ->
|
|
# Unknown binary format or too small
|
|
Logger.warning(
|
|
"Unknown SNMP binary value format, size: #{byte_size(value)}, bytes: #{inspect(value)}"
|
|
)
|
|
|
|
nil
|
|
end
|
|
rescue
|
|
error ->
|
|
Logger.error(
|
|
"Failed to decode SNMP binary value (size: #{byte_size(value)}): #{inspect(error)}, bytes: #{inspect(value)}"
|
|
)
|
|
|
|
nil
|
|
end
|
|
end
|
|
|
|
defp decode_snmp_value(_), do: nil
|
|
|
|
defp build_client_opts(equipment) do
|
|
[
|
|
ip: equipment.ip_address,
|
|
community: equipment.snmp_community,
|
|
version: equipment.snmp_version,
|
|
port: equipment.snmp_port || 161,
|
|
timeout: 5000
|
|
]
|
|
end
|
|
|
|
defp get_poll_interval(equipment) do
|
|
# Use check_interval_seconds, but minimum of 30 seconds for SNMP polling
|
|
max(equipment.check_interval_seconds || @default_poll_interval, 30)
|
|
end
|
|
|
|
defp schedule_next_poll(interval_seconds) do
|
|
Process.send_after(self(), :poll_data, interval_seconds * 1000)
|
|
end
|
|
|
|
defp should_skip_poll?(equipment, poll_interval_seconds, grace_period_seconds) do
|
|
case equipment.last_snmp_poll_at do
|
|
nil ->
|
|
false
|
|
|
|
last_poll ->
|
|
now = DateTime.utc_now()
|
|
seconds_since_poll = DateTime.diff(now, last_poll, :second)
|
|
min_interval = poll_interval_seconds - grace_period_seconds
|
|
|
|
# Skip if polled within the minimum interval
|
|
seconds_since_poll < min_interval
|
|
end
|
|
end
|
|
|
|
defp via_tuple(equipment_id) do
|
|
{:via, Registry, {PollerRegistry, equipment_id}}
|
|
end
|
|
end
|