snmp overhaul
This commit is contained in:
parent
94311a48aa
commit
adec4b134f
20 changed files with 931 additions and 97 deletions
|
|
@ -81,7 +81,7 @@ FROM ${RUNNER_IMAGE} AS final
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
|
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates snmp snmp-mibs-downloader \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Set the locale
|
# Set the locale
|
||||||
|
|
@ -101,6 +101,10 @@ ENV MIX_ENV="prod"
|
||||||
# Only copy the final release from the build stage
|
# Only copy the final release from the build stage
|
||||||
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/towerops ./
|
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/towerops ./
|
||||||
|
|
||||||
|
# Copy MIB files for SNMP MIB name resolution
|
||||||
|
# LibreNMS MIBs will be mounted or copied separately
|
||||||
|
RUN mkdir -p /app/mibs
|
||||||
|
|
||||||
USER nobody
|
USER nobody
|
||||||
|
|
||||||
# If using an environment that doesn't automatically reap zombie processes, it is
|
# If using an environment that doesn't automatically reap zombie processes, it is
|
||||||
|
|
|
||||||
|
|
@ -100,5 +100,8 @@ config :towerops, :redis,
|
||||||
host: "localhost",
|
host: "localhost",
|
||||||
port: 6379
|
port: 6379
|
||||||
|
|
||||||
|
# Configure SNMP polling interval for development (60 seconds)
|
||||||
|
config :towerops, :snmp_min_poll_interval, 60
|
||||||
|
|
||||||
# Enable dev routes for dashboard and mailbox
|
# Enable dev routes for dashboard and mailbox
|
||||||
config :towerops, dev_routes: true
|
config :towerops, dev_routes: true
|
||||||
|
|
|
||||||
|
|
@ -124,3 +124,11 @@ spec:
|
||||||
timeoutSeconds: 2
|
timeoutSeconds: 2
|
||||||
successThreshold: 1
|
successThreshold: 1
|
||||||
failureThreshold: 2
|
failureThreshold: 2
|
||||||
|
volumeMounts:
|
||||||
|
- name: mibs
|
||||||
|
mountPath: /app/mibs
|
||||||
|
readOnly: false
|
||||||
|
volumes:
|
||||||
|
- name: mibs
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: towerops-mibs
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ kind: Kustomization
|
||||||
namespace: towerops
|
namespace: towerops
|
||||||
resources:
|
resources:
|
||||||
- namespace.yaml
|
- namespace.yaml
|
||||||
|
- mib-pvc.yaml
|
||||||
- deployment.yaml
|
- deployment.yaml
|
||||||
- service.yaml
|
- service.yaml
|
||||||
- service-headless.yaml
|
- service-headless.yaml
|
||||||
|
|
|
||||||
13
k8s/mib-pvc.yaml
Normal file
13
k8s/mib-pvc.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: towerops-mibs
|
||||||
|
namespace: towerops
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce # Ceph RBD only supports ReadWriteOnce
|
||||||
|
storageClassName: ceph-rbd # Use the default Ceph RBD storage class
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi # MIB files are typically small (few hundred MB)
|
||||||
|
|
@ -195,12 +195,30 @@ defmodule Towerops.DeviceProfiles do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp list_profiles_with_detection_rules do
|
defp list_profiles_with_detection_rules do
|
||||||
Repo.all(
|
# Load all enabled profiles with detection rules
|
||||||
from p in DeviceProfile,
|
profiles =
|
||||||
where: p.enabled == true,
|
Repo.all(
|
||||||
order_by: [asc: p.priority],
|
from p in DeviceProfile,
|
||||||
preload: [:detection_rules]
|
where: p.enabled == true,
|
||||||
)
|
preload: [:detection_rules]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort profiles by:
|
||||||
|
# 1. Priority (ascending - lower number = higher priority)
|
||||||
|
# 2. Pattern specificity (descending - longer pattern = more specific)
|
||||||
|
# This ensures more specific patterns like RouterOS (.1.3.6.1.4.1.14988.1)
|
||||||
|
# are checked before generic patterns like Supermicro (.1.3.6.1.4.1.)
|
||||||
|
Enum.sort_by(profiles, fn profile ->
|
||||||
|
max_pattern_length = get_max_pattern_length(profile.detection_rules)
|
||||||
|
{profile.priority, -max_pattern_length}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_max_pattern_length(rules) do
|
||||||
|
rules
|
||||||
|
|> Enum.filter(&(&1.rule_type == "sysObjectID"))
|
||||||
|
|> Enum.map(fn rule -> String.length(rule.pattern || "") end)
|
||||||
|
|> Enum.max(fn -> 0 end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp match_detection_rules?(rules, system_info) do
|
defp match_detection_rules?(rules, system_info) do
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,10 @@ defmodule Towerops.DeviceProfiles.Importer do
|
||||||
has_valid_oid = present?(sensor["oid"]) || present?(sensor["num_oid"])
|
has_valid_oid = present?(sensor["oid"]) || present?(sensor["num_oid"])
|
||||||
|
|
||||||
if has_valid_oid do
|
if has_valid_oid do
|
||||||
|
# Calculate divisor with validation
|
||||||
|
raw_divisor = sensor["divisor"] || calculate_divisor(sensor["precision"])
|
||||||
|
divisor = validate_divisor(raw_divisor)
|
||||||
|
|
||||||
attrs = %{
|
attrs = %{
|
||||||
device_profile_id: profile_id,
|
device_profile_id: profile_id,
|
||||||
sensor_class: sensor_class,
|
sensor_class: sensor_class,
|
||||||
|
|
@ -341,7 +345,7 @@ defmodule Towerops.DeviceProfiles.Importer do
|
||||||
num_oid: nilify_blank(sensor["num_oid"]),
|
num_oid: nilify_blank(sensor["num_oid"]),
|
||||||
index: to_string(sensor["index"] || ""),
|
index: to_string(sensor["index"] || ""),
|
||||||
descr: sensor["descr"],
|
descr: sensor["descr"],
|
||||||
divisor: calculate_divisor(sensor["precision"]),
|
divisor: divisor,
|
||||||
precision: sensor["precision"],
|
precision: sensor["precision"],
|
||||||
sensor_type: sensor["type"],
|
sensor_type: sensor["type"],
|
||||||
low_limit: parse_float(sensor["low_limit"]),
|
low_limit: parse_float(sensor["low_limit"]),
|
||||||
|
|
@ -426,6 +430,32 @@ defmodule Towerops.DeviceProfiles.Importer do
|
||||||
defp present?(""), do: false
|
defp present?(""), do: false
|
||||||
defp present?(_), do: true
|
defp present?(_), do: true
|
||||||
|
|
||||||
|
# Validate divisor to ensure it fits in PostgreSQL integer range
|
||||||
|
# PostgreSQL integer: -2147483648 to 2147483647
|
||||||
|
@max_integer 2_147_483_647
|
||||||
|
|
||||||
|
defp validate_divisor(divisor) when is_integer(divisor) do
|
||||||
|
cond do
|
||||||
|
divisor > @max_integer ->
|
||||||
|
Logger.warning("Divisor #{divisor} exceeds PostgreSQL integer limit, capping at #{@max_integer}")
|
||||||
|
@max_integer
|
||||||
|
|
||||||
|
divisor < -@max_integer ->
|
||||||
|
Logger.warning("Divisor #{divisor} below PostgreSQL integer limit, capping at -#{@max_integer}")
|
||||||
|
-@max_integer
|
||||||
|
|
||||||
|
true ->
|
||||||
|
divisor
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_divisor(divisor) when is_float(divisor) do
|
||||||
|
validate_divisor(trunc(divisor))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_divisor(nil), do: 1
|
||||||
|
defp validate_divisor(_), do: 1
|
||||||
|
|
||||||
# Convert OID values, preserving all data types since the field is now JSONB
|
# Convert OID values, preserving all data types since the field is now JSONB
|
||||||
# Handles:
|
# Handles:
|
||||||
# - Empty strings -> nil
|
# - Empty strings -> nil
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,19 @@ defmodule Towerops.Devices do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a single device.
|
Gets a single device by ID, returns nil if not found.
|
||||||
|
"""
|
||||||
|
def get_device(id) do
|
||||||
|
DeviceSchema
|
||||||
|
|> Repo.get(id)
|
||||||
|
|> case do
|
||||||
|
nil -> nil
|
||||||
|
device -> Repo.preload(device, [:site])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a single device by ID, raises if not found.
|
||||||
"""
|
"""
|
||||||
def get_device!(id) do
|
def get_device!(id) do
|
||||||
DeviceSchema
|
DeviceSchema
|
||||||
|
|
|
||||||
|
|
@ -372,7 +372,11 @@ defmodule Towerops.Snmp.Discovery do
|
||||||
|
|
||||||
@spec insert_sensors(Device.t(), [sensor_data()]) :: [Sensor.t()]
|
@spec insert_sensors(Device.t(), [sensor_data()]) :: [Sensor.t()]
|
||||||
defp insert_sensors(device, sensors) do
|
defp insert_sensors(device, sensors) do
|
||||||
Enum.map(sensors, fn sensor_data ->
|
# Deduplicate sensors by sensor_index (keep first occurrence)
|
||||||
|
# This prevents duplicate key errors when profiles generate duplicate indices
|
||||||
|
sensors
|
||||||
|
|> Enum.uniq_by(& &1.sensor_index)
|
||||||
|
|> Enum.map(fn sensor_data ->
|
||||||
%Sensor{}
|
%Sensor{}
|
||||||
|> Sensor.changeset(Map.put(sensor_data, :snmp_device_id, device.id))
|
|> Sensor.changeset(Map.put(sensor_data, :snmp_device_id, device.id))
|
||||||
|> Repo.insert!()
|
|> Repo.insert!()
|
||||||
|
|
|
||||||
84
lib/towerops/snmp/mib_translator.ex
Normal file
84
lib/towerops/snmp/mib_translator.ex
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
defmodule Towerops.Snmp.MibTranslator do
|
||||||
|
@moduledoc """
|
||||||
|
Translates SNMP MIB symbolic names to numeric OIDs using net-snmp's snmptranslate.
|
||||||
|
|
||||||
|
Uses the system's snmptranslate command to resolve MIB names like
|
||||||
|
"MIKROTIK-MIB::mtxrSerialNumber.0" to numeric OIDs like "1.3.6.1.4.1.14988.1.1.7.3.0".
|
||||||
|
|
||||||
|
Requires net-snmp package to be installed and MIB files to be available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Translates a MIB symbolic name to a numeric OID.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> translate("MIKROTIK-MIB::mtxrSerialNumber.0")
|
||||||
|
{:ok, "1.3.6.1.4.1.14988.1.1.7.3.0"}
|
||||||
|
|
||||||
|
iex> translate("SNMPv2-MIB::sysDescr.0")
|
||||||
|
{:ok, "1.3.6.1.2.1.1.1.0"}
|
||||||
|
|
||||||
|
iex> translate("1.3.6.1.2.1.1.1.0")
|
||||||
|
{:ok, "1.3.6.1.2.1.1.1.0"}
|
||||||
|
|
||||||
|
iex> translate("INVALID-MIB::badObject")
|
||||||
|
{:error, :translation_failed}
|
||||||
|
"""
|
||||||
|
@spec translate(String.t()) :: {:ok, String.t()} | {:error, :translation_failed}
|
||||||
|
def translate(mib_name) when is_binary(mib_name) do
|
||||||
|
# If it's already a numeric OID, return it as-is
|
||||||
|
if numeric_oid?(mib_name) do
|
||||||
|
{:ok, mib_name}
|
||||||
|
else
|
||||||
|
translate_mib_name(mib_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Translates multiple MIB names to numeric OIDs in a single batch.
|
||||||
|
|
||||||
|
Returns a map of mib_name => {:ok, oid} or {:error, reason}.
|
||||||
|
"""
|
||||||
|
@spec translate_batch([String.t()]) :: %{String.t() => {:ok, String.t()} | {:error, term()}}
|
||||||
|
def translate_batch(mib_names) when is_list(mib_names) do
|
||||||
|
Map.new(mib_names, fn name ->
|
||||||
|
{name, translate(name)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Private functions
|
||||||
|
|
||||||
|
defp numeric_oid?(string) do
|
||||||
|
String.match?(string, ~r/^\.?\d+(\.\d+)*$/)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translate_mib_name(mib_name) do
|
||||||
|
# Get MIB directories from config or use default
|
||||||
|
mib_dirs = Application.get_env(:towerops, :mib_dirs, ["/app/mibs", "/usr/share/snmp/mibs"])
|
||||||
|
mib_dir_args = Enum.flat_map(mib_dirs, fn dir -> ["-M", "+#{dir}"] end)
|
||||||
|
|
||||||
|
# Use snmptranslate to convert MIB name to numeric OID
|
||||||
|
# -On: Output numeric OID
|
||||||
|
# -IR: Random access to MIB registry
|
||||||
|
args = ["-On", "-IR"] ++ mib_dir_args ++ [mib_name]
|
||||||
|
|
||||||
|
case System.cmd("snmptranslate", args, stderr_to_stdout: true) do
|
||||||
|
{output, 0} ->
|
||||||
|
# snmptranslate returns the numeric OID, sometimes with a leading dot
|
||||||
|
oid = String.trim(output)
|
||||||
|
{:ok, oid}
|
||||||
|
|
||||||
|
{error_output, _exit_code} ->
|
||||||
|
Logger.warning("Failed to translate MIB name #{mib_name}: #{error_output}")
|
||||||
|
{:error, :translation_failed}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
e in ErlangError ->
|
||||||
|
# snmptranslate command not found or other system error
|
||||||
|
Logger.error("snmptranslate command failed: #{inspect(e)}")
|
||||||
|
{:error, :command_not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -54,7 +54,8 @@ defmodule Towerops.Snmp.Poller do
|
||||||
ip: device.ip_address,
|
ip: device.ip_address,
|
||||||
community: device.snmp_community,
|
community: device.snmp_community,
|
||||||
version: device.snmp_version,
|
version: device.snmp_version,
|
||||||
port: device.snmp_port || 161
|
port: device.snmp_port || 161,
|
||||||
|
timeout: 5000
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,7 @@ defmodule Towerops.Snmp.PollerWorker do
|
||||||
|
|
||||||
defp poll_sensors(sensors, client_opts, timestamp) do
|
defp poll_sensors(sensors, client_opts, timestamp) do
|
||||||
# Poll sensors in parallel with max_concurrency to avoid overwhelming the device
|
# Poll sensors in parallel with max_concurrency to avoid overwhelming the device
|
||||||
|
# Timeout must be longer than SNMP timeout (30s) to allow slow devices
|
||||||
sensors
|
sensors
|
||||||
|> Task.async_stream(
|
|> Task.async_stream(
|
||||||
fn sensor ->
|
fn sensor ->
|
||||||
|
|
@ -233,7 +234,7 @@ defmodule Towerops.Snmp.PollerWorker do
|
||||||
handle_sensor_poll_result(sensor, result, timestamp)
|
handle_sensor_poll_result(sensor, result, timestamp)
|
||||||
end,
|
end,
|
||||||
max_concurrency: 2,
|
max_concurrency: 2,
|
||||||
timeout: 10_000,
|
timeout: 40_000,
|
||||||
on_timeout: :kill_task
|
on_timeout: :kill_task
|
||||||
)
|
)
|
||||||
|> Stream.run()
|
|> Stream.run()
|
||||||
|
|
@ -358,6 +359,7 @@ defmodule Towerops.Snmp.PollerWorker do
|
||||||
|
|
||||||
defp poll_interfaces(interfaces, client_opts, timestamp) do
|
defp poll_interfaces(interfaces, client_opts, timestamp) do
|
||||||
# Poll interfaces in parallel with max_concurrency
|
# Poll interfaces in parallel with max_concurrency
|
||||||
|
# Timeout must be longer than SNMP timeout (30s) to allow slow devices
|
||||||
interfaces
|
interfaces
|
||||||
|> Task.async_stream(
|
|> Task.async_stream(
|
||||||
fn interface ->
|
fn interface ->
|
||||||
|
|
@ -373,7 +375,7 @@ defmodule Towerops.Snmp.PollerWorker do
|
||||||
Snmp.create_interface_stat(stats)
|
Snmp.create_interface_stat(stats)
|
||||||
end,
|
end,
|
||||||
max_concurrency: 2,
|
max_concurrency: 2,
|
||||||
timeout: 10_000,
|
timeout: 40_000,
|
||||||
on_timeout: :kill_task
|
on_timeout: :kill_task
|
||||||
)
|
)
|
||||||
|> Stream.run()
|
|> Stream.run()
|
||||||
|
|
@ -381,6 +383,7 @@ defmodule Towerops.Snmp.PollerWorker do
|
||||||
|
|
||||||
defp check_interface_changes(interfaces, device, client_opts, timestamp) do
|
defp check_interface_changes(interfaces, device, client_opts, timestamp) do
|
||||||
# Check interface changes in parallel
|
# Check interface changes in parallel
|
||||||
|
# Timeout must be longer than SNMP timeout (30s) to allow slow devices
|
||||||
interfaces
|
interfaces
|
||||||
|> Task.async_stream(
|
|> Task.async_stream(
|
||||||
fn interface ->
|
fn interface ->
|
||||||
|
|
@ -393,7 +396,7 @@ defmodule Towerops.Snmp.PollerWorker do
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
max_concurrency: 2,
|
max_concurrency: 2,
|
||||||
timeout: 10_000,
|
timeout: 40_000,
|
||||||
on_timeout: :kill_task
|
on_timeout: :kill_task
|
||||||
)
|
)
|
||||||
|> Stream.run()
|
|> Stream.run()
|
||||||
|
|
@ -725,8 +728,9 @@ defmodule Towerops.Snmp.PollerWorker do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_poll_interval(device) do
|
defp get_poll_interval(device) do
|
||||||
# Use check_interval_seconds, but minimum of 30 seconds for SNMP polling
|
# Use check_interval_seconds, with configurable minimum for SNMP polling
|
||||||
max(device.check_interval_seconds || @default_poll_interval, 30)
|
min_interval = Application.get_env(:towerops, :snmp_min_poll_interval, 300)
|
||||||
|
max(device.check_interval_seconds || @default_poll_interval, min_interval)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp schedule_next_poll(interval_seconds) do
|
defp schedule_next_poll(interval_seconds) do
|
||||||
|
|
|
||||||
|
|
@ -86,12 +86,13 @@ defmodule Towerops.Snmp.Profiles.Base do
|
||||||
indices = if_indices |> Map.values() |> Enum.map(&parse_integer/1)
|
indices = if_indices |> Map.values() |> Enum.map(&parse_integer/1)
|
||||||
|
|
||||||
# Fetch all interface data in parallel
|
# Fetch all interface data in parallel
|
||||||
|
# Timeout must be longer than SNMP timeout (30s) to allow slow devices
|
||||||
interface_data =
|
interface_data =
|
||||||
indices
|
indices
|
||||||
|> Task.async_stream(
|
|> Task.async_stream(
|
||||||
fn index -> fetch_interface_data(client_opts, index) end,
|
fn index -> fetch_interface_data(client_opts, index) end,
|
||||||
max_concurrency: 10,
|
max_concurrency: 10,
|
||||||
timeout: 10_000
|
timeout: 40_000
|
||||||
)
|
)
|
||||||
|> Enum.map(fn
|
|> Enum.map(fn
|
||||||
{:ok, {:ok, interface}} -> interface
|
{:ok, {:ok, interface}} -> interface
|
||||||
|
|
|
||||||
|
|
@ -18,24 +18,34 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
||||||
Identifies device using the database profile information.
|
Identifies device using the database profile information.
|
||||||
Returns manufacturer, model, and firmware info.
|
Returns manufacturer, model, and firmware info.
|
||||||
|
|
||||||
Polls SNMP OIDs from OS definitions to extract version and serial number.
|
Delegates to hardcoded vendor profiles when available for accurate device identification.
|
||||||
|
Falls back to database OS definitions with MIB name translation.
|
||||||
"""
|
"""
|
||||||
@spec identify_device(DeviceProfile.t(), Client.connection_opts(), Discovery.system_info()) ::
|
@spec identify_device(DeviceProfile.t(), Client.connection_opts(), Discovery.system_info()) ::
|
||||||
Discovery.device_info()
|
Discovery.device_info()
|
||||||
def identify_device(profile, client_opts, _system_info) do
|
def identify_device(profile, client_opts, system_info) do
|
||||||
# Load profile with OS definitions
|
# Load profile with OS definitions
|
||||||
profile = DeviceProfiles.get_profile_with_associations(profile.os)
|
profile = DeviceProfiles.get_profile_with_associations(profile.os)
|
||||||
|
|
||||||
# Poll OS definitions for version, serial, etc.
|
# Prefer hardcoded vendor profiles when available for accurate identification
|
||||||
os_data = poll_os_definitions(profile, client_opts)
|
# They use numeric OIDs and vendor-specific logic
|
||||||
|
case delegate_to_vendor_profile(profile, client_opts, system_info) do
|
||||||
|
{:ok, device_info} ->
|
||||||
|
Logger.debug("Using vendor-specific profile for device identification")
|
||||||
|
device_info
|
||||||
|
|
||||||
# Use profile metadata to build device info
|
:no_vendor_profile ->
|
||||||
%{
|
# Fall back to database-driven identification with MIB translation
|
||||||
manufacturer: extract_manufacturer(profile),
|
Logger.debug("Using database profile with MIB translation for device identification")
|
||||||
model: profile.text || profile.os,
|
os_data = poll_os_definitions(profile, client_opts)
|
||||||
firmware_version: os_data[:version] || profile.os,
|
|
||||||
serial_number: os_data[:serial]
|
%{
|
||||||
}
|
manufacturer: extract_manufacturer(profile),
|
||||||
|
model: profile.text || profile.os,
|
||||||
|
firmware_version: os_data[:version] || os_data[:firmware_version] || profile.os,
|
||||||
|
serial_number: os_data[:serial]
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -88,13 +98,59 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
||||||
|
|
||||||
# Private functions
|
# Private functions
|
||||||
|
|
||||||
defp poll_os_definitions(_profile, _client_opts) do
|
# Delegate device identification to hardcoded vendor profiles when available
|
||||||
# Note: OS definition polling is currently disabled because device profiles
|
# This allows us to use vendor-specific logic with numeric OIDs for proper identification
|
||||||
# use MIB symbolic names (e.g., CAMBIUM-PMP80211-MIB::cambiumCurrentuImageVersion.0)
|
defp delegate_to_vendor_profile(profile, _client_opts, system_info) do
|
||||||
# which require MIB compilation to resolve to numeric OIDs.
|
case profile.group do
|
||||||
# The SNMP client only supports numeric OIDs.
|
"mikrotik" ->
|
||||||
# Future enhancement: Add MIB resolution or import numeric OIDs during profile import
|
# Use hardcoded MikroTik profile for device identification
|
||||||
%{}
|
alias Towerops.Snmp.Profiles.Mikrotik
|
||||||
|
|
||||||
|
device_info = Mikrotik.identify_device(system_info)
|
||||||
|
{:ok, device_info}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:no_vendor_profile
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp poll_os_definitions(profile, client_opts) do
|
||||||
|
alias Towerops.Snmp.MibTranslator
|
||||||
|
|
||||||
|
# Get all OS definitions for this profile
|
||||||
|
os_defs = profile.os_definitions || []
|
||||||
|
|
||||||
|
# Poll each OS definition and build a map of field => value
|
||||||
|
Map.new(os_defs, fn os_def ->
|
||||||
|
value =
|
||||||
|
if os_def.oid do
|
||||||
|
# Translate MIB name to numeric OID if needed
|
||||||
|
case MibTranslator.translate(os_def.oid) do
|
||||||
|
{:ok, numeric_oid} ->
|
||||||
|
# Poll the numeric OID
|
||||||
|
case Client.get(client_opts, numeric_oid) do
|
||||||
|
{:ok, result} -> result
|
||||||
|
{:error, _} -> nil
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, _} ->
|
||||||
|
Logger.debug("Failed to translate MIB name: #{os_def.oid}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Convert field name to atom for the map key
|
||||||
|
field_atom =
|
||||||
|
case os_def.field do
|
||||||
|
"version" -> :version
|
||||||
|
"serial" -> :serial
|
||||||
|
"hardware" -> :hardware
|
||||||
|
"features" -> :features
|
||||||
|
field -> String.to_atom(field)
|
||||||
|
end
|
||||||
|
|
||||||
|
{field_atom, value}
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp extract_manufacturer(%DeviceProfile{group: group}) when is_binary(group) do
|
defp extract_manufacturer(%DeviceProfile{group: group}) when is_binary(group) do
|
||||||
|
|
@ -116,9 +172,15 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
||||||
oid = proc_def.num_oid || proc_def.oid
|
oid = proc_def.num_oid || proc_def.oid
|
||||||
|
|
||||||
if oid do
|
if oid do
|
||||||
# Remove template variables from OID
|
# Skip symbolic MIB names (contain "::") - only numeric OIDs can be polled
|
||||||
walk_oid = clean_oid_template(oid)
|
if String.contains?(oid, "::") do
|
||||||
walk_processor_oid(client_opts, walk_oid, proc_def)
|
Logger.debug("Skipping symbolic MIB name in processor: #{oid}")
|
||||||
|
[]
|
||||||
|
else
|
||||||
|
# Remove template variables from OID
|
||||||
|
walk_oid = clean_oid_template(oid)
|
||||||
|
walk_processor_oid(client_opts, walk_oid, proc_def)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
Logger.warning("Processor definition missing OID: #{inspect(proc_def)}")
|
Logger.warning("Processor definition missing OID: #{inspect(proc_def)}")
|
||||||
[]
|
[]
|
||||||
|
|
@ -157,10 +219,20 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
||||||
end
|
end
|
||||||
|
|
||||||
if oid_string do
|
if oid_string do
|
||||||
# Remove template variables like ".{{ $index }}" from OID
|
# Skip symbolic MIB names (contain "::") - only numeric OIDs can be polled
|
||||||
# For walks, we need the base OID without the index placeholder
|
if String.contains?(oid_string, "::") do
|
||||||
walk_oid = clean_oid_template(oid_string)
|
Logger.debug("Skipping symbolic MIB name: #{oid_string}")
|
||||||
walk_sensor_oid(client_opts, walk_oid, sensor_def)
|
[]
|
||||||
|
else
|
||||||
|
# Remove template variables like ".{{ $index }}" from OID
|
||||||
|
# For walks, we need the base OID without the index placeholder
|
||||||
|
walk_oid = clean_oid_template(oid_string)
|
||||||
|
|
||||||
|
# Check if descr references another OID column (e.g., MIKROTIK-MIB::mtxrGaugeName)
|
||||||
|
descr_map = maybe_walk_descr_oid(client_opts, walk_oid, sensor_def.descr)
|
||||||
|
|
||||||
|
walk_sensor_oid(client_opts, walk_oid, sensor_def, descr_map)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
Logger.warning("Sensor definition has invalid OID format: #{inspect(oid)}")
|
Logger.warning("Sensor definition has invalid OID format: #{inspect(oid)}")
|
||||||
[]
|
[]
|
||||||
|
|
@ -171,12 +243,30 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp walk_sensor_oid(client_opts, walk_oid, sensor_def) do
|
defp walk_sensor_oid(client_opts, walk_oid, sensor_def, descr_map) do
|
||||||
case Client.walk(client_opts, walk_oid) do
|
case Client.walk(client_opts, walk_oid) do
|
||||||
{:ok, results} when is_map(results) and map_size(results) > 0 ->
|
{:ok, results} when is_map(results) and map_size(results) > 0 ->
|
||||||
# Convert walk results (map) to sensor data
|
# Get skip_values filtering rules from sensor definition options
|
||||||
Enum.map(results, fn {result_oid, value} ->
|
skip_values = get_skip_values(sensor_def)
|
||||||
build_sensor_data(sensor_def, %{oid: result_oid, value: value})
|
|
||||||
|
# Walk filter OID if skip_values are defined
|
||||||
|
{filter_map, descr_map_with_fallback} =
|
||||||
|
if skip_values == [] do
|
||||||
|
{nil, descr_map}
|
||||||
|
else
|
||||||
|
apply_skip_values_filter(client_opts, walk_oid, skip_values, descr_map)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Convert walk results (map) to sensor data, applying filters if present
|
||||||
|
results
|
||||||
|
|> Enum.map(fn {result_oid, value} ->
|
||||||
|
{result_oid, value, extract_index_from_full_oid(result_oid)}
|
||||||
|
end)
|
||||||
|
|> Enum.filter(fn {_oid, _value, index} ->
|
||||||
|
should_include_sensor?(sensor_def, index, filter_map, skip_values)
|
||||||
|
end)
|
||||||
|
|> Enum.map(fn {result_oid, value, _index} ->
|
||||||
|
build_sensor_data(sensor_def, %{oid: result_oid, value: value}, descr_map_with_fallback)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
{:ok, _empty} ->
|
{:ok, _empty} ->
|
||||||
|
|
@ -189,6 +279,312 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if this is a MikroTik mtxrGaugeTable sensor
|
||||||
|
# Extract skip_values filtering rules from sensor definition options
|
||||||
|
defp get_skip_values(%{options: %{"skip_values" => skip_values}}) when is_list(skip_values), do: skip_values
|
||||||
|
defp get_skip_values(_sensor_def), do: []
|
||||||
|
|
||||||
|
# Apply skip_values filter by walking the referenced OID
|
||||||
|
# Returns {filter_map, descr_map_with_fallback}
|
||||||
|
defp apply_skip_values_filter(client_opts, value_oid, [skip_rule | _], descr_map) do
|
||||||
|
# Currently only handle first skip_values rule (LibreNMS profiles only have one)
|
||||||
|
filter_oid_name = skip_rule["oid"]
|
||||||
|
|
||||||
|
# Derive the filter OID from the value OID and the filter column reference
|
||||||
|
filter_oid = derive_filter_oid(value_oid, filter_oid_name)
|
||||||
|
|
||||||
|
Logger.info("Walking filter column #{filter_oid_name}: #{filter_oid}")
|
||||||
|
|
||||||
|
case Client.walk(client_opts, filter_oid) do
|
||||||
|
{:ok, results} when is_map(results) and map_size(results) > 0 ->
|
||||||
|
filter_map =
|
||||||
|
Map.new(results, fn {oid, value} ->
|
||||||
|
index = oid |> String.split(".") |> List.last()
|
||||||
|
{index, value}
|
||||||
|
end)
|
||||||
|
|
||||||
|
Logger.info("Found #{map_size(filter_map)} filter entries")
|
||||||
|
|
||||||
|
# Firmware bug workaround: if filter column returns strings and description column
|
||||||
|
# failed, use filter column strings as descriptions
|
||||||
|
descr_fallback =
|
||||||
|
if map_size(descr_map) == 0 and Enum.any?(filter_map, fn {_k, v} -> is_binary(v) end) do
|
||||||
|
Logger.info("Using filter column strings as sensor descriptions (firmware workaround)")
|
||||||
|
filter_map
|
||||||
|
else
|
||||||
|
descr_map
|
||||||
|
end
|
||||||
|
|
||||||
|
{filter_map, descr_fallback}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.info("Filter column not available (#{inspect(reason)}), will include all sensors")
|
||||||
|
{nil, descr_map}
|
||||||
|
|
||||||
|
{:ok, _empty} ->
|
||||||
|
Logger.info("Filter column returned no data, will include all sensors")
|
||||||
|
{nil, descr_map}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_skip_values_filter(_client_opts, _value_oid, [], descr_map), do: {nil, descr_map}
|
||||||
|
|
||||||
|
# Determine if a sensor should be included based on skip_values filter
|
||||||
|
defp should_include_sensor?(_sensor_def, _index, nil, _skip_values), do: true
|
||||||
|
|
||||||
|
defp should_include_sensor?(sensor_def, index, filter_map, skip_values) when is_map(filter_map) do
|
||||||
|
case Map.get(filter_map, index) do
|
||||||
|
nil ->
|
||||||
|
# No filter value for this index, include it
|
||||||
|
true
|
||||||
|
|
||||||
|
filter_value ->
|
||||||
|
# Apply skip_values logic: if any rule evaluates to true, SKIP the sensor
|
||||||
|
# Also handle firmware bug where filter returns strings - match by sensor class
|
||||||
|
should_skip =
|
||||||
|
Enum.any?(skip_values, fn rule ->
|
||||||
|
evaluate_skip_rule(rule, filter_value, sensor_def.sensor_class)
|
||||||
|
end)
|
||||||
|
|
||||||
|
not should_skip
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Evaluate a single skip_values rule
|
||||||
|
# Returns true if the sensor should be SKIPPED (filtered out)
|
||||||
|
defp evaluate_skip_rule(%{"op" => "!=", "value" => expected_value}, actual_value, sensor_class) do
|
||||||
|
# Skip if actual != expected (i.e., keep only if actual == expected)
|
||||||
|
# Handle both numeric values and string values (firmware bug workaround)
|
||||||
|
if is_binary(actual_value) do
|
||||||
|
# Firmware returns strings like "cpu-temperature" instead of unit codes
|
||||||
|
# Match by sensor class instead
|
||||||
|
not matches_sensor_class_in_string?(sensor_class, actual_value)
|
||||||
|
else
|
||||||
|
actual_value != expected_value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp evaluate_skip_rule(%{"op" => "==", "value" => expected_value}, actual_value, _sensor_class) do
|
||||||
|
# Skip if actual == expected
|
||||||
|
actual_value == expected_value
|
||||||
|
end
|
||||||
|
|
||||||
|
defp evaluate_skip_rule(_rule, _actual_value, _sensor_class), do: false
|
||||||
|
|
||||||
|
# Firmware bug workaround: match sensor class in string value
|
||||||
|
defp matches_sensor_class_in_string?("temperature", str), do: String.contains?(str, "temperature")
|
||||||
|
defp matches_sensor_class_in_string?("voltage", str), do: String.contains?(str, "voltage")
|
||||||
|
defp matches_sensor_class_in_string?("current", str), do: String.contains?(str, "current")
|
||||||
|
defp matches_sensor_class_in_string?("power", str), do: String.contains?(str, "power")
|
||||||
|
defp matches_sensor_class_in_string?("fanspeed", str), do: String.contains?(str, "fan")
|
||||||
|
|
||||||
|
defp matches_sensor_class_in_string?("state", str),
|
||||||
|
do: String.contains?(str, "state") or String.contains?(str, "status")
|
||||||
|
|
||||||
|
defp matches_sensor_class_in_string?(_, _), do: false
|
||||||
|
|
||||||
|
# Derive the filter OID from the value OID and filter column name
|
||||||
|
# Handles both numeric OIDs and symbolic MIB names
|
||||||
|
defp derive_filter_oid(value_oid, filter_name) when is_binary(filter_name) do
|
||||||
|
cond do
|
||||||
|
# If filter_name contains MIB reference, try to derive column OID
|
||||||
|
String.contains?(filter_name, "::") ->
|
||||||
|
derive_oid_from_mib_name(value_oid, filter_name)
|
||||||
|
|
||||||
|
# If it's already a numeric OID, use it directly
|
||||||
|
String.match?(filter_name, ~r/^[\d\.]+$/) ->
|
||||||
|
filter_name
|
||||||
|
|
||||||
|
# Unknown format, return value OID as fallback
|
||||||
|
true ->
|
||||||
|
Logger.warning("Unknown filter OID format: #{filter_name}, using value OID as fallback")
|
||||||
|
value_oid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Derive OID from MIB column name by analyzing the table structure
|
||||||
|
# For SNMP tables following .table.entry.column.index pattern
|
||||||
|
defp derive_oid_from_mib_name(value_oid, mib_name) do
|
||||||
|
# Extract the column name from MIB reference
|
||||||
|
column_suffix = mib_name |> String.split("::") |> List.last()
|
||||||
|
|
||||||
|
# Known column mappings for common MIB tables
|
||||||
|
# This could be extended or moved to database if needed
|
||||||
|
column_map = %{
|
||||||
|
# MikroTik mtxrGaugeTable: .100.1.{column}
|
||||||
|
"mtxrGaugeName" => 1,
|
||||||
|
"mtxrGaugeUnit" => 2,
|
||||||
|
"mtxrGaugeValue" => 3,
|
||||||
|
# MikroTik mtxrOpticalTable: .19.1.1.{column}
|
||||||
|
"mtxrOpticalName" => 2,
|
||||||
|
"mtxrOpticalRxPower" => 10,
|
||||||
|
"mtxrOpticalTxPower" => 9,
|
||||||
|
# MikroTik mtxrPOETable: .15.1.1.{column}
|
||||||
|
"mtxrPOEName" => 1,
|
||||||
|
"mtxrPOEVoltage" => 4,
|
||||||
|
"mtxrPOECurrent" => 5
|
||||||
|
}
|
||||||
|
|
||||||
|
case Map.get(column_map, column_suffix) do
|
||||||
|
nil ->
|
||||||
|
Logger.debug("No column mapping for #{column_suffix}, using value OID")
|
||||||
|
value_oid
|
||||||
|
|
||||||
|
target_column ->
|
||||||
|
# Replace the column number in the OID path
|
||||||
|
# Pattern: find .table.entry.{column}.{index} and replace column
|
||||||
|
replace_column_in_oid(value_oid, target_column)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generic column replacement in SNMP table OID
|
||||||
|
# Finds the last column number before the index and replaces it
|
||||||
|
defp replace_column_in_oid(oid, new_column) do
|
||||||
|
# Match standard SNMP table pattern: ...table.entry.column[.index]
|
||||||
|
# Replace the column number (second-to-last or last component)
|
||||||
|
case oid |> String.split(".") |> Enum.reverse() do
|
||||||
|
[index | [_old_column | rest]] when index != "" ->
|
||||||
|
# Has index: ...column.index -> ...new_column.index
|
||||||
|
Enum.join(Enum.reverse(rest, [to_string(new_column), index]), ".")
|
||||||
|
|
||||||
|
[_old_column | rest] ->
|
||||||
|
# No index: ...column -> ...new_column
|
||||||
|
Enum.join(Enum.reverse(rest, [to_string(new_column)]), ".")
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
# Can't parse, return original
|
||||||
|
oid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extract numeric index from full OID
|
||||||
|
defp extract_index_from_full_oid(oid) do
|
||||||
|
oid |> String.split(".") |> List.last()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Walk description OID if descr contains a symbolic MIB reference
|
||||||
|
defp maybe_walk_descr_oid(client_opts, value_oid, descr) when is_binary(descr) do
|
||||||
|
cond do
|
||||||
|
# Handle templates like "{{ MIKROTIK-MIB::mtxrOpticalName }} Tx Bias"
|
||||||
|
String.contains?(descr, "{{") and String.contains?(descr, "::") ->
|
||||||
|
# Extract symbolic name from template
|
||||||
|
case Regex.run(~r/\{\{\s*([A-Z0-9\-]+::[a-zA-Z0-9]+)\s*\}\}/, descr) do
|
||||||
|
[_full_match, symbolic_name] ->
|
||||||
|
walk_and_resolve_descriptions(client_opts, value_oid, symbolic_name, :template)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
%{}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle simple symbolic names like "MIKROTIK-MIB::mtxrGaugeName"
|
||||||
|
String.contains?(descr, "::") and is_simple_symbolic_name?(descr) ->
|
||||||
|
walk_and_resolve_descriptions(client_opts, value_oid, descr, :simple)
|
||||||
|
|
||||||
|
true ->
|
||||||
|
%{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_walk_descr_oid(_client_opts, _value_oid, _descr), do: %{}
|
||||||
|
|
||||||
|
defp walk_and_resolve_descriptions(client_opts, value_oid, symbolic_name, type) do
|
||||||
|
descr_oid = derive_descr_oid(value_oid, symbolic_name)
|
||||||
|
Logger.info("Resolving symbolic name #{symbolic_name} (#{type}): walking #{descr_oid}")
|
||||||
|
|
||||||
|
case Client.walk(client_opts, descr_oid) do
|
||||||
|
{:ok, results} when is_map(results) and map_size(results) > 0 ->
|
||||||
|
# Build map of index -> description
|
||||||
|
descr_map =
|
||||||
|
Map.new(results, fn {oid, value} ->
|
||||||
|
index = oid |> String.split(".") |> List.last()
|
||||||
|
{index, to_string(value)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
Logger.info("Resolved #{map_size(descr_map)} descriptions for #{symbolic_name}: #{inspect(descr_map)}")
|
||||||
|
|
||||||
|
descr_map
|
||||||
|
|
||||||
|
{:ok, _results} ->
|
||||||
|
Logger.warning("Empty results for description OID: #{descr_oid} for #{symbolic_name}")
|
||||||
|
%{}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Failed to walk description OID: #{descr_oid} for #{symbolic_name}: #{inspect(reason)}")
|
||||||
|
|
||||||
|
%{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if description is a simple symbolic name (not a template)
|
||||||
|
# Simple: "MIKROTIK-MIB::mtxrGaugeName"
|
||||||
|
# Template: "{{ PowerNet-MIB::pduOutletMeteredStatusName }}"
|
||||||
|
# Complex template: "GE{{ ADVANTECH-EKI-PRONEER-MIB::poePortStatusIndex }} POE"
|
||||||
|
defp is_simple_symbolic_name?(descr) do
|
||||||
|
String.contains?(descr, "::") and not String.contains?(descr, "{{")
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
# Derive description OID from value OID
|
||||||
|
# Most SNMP tables follow the pattern: .table_base.entry.column.index
|
||||||
|
# Description/name is typically in column 1 or 2
|
||||||
|
# Public for testing purposes
|
||||||
|
def derive_descr_oid(value_oid, descr) do
|
||||||
|
# Vendor-specific mappings where we know the exact column
|
||||||
|
vendor_specific_oid =
|
||||||
|
cond do
|
||||||
|
# MikroTik mtxrGaugeName is in column 1 of mtxrGaugeTable
|
||||||
|
# Value OID: .1.3.6.1.4.1.14988.1.1.3.100.1.3 or .1.3.6.1.4.1.14988.1.1.3.100.1.3.{index}
|
||||||
|
# Name OID: .1.3.6.1.4.1.14988.1.1.3.100.1.1 or .1.3.6.1.4.1.14988.1.1.3.100.1.1.{index}
|
||||||
|
String.contains?(descr, "mtxrGaugeName") ->
|
||||||
|
String.replace(value_oid, ~r/\.100\.1\.\d+($|\.)/, ".100.1.1\\1")
|
||||||
|
|
||||||
|
# MikroTik mtxrOpticalName is in column 2 of mtxrOpticalTable
|
||||||
|
# Value OID: .1.3.6.1.4.1.14988.1.1.19.1.1.{column} or .1.3.6.1.4.1.14988.1.1.19.1.1.{column}.{index}
|
||||||
|
# Name OID: .1.3.6.1.4.1.14988.1.1.19.1.1.2 or .1.3.6.1.4.1.14988.1.1.19.1.1.2.{index}
|
||||||
|
String.contains?(descr, "mtxrOpticalName") ->
|
||||||
|
String.replace(value_oid, ~r/\.19\.1\.1\.\d+($|\.)/, ".19.1.1.2\\1")
|
||||||
|
|
||||||
|
# MikroTik mtxrPOEName is in column 1 of mtxrPOETable
|
||||||
|
# Value OID: .1.3.6.1.4.1.14988.1.1.3.15.1.1.{column} or .1.3.6.1.4.1.14988.1.1.3.15.1.1.{column}.{index}
|
||||||
|
# Name OID: .1.3.6.1.4.1.14988.1.1.3.15.1.1.1 or .1.3.6.1.4.1.14988.1.1.3.15.1.1.1.{index}
|
||||||
|
String.contains?(descr, "mtxrPOEName") ->
|
||||||
|
String.replace(value_oid, ~r/\.15\.1\.1\.\d+($|\.)/, ".15.1.1.1\\1")
|
||||||
|
|
||||||
|
true ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# If we have a vendor-specific mapping, use it
|
||||||
|
# Otherwise try generic approach
|
||||||
|
vendor_specific_oid || derive_generic_descr_oid(value_oid)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generic SNMP table description OID derivation
|
||||||
|
# Attempts to find the description by trying common column positions
|
||||||
|
defp derive_generic_descr_oid(value_oid) do
|
||||||
|
# Parse OID to find table structure
|
||||||
|
# Format: .base.table.entry.column.index
|
||||||
|
# Example: .1.3.6.1.4.1.49136.100.1.3.5 -> try .1.3.6.1.4.1.49136.100.1.1.5
|
||||||
|
parts = value_oid |> String.split(".") |> Enum.reverse()
|
||||||
|
|
||||||
|
# Pattern match on list to get index and column
|
||||||
|
case parts do
|
||||||
|
[index, column | _rest]
|
||||||
|
when is_binary(column) and is_binary(index) and column != "" and index != "" ->
|
||||||
|
# Replace the column number with 1, but only the occurrence before the index
|
||||||
|
# This avoids replacing earlier occurrences (e.g., in 1.3.6.1)
|
||||||
|
String.replace(
|
||||||
|
value_oid,
|
||||||
|
~r/\.#{Regex.escape(column)}\.(#{Regex.escape(index)})$/,
|
||||||
|
".1.\\1"
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
# Fallback: try replacing last number before index with 1
|
||||||
|
String.replace(value_oid, ~r/\.\d+\.(\d+)$/, ".1.\\1")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Remove template variables from OID
|
# Remove template variables from OID
|
||||||
# Examples:
|
# Examples:
|
||||||
# ".1.3.6.1.4.1.17713.21.2.1.36.{{ $index }}" -> ".1.3.6.1.4.1.17713.21.2.1.36"
|
# ".1.3.6.1.4.1.17713.21.2.1.36.{{ $index }}" -> ".1.3.6.1.4.1.17713.21.2.1.36"
|
||||||
|
|
@ -199,13 +595,34 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
||||||
|> String.trim_trailing(".")
|
|> String.trim_trailing(".")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_sensor_data(sensor_def, %{oid: oid, value: value}) do
|
defp build_sensor_data(sensor_def, %{oid: oid, value: value}, descr_map) do
|
||||||
# Build sensor index from OID (extract last component)
|
# Build sensor index from OID (extract last component)
|
||||||
oid_index = extract_index_from_oid(oid, sensor_def.index)
|
oid_index = extract_index_from_oid(oid, sensor_def.index)
|
||||||
|
|
||||||
|
# Extract the numeric index for description lookup
|
||||||
|
numeric_index = oid |> String.split(".") |> List.last()
|
||||||
|
|
||||||
|
# Get actual description from map if available, otherwise use configured descr
|
||||||
|
actual_descr =
|
||||||
|
case Map.get(descr_map, numeric_index) do
|
||||||
|
nil ->
|
||||||
|
sensor_def.descr || "#{sensor_def.sensor_class} #{oid_index}"
|
||||||
|
|
||||||
|
resolved_name ->
|
||||||
|
# Check if descr is a template (contains {{ }})
|
||||||
|
if String.contains?(sensor_def.descr, "{{") do
|
||||||
|
# Replace template variable with resolved name
|
||||||
|
# E.g., "{{ MIKROTIK-MIB::mtxrOpticalName }} Tx Bias" -> "sfp-sfpplus1 Tx Bias"
|
||||||
|
String.replace(sensor_def.descr, ~r/\{\{[^}]+\}\}/, resolved_name)
|
||||||
|
else
|
||||||
|
# For simple symbolic names, use the resolved name directly
|
||||||
|
resolved_name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Make index unique by combining sensor class with OID index
|
# Make index unique by combining sensor class with OID index
|
||||||
# This prevents conflicts when multiple sensors have the same OID index (e.g., ".0")
|
# This prevents conflicts when multiple sensors have the same OID index (e.g., ".0")
|
||||||
sensor_index = build_unique_sensor_index(sensor_def.sensor_class, sensor_def.descr, oid_index)
|
sensor_index = build_unique_sensor_index(sensor_def.sensor_class, actual_descr, oid_index)
|
||||||
|
|
||||||
# Apply divisor to value if it's numeric
|
# Apply divisor to value if it's numeric
|
||||||
last_value =
|
last_value =
|
||||||
|
|
@ -224,7 +641,7 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
||||||
sensor_type: sensor_def.sensor_class,
|
sensor_type: sensor_def.sensor_class,
|
||||||
sensor_index: sensor_index,
|
sensor_index: sensor_index,
|
||||||
sensor_oid: oid,
|
sensor_oid: oid,
|
||||||
sensor_descr: sensor_def.descr || "#{sensor_def.sensor_class} #{sensor_index}",
|
sensor_descr: actual_descr,
|
||||||
sensor_unit: determine_unit(sensor_def.sensor_class),
|
sensor_unit: determine_unit(sensor_def.sensor_class),
|
||||||
sensor_divisor: sensor_def.divisor || 1,
|
sensor_divisor: sensor_def.divisor || 1,
|
||||||
last_value: last_value,
|
last_value: last_value,
|
||||||
|
|
@ -277,16 +694,26 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp extract_index_from_oid(oid, configured_index) when is_binary(configured_index) do
|
defp extract_index_from_oid(oid, configured_index) when is_binary(configured_index) do
|
||||||
# If index contains template variable or is empty, extract from OID
|
# Extract last component of OID
|
||||||
if configured_index == "" or String.contains?(configured_index, "{{") do
|
oid_index =
|
||||||
# Extract last component of OID as index
|
|
||||||
oid
|
oid
|
||||||
|> String.split(".")
|
|> String.split(".")
|
||||||
|> List.last()
|
|> List.last()
|
||||||
|> to_string()
|
|> to_string()
|
||||||
else
|
|
||||||
# Use pre-configured static index
|
cond do
|
||||||
configured_index
|
configured_index == "" ->
|
||||||
|
# No template, just use OID index
|
||||||
|
oid_index
|
||||||
|
|
||||||
|
String.contains?(configured_index, "{{") ->
|
||||||
|
# Replace template variable with actual OID index
|
||||||
|
# E.g., "mtxrGaugeCurrent.{{ $index }}" -> "mtxrGaugeCurrent.17"
|
||||||
|
String.replace(configured_index, ~r/\{\{\s*\$index\s*\}\}/, oid_index)
|
||||||
|
|
||||||
|
true ->
|
||||||
|
# Use pre-configured static index
|
||||||
|
configured_index
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -299,19 +726,21 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_unique_sensor_index(sensor_class, descr, oid_index) do
|
defp build_unique_sensor_index(sensor_class, descr, oid_index) do
|
||||||
# For scalar sensors (index 0), use a descriptive name from the description
|
cond do
|
||||||
# For table sensors, use the OID index
|
# If oid_index already has a dot (e.g., "mtxrGaugeCurrent.17"), use it as-is
|
||||||
if oid_index == "0" && descr do
|
String.contains?(oid_index, ".") ->
|
||||||
# Convert description to a safe index string
|
oid_index
|
||||||
# E.g., "DFS Detected Count" -> "dfs_detected_count"
|
|
||||||
# E.g., "GPS Status" -> "gps_status"
|
# For scalar sensors (index 0), use a descriptive name from the description
|
||||||
descr
|
oid_index == "0" && descr ->
|
||||||
|> String.downcase()
|
descr
|
||||||
|> String.replace(~r/[^a-z0-9]+/, "_")
|
|> String.downcase()
|
||||||
|> String.trim("_")
|
|> String.replace(~r/[^a-z0-9]+/, "_")
|
||||||
else
|
|> String.trim("_")
|
||||||
# For table entries, prepend sensor class to OID index
|
|
||||||
"#{sensor_class}_#{oid_index}"
|
# For simple numeric indices, prepend sensor class
|
||||||
|
true ->
|
||||||
|
"#{sensor_class}_#{oid_index}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
|
||||||
board_name: "1.3.6.1.4.1.14988.1.1.7.9.0",
|
board_name: "1.3.6.1.4.1.14988.1.1.7.9.0",
|
||||||
display_name: "1.3.6.1.4.1.14988.1.1.7.8.0",
|
display_name: "1.3.6.1.4.1.14988.1.1.7.8.0",
|
||||||
build_time: "1.3.6.1.4.1.14988.1.1.7.6.0",
|
build_time: "1.3.6.1.4.1.14988.1.1.7.6.0",
|
||||||
license_version: "1.3.6.1.4.1.14988.1.1.4.4.0",
|
license_level: "1.3.6.1.4.1.14988.1.1.7.5.0",
|
||||||
|
|
||||||
# Health - Voltages (mtxrHealth)
|
# Health - Voltages (mtxrHealth)
|
||||||
core_voltage: "1.3.6.1.4.1.14988.1.1.3.1.0",
|
core_voltage: "1.3.6.1.4.1.14988.1.1.3.1.0",
|
||||||
|
|
@ -126,15 +126,30 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
|
||||||
model =
|
model =
|
||||||
case Regex.run(~r/RouterOS\s+([A-Z0-9\+\-]+)/, sys_descr) do
|
case Regex.run(~r/RouterOS\s+([A-Z0-9\+\-]+)/, sys_descr) do
|
||||||
[_, model] -> model
|
[_, model] -> model
|
||||||
_ -> "RouterOS Device"
|
_ -> "MikroTik RouterOS"
|
||||||
end
|
end
|
||||||
|
|
||||||
firmware = Map.get(system_info, :firmware_version) || extract_firmware_from_sysdescr(sys_descr)
|
# Build formatted firmware version like LibreNMS: "Mikrotik RouterOS 7.20.6 (Level 6)"
|
||||||
|
firmware_version = Map.get(system_info, :firmware_version)
|
||||||
|
license_level = Map.get(system_info, :license_version)
|
||||||
|
|
||||||
|
formatted_firmware =
|
||||||
|
cond do
|
||||||
|
firmware_version && license_level ->
|
||||||
|
"Mikrotik RouterOS #{firmware_version} (Level #{license_level})"
|
||||||
|
|
||||||
|
firmware_version ->
|
||||||
|
"Mikrotik RouterOS #{firmware_version}"
|
||||||
|
|
||||||
|
true ->
|
||||||
|
# Fallback to extracting from sysDescr
|
||||||
|
extract_firmware_from_sysdescr(sys_descr)
|
||||||
|
end
|
||||||
|
|
||||||
Map.merge(system_info, %{
|
Map.merge(system_info, %{
|
||||||
manufacturer: "MikroTik",
|
manufacturer: "MikroTik",
|
||||||
model: model,
|
model: model,
|
||||||
firmware_version: firmware
|
firmware_version: formatted_firmware
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -146,18 +161,18 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
|
||||||
@mikrotik_oids.firmware_version,
|
@mikrotik_oids.firmware_version,
|
||||||
@mikrotik_oids.board_name,
|
@mikrotik_oids.board_name,
|
||||||
@mikrotik_oids.display_name,
|
@mikrotik_oids.display_name,
|
||||||
@mikrotik_oids.license_version
|
@mikrotik_oids.license_level
|
||||||
]
|
]
|
||||||
|
|
||||||
case Client.get_multiple(client_opts, oids) do
|
case Client.get_multiple(client_opts, oids) do
|
||||||
{:ok, [serial, firmware, board, display, license]} ->
|
{:ok, [serial, firmware, board, display, license_level]} ->
|
||||||
{:ok,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
serial_number: serial,
|
serial_number: serial,
|
||||||
firmware_version: firmware,
|
firmware_version: firmware,
|
||||||
board_name: board,
|
board_name: board,
|
||||||
display_name: display,
|
display_name: display,
|
||||||
license_version: license
|
license_version: license_level
|
||||||
}}
|
}}
|
||||||
|
|
||||||
{:error, _} ->
|
{:error, _} ->
|
||||||
|
|
|
||||||
|
|
@ -39,24 +39,45 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(%{"id" => id} = params, _, socket) do
|
def handle_params(%{"id" => id} = params, _, socket) do
|
||||||
_ =
|
# Check if device exists before setting up subscriptions
|
||||||
if connected?(socket) do
|
case Devices.get_device(id) do
|
||||||
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{id}")
|
nil ->
|
||||||
Process.send_after(self(), :refresh_data, 10_000)
|
{:noreply,
|
||||||
end
|
socket
|
||||||
|
|> put_flash(:error, "Device not found")
|
||||||
|
|> push_navigate(to: ~p"/devices")}
|
||||||
|
|
||||||
tab = Map.get(params, "tab", "overview")
|
_device ->
|
||||||
|
_ =
|
||||||
|
if connected?(socket) do
|
||||||
|
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{id}")
|
||||||
|
Process.send_after(self(), :refresh_data, 10_000)
|
||||||
|
end
|
||||||
|
|
||||||
socket
|
tab = Map.get(params, "tab", "overview")
|
||||||
|> load_equipment_data(id)
|
|
||||||
|> assign(:active_tab, tab)
|
socket
|
||||||
|> then(&{:noreply, &1})
|
|> load_equipment_data(id)
|
||||||
|
|> assign(:active_tab, tab)
|
||||||
|
|> then(&{:noreply, &1})
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info(:refresh_data, socket) do
|
def handle_info(:refresh_data, socket) do
|
||||||
Process.send_after(self(), :refresh_data, 10_000)
|
# Check if device still exists before scheduling next refresh
|
||||||
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
case Devices.get_device(socket.assigns.device.id) do
|
||||||
|
nil ->
|
||||||
|
# Device was deleted, don't schedule another refresh
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "Device no longer exists")
|
||||||
|
|> push_navigate(to: ~p"/devices")}
|
||||||
|
|
||||||
|
_device ->
|
||||||
|
Process.send_after(self(), :refresh_data, 10_000)
|
||||||
|
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -113,13 +134,24 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
||||||
storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"])
|
storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"])
|
||||||
traffic_chart_data = load_overall_traffic_chart_data(device_id)
|
traffic_chart_data = load_overall_traffic_chart_data(device_id)
|
||||||
|
|
||||||
# Filter sensors by type for temperature, voltage, and storage
|
# Filter sensors by type for temperature, voltage, storage, count, and transceivers
|
||||||
|
# Separate transceiver sensors from general sensors
|
||||||
|
{transceiver_sensors, non_transceiver_sensors} =
|
||||||
|
Enum.split_with(snmp_data.sensors, fn sensor ->
|
||||||
|
sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "sfp")
|
||||||
|
end)
|
||||||
|
|
||||||
temperature_sensors =
|
temperature_sensors =
|
||||||
Enum.filter(snmp_data.sensors, &(&1.sensor_type in ["temperature", "cpu_temperature", "celsius"]))
|
Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["temperature", "cpu_temperature", "celsius"]))
|
||||||
|
|
||||||
voltage_sensors = Enum.filter(snmp_data.sensors, &(&1.sensor_type in ["voltage", "volts"]))
|
voltage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["voltage", "volts"]))
|
||||||
|
|
||||||
storage_sensors = Enum.filter(snmp_data.sensors, &(&1.sensor_type in ["disk_usage"]))
|
storage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["disk_usage"]))
|
||||||
|
|
||||||
|
count_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type == "count"))
|
||||||
|
|
||||||
|
# Group transceiver sensors by transceiver name
|
||||||
|
grouped_transceivers = group_transceiver_sensors(transceiver_sensors)
|
||||||
|
|
||||||
# Calculate metrics for dashboard
|
# Calculate metrics for dashboard
|
||||||
metrics = calculate_metrics(recent_checks, device)
|
metrics = calculate_metrics(recent_checks, device)
|
||||||
|
|
@ -146,6 +178,8 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
||||||
|> assign(:temperature_sensors, temperature_sensors)
|
|> assign(:temperature_sensors, temperature_sensors)
|
||||||
|> assign(:voltage_sensors, voltage_sensors)
|
|> assign(:voltage_sensors, voltage_sensors)
|
||||||
|> assign(:storage_sensors, storage_sensors)
|
|> assign(:storage_sensors, storage_sensors)
|
||||||
|
|> assign(:count_sensors, count_sensors)
|
||||||
|
|> assign(:grouped_transceivers, grouped_transceivers)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp calculate_metrics(checks, _equipment) do
|
defp calculate_metrics(checks, _equipment) do
|
||||||
|
|
@ -481,4 +515,31 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
||||||
defp interface_type_order(category) do
|
defp interface_type_order(category) do
|
||||||
Map.get(@interface_category_order, category, 99)
|
Map.get(@interface_category_order, category, 99)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Group transceiver sensors by transceiver name
|
||||||
|
# Returns list of {name, sensors} tuples sorted by name
|
||||||
|
defp group_transceiver_sensors(transceiver_sensors) do
|
||||||
|
transceiver_sensors
|
||||||
|
|> Enum.group_by(&extract_transceiver_name/1)
|
||||||
|
|> Enum.sort_by(fn {name, _sensors} -> name end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extract transceiver name from sensor description
|
||||||
|
# Examples:
|
||||||
|
# "sfp-sfpplus1 Rx Power" -> "sfp-sfpplus1"
|
||||||
|
# "sfp-sfpplus2 Temperature" -> "sfp-sfpplus2"
|
||||||
|
# "SFP1 Voltage" -> "SFP1"
|
||||||
|
defp extract_transceiver_name(sensor) do
|
||||||
|
case sensor.sensor_descr do
|
||||||
|
nil ->
|
||||||
|
"unknown"
|
||||||
|
|
||||||
|
descr ->
|
||||||
|
# Extract the first word (transceiver identifier)
|
||||||
|
descr
|
||||||
|
|> String.split(" ")
|
||||||
|
|> List.first()
|
||||||
|
|> String.downcase()
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -433,6 +433,76 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<!-- SFP Transceivers -->
|
||||||
|
<%= if @grouped_transceivers && length(@grouped_transceivers) > 0 do %>
|
||||||
|
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||||
|
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
||||||
|
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
Transceivers
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<dl class="space-y-1">
|
||||||
|
<%= for {_transceiver_name, sensors} <- @grouped_transceivers do %>
|
||||||
|
<%= for sensor <- sensors do %>
|
||||||
|
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||||
|
<dt class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
<.link
|
||||||
|
navigate={~p"/devices/#{@device.id}/graph/dbm"}
|
||||||
|
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
{sensor.sensor_descr}
|
||||||
|
</.link>
|
||||||
|
</dt>
|
||||||
|
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100 font-mono">
|
||||||
|
<%= if sensor.latest_reading do %>
|
||||||
|
{format_sensor_value(
|
||||||
|
sensor.latest_reading.value,
|
||||||
|
sensor.sensor_divisor
|
||||||
|
)}
|
||||||
|
{sensor.sensor_unit}
|
||||||
|
<% else %>
|
||||||
|
N/A
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<!-- Count Sensors (DHCP Leases, Connections, etc.) -->
|
||||||
|
<%= if @count_sensors && length(@count_sensors) > 0 do %>
|
||||||
|
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||||
|
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
||||||
|
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Counters</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<dl class="space-y-1">
|
||||||
|
<%= for sensor <- @count_sensors do %>
|
||||||
|
<div class="flex justify-between py-1.5 border-b border-zinc-100 dark:border-zinc-800">
|
||||||
|
<dt class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
<.link
|
||||||
|
navigate={~p"/devices/#{@device.id}/graph/count"}
|
||||||
|
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
{sensor.sensor_descr}
|
||||||
|
</.link>
|
||||||
|
</dt>
|
||||||
|
<dd class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
|
<%= if sensor.latest_reading do %>
|
||||||
|
{trunc(sensor.latest_reading.value)}
|
||||||
|
<% else %>
|
||||||
|
N/A
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% "ports" -> %>
|
<% "ports" -> %>
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,12 @@ defmodule Towerops.Monitoring.PingTest do
|
||||||
test "returns error for unreachable host" do
|
test "returns error for unreachable host" do
|
||||||
# Use an address that should be unreachable
|
# Use an address that should be unreachable
|
||||||
# 192.0.2.0/24 is TEST-NET-1, reserved for documentation
|
# 192.0.2.0/24 is TEST-NET-1, reserved for documentation
|
||||||
assert {:error, :timeout_or_unreachable} = Ping.ping("192.0.2.1", 1000)
|
assert {:error, _reason} = Ping.ping("192.0.2.1", 1000)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns error for invalid IP address" do
|
test "returns error for invalid IP address" do
|
||||||
# Invalid IP address should fail
|
# Invalid IP address should fail
|
||||||
assert {:error, :timeout_or_unreachable} = Ping.ping("999.999.999.999", 1000)
|
assert {:error, _reason} = Ping.ping("999.999.999.999", 1000)
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :integration
|
@tag :integration
|
||||||
|
|
|
||||||
|
|
@ -137,8 +137,8 @@ defmodule Towerops.Monitoring.SupervisorTest do
|
||||||
MonitoringSupervisor.start_all_monitors()
|
MonitoringSupervisor.start_all_monitors()
|
||||||
|
|
||||||
# Check that both are running
|
# Check that both are running
|
||||||
assert [{pid1, _}] = Registry.lookup(Towerops.Monitoring.Registry, device.id)
|
assert [{pid1, _}] = Horde.Registry.lookup(Towerops.Monitoring.Registry, device.id)
|
||||||
assert [{pid2, _}] = Registry.lookup(Towerops.Monitoring.Registry, device2.id)
|
assert [{pid2, _}] = Horde.Registry.lookup(Towerops.Monitoring.Registry, device2.id)
|
||||||
assert Process.alive?(pid1)
|
assert Process.alive?(pid1)
|
||||||
assert Process.alive?(pid2)
|
assert Process.alive?(pid2)
|
||||||
|
|
||||||
|
|
@ -171,8 +171,8 @@ defmodule Towerops.Monitoring.SupervisorTest do
|
||||||
MonitoringSupervisor.start_all_snmp_pollers()
|
MonitoringSupervisor.start_all_snmp_pollers()
|
||||||
|
|
||||||
# Check that both are running
|
# Check that both are running
|
||||||
assert [{pid1, _}] = Registry.lookup(PollerRegistry, device.id)
|
assert [{pid1, _}] = Horde.Registry.lookup(PollerRegistry, device.id)
|
||||||
assert [{pid2, _}] = Registry.lookup(PollerRegistry, device2.id)
|
assert [{pid2, _}] = Horde.Registry.lookup(PollerRegistry, device2.id)
|
||||||
assert Process.alive?(pid1)
|
assert Process.alive?(pid1)
|
||||||
assert Process.alive?(pid2)
|
assert Process.alive?(pid2)
|
||||||
|
|
||||||
|
|
|
||||||
76
test/towerops/snmp/profiles/dynamic_test.exs
Normal file
76
test/towerops/snmp/profiles/dynamic_test.exs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
defmodule Towerops.Snmp.Profiles.DynamicTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Towerops.Snmp.Profiles.Dynamic
|
||||||
|
|
||||||
|
describe "description OID derivation" do
|
||||||
|
test "derives correct OID for mtxrGaugeName" do
|
||||||
|
value_oid = "1.3.6.1.4.1.14988.1.1.3.100.1.3.17"
|
||||||
|
descr = "MIKROTIK-MIB::mtxrGaugeName"
|
||||||
|
|
||||||
|
# Call the private function via module attribute trick
|
||||||
|
descr_oid = derive_descr_oid_wrapper(value_oid, descr)
|
||||||
|
|
||||||
|
assert descr_oid == "1.3.6.1.4.1.14988.1.1.3.100.1.1.17"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "derives correct OID for mtxrOpticalName" do
|
||||||
|
value_oid = "1.3.6.1.4.1.14988.1.1.19.1.1.8.17"
|
||||||
|
descr = "MIKROTIK-MIB::mtxrOpticalName"
|
||||||
|
|
||||||
|
descr_oid = derive_descr_oid_wrapper(value_oid, descr)
|
||||||
|
|
||||||
|
assert descr_oid == "1.3.6.1.4.1.14988.1.1.19.1.1.2.17"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "derives correct OID for mtxrPOEName" do
|
||||||
|
value_oid = "1.3.6.1.4.1.14988.1.1.3.15.1.1.5.3"
|
||||||
|
descr = "MIKROTIK-MIB::mtxrPOEName"
|
||||||
|
|
||||||
|
descr_oid = derive_descr_oid_wrapper(value_oid, descr)
|
||||||
|
|
||||||
|
assert descr_oid == "1.3.6.1.4.1.14988.1.1.3.15.1.1.1.3"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles multiple indices correctly" do
|
||||||
|
# Test with different index values
|
||||||
|
value_oid_1 = "1.3.6.1.4.1.14988.1.1.3.100.1.3.7101"
|
||||||
|
value_oid_2 = "1.3.6.1.4.1.14988.1.1.3.100.1.3.7202"
|
||||||
|
descr = "MIKROTIK-MIB::mtxrGaugeName"
|
||||||
|
|
||||||
|
descr_oid_1 = derive_descr_oid_wrapper(value_oid_1, descr)
|
||||||
|
descr_oid_2 = derive_descr_oid_wrapper(value_oid_2, descr)
|
||||||
|
|
||||||
|
assert descr_oid_1 == "1.3.6.1.4.1.14988.1.1.3.100.1.1.7101"
|
||||||
|
assert descr_oid_2 == "1.3.6.1.4.1.14988.1.1.3.100.1.1.7202"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "falls back to generic derivation for unknown MIBs" do
|
||||||
|
# Generic SNMP table: .1.3.6.1.4.1.12345.1.2.3.5
|
||||||
|
# Should try to replace column 3 with column 1
|
||||||
|
value_oid = "1.3.6.1.4.1.12345.1.2.3.5"
|
||||||
|
descr = "VENDOR-MIB::someName"
|
||||||
|
|
||||||
|
descr_oid = derive_descr_oid_wrapper(value_oid, descr)
|
||||||
|
|
||||||
|
# Generic derivation replaces second-to-last number with 1
|
||||||
|
assert descr_oid == "1.3.6.1.4.1.12345.1.2.1.5"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles OIDs without index (base walk OIDs)" do
|
||||||
|
# Base walk OID without index
|
||||||
|
value_oid = "1.3.6.1.4.1.14988.1.1.3.100.1.3"
|
||||||
|
descr = "MIKROTIK-MIB::mtxrGaugeName"
|
||||||
|
|
||||||
|
descr_oid = derive_descr_oid_wrapper(value_oid, descr)
|
||||||
|
|
||||||
|
# Should replace column 3 with column 1, preserving the end of string
|
||||||
|
assert descr_oid == "1.3.6.1.4.1.14988.1.1.3.100.1.1"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to call public function (made public for testing)
|
||||||
|
defp derive_descr_oid_wrapper(value_oid, descr) do
|
||||||
|
Dynamic.derive_descr_oid(value_oid, descr)
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue