snmp overhaul

This commit is contained in:
Graham McIntire 2026-01-18 16:00:01 -06:00
parent 94311a48aa
commit adec4b134f
No known key found for this signature in database
20 changed files with 931 additions and 97 deletions

View file

@ -81,7 +81,7 @@ FROM ${RUNNER_IMAGE} AS final
RUN apt-get update \
&& 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/*
# Set the locale
@ -101,6 +101,10 @@ ENV MIX_ENV="prod"
# Only copy the final release from the build stage
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
# If using an environment that doesn't automatically reap zombie processes, it is

View file

@ -100,5 +100,8 @@ config :towerops, :redis,
host: "localhost",
port: 6379
# Configure SNMP polling interval for development (60 seconds)
config :towerops, :snmp_min_poll_interval, 60
# Enable dev routes for dashboard and mailbox
config :towerops, dev_routes: true

View file

@ -124,3 +124,11 @@ spec:
timeoutSeconds: 2
successThreshold: 1
failureThreshold: 2
volumeMounts:
- name: mibs
mountPath: /app/mibs
readOnly: false
volumes:
- name: mibs
persistentVolumeClaim:
claimName: towerops-mibs

View file

@ -3,6 +3,7 @@ kind: Kustomization
namespace: towerops
resources:
- namespace.yaml
- mib-pvc.yaml
- deployment.yaml
- service.yaml
- service-headless.yaml

13
k8s/mib-pvc.yaml Normal file
View 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)

View file

@ -195,12 +195,30 @@ defmodule Towerops.DeviceProfiles do
end
defp list_profiles_with_detection_rules do
Repo.all(
from p in DeviceProfile,
where: p.enabled == true,
order_by: [asc: p.priority],
preload: [:detection_rules]
)
# Load all enabled profiles with detection rules
profiles =
Repo.all(
from p in DeviceProfile,
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
defp match_detection_rules?(rules, system_info) do

View file

@ -334,6 +334,10 @@ defmodule Towerops.DeviceProfiles.Importer do
has_valid_oid = present?(sensor["oid"]) || present?(sensor["num_oid"])
if has_valid_oid do
# Calculate divisor with validation
raw_divisor = sensor["divisor"] || calculate_divisor(sensor["precision"])
divisor = validate_divisor(raw_divisor)
attrs = %{
device_profile_id: profile_id,
sensor_class: sensor_class,
@ -341,7 +345,7 @@ defmodule Towerops.DeviceProfiles.Importer do
num_oid: nilify_blank(sensor["num_oid"]),
index: to_string(sensor["index"] || ""),
descr: sensor["descr"],
divisor: calculate_divisor(sensor["precision"]),
divisor: divisor,
precision: sensor["precision"],
sensor_type: sensor["type"],
low_limit: parse_float(sensor["low_limit"]),
@ -426,6 +430,32 @@ defmodule Towerops.DeviceProfiles.Importer do
defp present?(""), do: false
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
# Handles:
# - Empty strings -> nil

View file

@ -107,7 +107,19 @@ defmodule Towerops.Devices do
end
@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
DeviceSchema

View file

@ -372,7 +372,11 @@ defmodule Towerops.Snmp.Discovery do
@spec insert_sensors(Device.t(), [sensor_data()]) :: [Sensor.t()]
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.changeset(Map.put(sensor_data, :snmp_device_id, device.id))
|> Repo.insert!()

View 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

View file

@ -54,7 +54,8 @@ defmodule Towerops.Snmp.Poller do
ip: device.ip_address,
community: device.snmp_community,
version: device.snmp_version,
port: device.snmp_port || 161
port: device.snmp_port || 161,
timeout: 5000
]
end
end

View file

@ -226,6 +226,7 @@ defmodule Towerops.Snmp.PollerWorker do
defp poll_sensors(sensors, client_opts, timestamp) do
# 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
|> Task.async_stream(
fn sensor ->
@ -233,7 +234,7 @@ defmodule Towerops.Snmp.PollerWorker do
handle_sensor_poll_result(sensor, result, timestamp)
end,
max_concurrency: 2,
timeout: 10_000,
timeout: 40_000,
on_timeout: :kill_task
)
|> Stream.run()
@ -358,6 +359,7 @@ defmodule Towerops.Snmp.PollerWorker do
defp poll_interfaces(interfaces, client_opts, timestamp) do
# Poll interfaces in parallel with max_concurrency
# Timeout must be longer than SNMP timeout (30s) to allow slow devices
interfaces
|> Task.async_stream(
fn interface ->
@ -373,7 +375,7 @@ defmodule Towerops.Snmp.PollerWorker do
Snmp.create_interface_stat(stats)
end,
max_concurrency: 2,
timeout: 10_000,
timeout: 40_000,
on_timeout: :kill_task
)
|> Stream.run()
@ -381,6 +383,7 @@ defmodule Towerops.Snmp.PollerWorker do
defp check_interface_changes(interfaces, device, client_opts, timestamp) do
# Check interface changes in parallel
# Timeout must be longer than SNMP timeout (30s) to allow slow devices
interfaces
|> Task.async_stream(
fn interface ->
@ -393,7 +396,7 @@ defmodule Towerops.Snmp.PollerWorker do
end
end,
max_concurrency: 2,
timeout: 10_000,
timeout: 40_000,
on_timeout: :kill_task
)
|> Stream.run()
@ -725,8 +728,9 @@ defmodule Towerops.Snmp.PollerWorker do
end
defp get_poll_interval(device) do
# Use check_interval_seconds, but minimum of 30 seconds for SNMP polling
max(device.check_interval_seconds || @default_poll_interval, 30)
# Use check_interval_seconds, with configurable minimum for SNMP polling
min_interval = Application.get_env(:towerops, :snmp_min_poll_interval, 300)
max(device.check_interval_seconds || @default_poll_interval, min_interval)
end
defp schedule_next_poll(interval_seconds) do

View file

@ -86,12 +86,13 @@ defmodule Towerops.Snmp.Profiles.Base do
indices = if_indices |> Map.values() |> Enum.map(&parse_integer/1)
# Fetch all interface data in parallel
# Timeout must be longer than SNMP timeout (30s) to allow slow devices
interface_data =
indices
|> Task.async_stream(
fn index -> fetch_interface_data(client_opts, index) end,
max_concurrency: 10,
timeout: 10_000
timeout: 40_000
)
|> Enum.map(fn
{:ok, {:ok, interface}} -> interface

View file

@ -18,24 +18,34 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
Identifies device using the database profile information.
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()) ::
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
profile = DeviceProfiles.get_profile_with_associations(profile.os)
# Poll OS definitions for version, serial, etc.
os_data = poll_os_definitions(profile, client_opts)
# Prefer hardcoded vendor profiles when available for accurate identification
# 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
%{
manufacturer: extract_manufacturer(profile),
model: profile.text || profile.os,
firmware_version: os_data[:version] || profile.os,
serial_number: os_data[:serial]
}
:no_vendor_profile ->
# Fall back to database-driven identification with MIB translation
Logger.debug("Using database profile with MIB translation for device identification")
os_data = poll_os_definitions(profile, client_opts)
%{
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
@doc """
@ -88,13 +98,59 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
# Private functions
defp poll_os_definitions(_profile, _client_opts) do
# Note: OS definition polling is currently disabled because device profiles
# use MIB symbolic names (e.g., CAMBIUM-PMP80211-MIB::cambiumCurrentuImageVersion.0)
# which require MIB compilation to resolve to numeric OIDs.
# The SNMP client only supports numeric OIDs.
# Future enhancement: Add MIB resolution or import numeric OIDs during profile import
%{}
# Delegate device identification to hardcoded vendor profiles when available
# This allows us to use vendor-specific logic with numeric OIDs for proper identification
defp delegate_to_vendor_profile(profile, _client_opts, system_info) do
case profile.group do
"mikrotik" ->
# 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
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
if oid do
# Remove template variables from OID
walk_oid = clean_oid_template(oid)
walk_processor_oid(client_opts, walk_oid, proc_def)
# Skip symbolic MIB names (contain "::") - only numeric OIDs can be polled
if String.contains?(oid, "::") do
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
Logger.warning("Processor definition missing OID: #{inspect(proc_def)}")
[]
@ -157,10 +219,20 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
end
if oid_string do
# 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)
walk_sensor_oid(client_opts, walk_oid, sensor_def)
# Skip symbolic MIB names (contain "::") - only numeric OIDs can be polled
if String.contains?(oid_string, "::") do
Logger.debug("Skipping symbolic MIB name: #{oid_string}")
[]
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
Logger.warning("Sensor definition has invalid OID format: #{inspect(oid)}")
[]
@ -171,12 +243,30 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
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
{:ok, results} when is_map(results) and map_size(results) > 0 ->
# Convert walk results (map) to sensor data
Enum.map(results, fn {result_oid, value} ->
build_sensor_data(sensor_def, %{oid: result_oid, value: value})
# Get skip_values filtering rules from sensor definition options
skip_values = get_skip_values(sensor_def)
# 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)
{:ok, _empty} ->
@ -189,6 +279,312 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
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
# Examples:
# ".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(".")
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)
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
# 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
last_value =
@ -224,7 +641,7 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
sensor_type: sensor_def.sensor_class,
sensor_index: sensor_index,
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_divisor: sensor_def.divisor || 1,
last_value: last_value,
@ -277,16 +694,26 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
end
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
if configured_index == "" or String.contains?(configured_index, "{{") do
# Extract last component of OID as index
# Extract last component of OID
oid_index =
oid
|> String.split(".")
|> List.last()
|> to_string()
else
# Use pre-configured static index
configured_index
cond do
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
@ -299,19 +726,21 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
end
defp build_unique_sensor_index(sensor_class, descr, oid_index) do
# For scalar sensors (index 0), use a descriptive name from the description
# For table sensors, use the OID index
if oid_index == "0" && descr do
# Convert description to a safe index string
# E.g., "DFS Detected Count" -> "dfs_detected_count"
# E.g., "GPS Status" -> "gps_status"
descr
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/, "_")
|> String.trim("_")
else
# For table entries, prepend sensor class to OID index
"#{sensor_class}_#{oid_index}"
cond do
# If oid_index already has a dot (e.g., "mtxrGaugeCurrent.17"), use it as-is
String.contains?(oid_index, ".") ->
oid_index
# For scalar sensors (index 0), use a descriptive name from the description
oid_index == "0" && descr ->
descr
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/, "_")
|> String.trim("_")
# For simple numeric indices, prepend sensor class
true ->
"#{sensor_class}_#{oid_index}"
end
end

View file

@ -25,7 +25,7 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
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",
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)
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 =
case Regex.run(~r/RouterOS\s+([A-Z0-9\+\-]+)/, sys_descr) do
[_, model] -> model
_ -> "RouterOS Device"
_ -> "MikroTik RouterOS"
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, %{
manufacturer: "MikroTik",
model: model,
firmware_version: firmware
firmware_version: formatted_firmware
})
end
@ -146,18 +161,18 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
@mikrotik_oids.firmware_version,
@mikrotik_oids.board_name,
@mikrotik_oids.display_name,
@mikrotik_oids.license_version
@mikrotik_oids.license_level
]
case Client.get_multiple(client_opts, oids) do
{:ok, [serial, firmware, board, display, license]} ->
{:ok, [serial, firmware, board, display, license_level]} ->
{:ok,
%{
serial_number: serial,
firmware_version: firmware,
board_name: board,
display_name: display,
license_version: license
license_version: license_level
}}
{:error, _} ->

View file

@ -39,24 +39,45 @@ defmodule ToweropsWeb.DeviceLive.Show do
@impl true
def handle_params(%{"id" => id} = params, _, socket) do
_ =
if connected?(socket) do
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{id}")
Process.send_after(self(), :refresh_data, 10_000)
end
# Check if device exists before setting up subscriptions
case Devices.get_device(id) do
nil ->
{:noreply,
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
|> load_equipment_data(id)
|> assign(:active_tab, tab)
|> then(&{:noreply, &1})
tab = Map.get(params, "tab", "overview")
socket
|> load_equipment_data(id)
|> assign(:active_tab, tab)
|> then(&{:noreply, &1})
end
end
@impl true
def handle_info(:refresh_data, socket) do
Process.send_after(self(), :refresh_data, 10_000)
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
# Check if device still exists before scheduling next refresh
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
@impl true
@ -113,13 +134,24 @@ defmodule ToweropsWeb.DeviceLive.Show do
storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"])
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 =
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
metrics = calculate_metrics(recent_checks, device)
@ -146,6 +178,8 @@ defmodule ToweropsWeb.DeviceLive.Show do
|> assign(:temperature_sensors, temperature_sensors)
|> assign(:voltage_sensors, voltage_sensors)
|> assign(:storage_sensors, storage_sensors)
|> assign(:count_sensors, count_sensors)
|> assign(:grouped_transceivers, grouped_transceivers)
end
defp calculate_metrics(checks, _equipment) do
@ -481,4 +515,31 @@ defmodule ToweropsWeb.DeviceLive.Show do
defp interface_type_order(category) do
Map.get(@interface_category_order, category, 99)
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

View file

@ -433,6 +433,76 @@
</div>
</div>
<% 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>
<% "ports" -> %>

View file

@ -25,12 +25,12 @@ defmodule Towerops.Monitoring.PingTest do
test "returns error for unreachable host" do
# Use an address that should be unreachable
# 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
test "returns error for invalid IP address" do
# 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
@tag :integration

View file

@ -137,8 +137,8 @@ defmodule Towerops.Monitoring.SupervisorTest do
MonitoringSupervisor.start_all_monitors()
# Check that both are running
assert [{pid1, _}] = Registry.lookup(Towerops.Monitoring.Registry, device.id)
assert [{pid2, _}] = Registry.lookup(Towerops.Monitoring.Registry, device2.id)
assert [{pid1, _}] = Horde.Registry.lookup(Towerops.Monitoring.Registry, device.id)
assert [{pid2, _}] = Horde.Registry.lookup(Towerops.Monitoring.Registry, device2.id)
assert Process.alive?(pid1)
assert Process.alive?(pid2)
@ -171,8 +171,8 @@ defmodule Towerops.Monitoring.SupervisorTest do
MonitoringSupervisor.start_all_snmp_pollers()
# Check that both are running
assert [{pid1, _}] = Registry.lookup(PollerRegistry, device.id)
assert [{pid2, _}] = Registry.lookup(PollerRegistry, device2.id)
assert [{pid1, _}] = Horde.Registry.lookup(PollerRegistry, device.id)
assert [{pid2, _}] = Horde.Registry.lookup(PollerRegistry, device2.id)
assert Process.alive?(pid1)
assert Process.alive?(pid2)

View 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