test improvements

This commit is contained in:
Graham McIntire 2026-01-19 12:08:48 -06:00
parent 49bc320a89
commit fe7a44e5e2
No known key found for this signature in database
17 changed files with 973 additions and 214 deletions

View file

@ -190,28 +190,31 @@ defmodule Towerops.Devices do
end
defp resolve_snmp_config(device) do
cond do
device.snmp_community != nil ->
build_snmp_config(device.snmp_version, device.snmp_community, :device)
# Resolve community and version independently with hierarchical fallback
# This allows setting version at device level while inheriting community from org
community =
device.snmp_community ||
device.site.snmp_community ||
device.site.organization.snmp_community
device.site.snmp_community != nil ->
build_snmp_config(device.site.snmp_version, device.site.snmp_community, :site)
version =
device.snmp_version ||
device.site.snmp_version ||
device.site.organization.snmp_version ||
"2c"
device.site.organization.snmp_community != nil ->
build_snmp_config(
device.site.organization.snmp_version,
device.site.organization.snmp_community,
:organization
)
# Determine source based on where community string came from (primary credential)
# If no community anywhere, source is :default
source =
cond do
device.snmp_community != nil -> :device
device.site.snmp_community != nil -> :site
device.site.organization.snmp_community != nil -> :organization
true -> :default
end
true ->
%{version: "2c", community: nil, source: :default}
end
end
defp build_snmp_config(version, community, source) do
%{
version: version || "2c",
version: version,
community: community,
source: source
}

View file

@ -27,6 +27,8 @@ defmodule Towerops.Snmp.Device do
field :model, :string
field :firmware_version, :string
field :serial_number, :string
field :raw_discovery_data, :map
field :last_discovery_at, :utc_datetime
belongs_to :device, DeviceSchema
@ -48,6 +50,8 @@ defmodule Towerops.Snmp.Device do
model: String.t() | nil,
firmware_version: String.t() | nil,
serial_number: String.t() | nil,
raw_discovery_data: map() | nil,
last_discovery_at: DateTime.t() | nil,
device_id: Ecto.UUID.t(),
device: NotLoaded.t() | Devices.t(),
interfaces: NotLoaded.t() | [Interface.t()],
@ -70,7 +74,9 @@ defmodule Towerops.Snmp.Device do
:manufacturer,
:model,
:firmware_version,
:serial_number
:serial_number,
:raw_discovery_data,
:last_discovery_at
])
|> validate_required([:device_id])
|> unique_constraint(:device_id)

View file

@ -117,6 +117,13 @@ defmodule Towerops.Snmp.Discovery do
_ = update_device_discovery_time(device)
Logger.info("SNMP discovery completed successfully for: #{device.name}")
# Determine if this was first discovery or rediscovery
# Check if device existed before (had last_discovery_at set)
is_rediscovery = device.last_discovery_at != nil
# Log discovery event
log_discovery_event(device, discovered_device, is_rediscovery)
# Broadcast discovery completion for real-time updates
_ =
Phoenix.PubSub.broadcast(
@ -236,7 +243,8 @@ defmodule Towerops.Snmp.Discovery do
Cisco
# Ubiquiti devices (check before generic Linux since they run Linux)
String.contains?(sys_object_id, "41112") or
# OID 10002 = old Ubiquiti/UniFi devices, 41112 = newer airOS devices
String.contains?(sys_object_id, ["10002", "41112"]) or
String.contains?(sys_descr, ["airOS", "AirFiber", "airMAX", "Ubiquiti"]) ->
Logger.debug("Selected Ubiquiti profile")
Ubiquiti
@ -263,14 +271,53 @@ defmodule Towerops.Snmp.Discovery do
{:ok, system_info}
end
defp build_device_info(_client_opts, system_info, profile) when is_atom(profile) do
defp build_device_info(client_opts, system_info, profile) when is_atom(profile) do
Logger.info(
"build_device_info: system_info has _raw_discovery_data: #{inspect(Map.has_key?(system_info, :_raw_discovery_data))}"
)
# Get profile-specific system info if profile has discover_system_info/1
enriched_system_info =
if function_exported?(profile, :discover_system_info, 1) do
case profile.discover_system_info(client_opts) do
{:ok, profile_info} ->
Logger.info("Enriched system_info with profile-specific data")
Map.merge(system_info, profile_info)
{:error, _reason} ->
Logger.warning("Failed to get profile-specific system info, using base info")
system_info
end
else
system_info
end
# Let the hard-coded profile identify the device
identified_info = profile.identify_device(system_info)
{:ok, identified_info}
identified_info = profile.identify_device(enriched_system_info)
# Collect raw debug data if profile supports it
identified_info_with_debug =
if function_exported?(profile, :collect_raw_debug_data, 1) do
raw_data = profile.collect_raw_debug_data(client_opts)
Logger.info("Collected raw debug data: #{map_size(raw_data)} keys")
identified_info
|> Map.put(:_raw_discovery_data, raw_data)
|> Map.put(:_last_discovery_at, DateTime.utc_now())
else
identified_info
end
Logger.info(
"build_device_info: identified_info has _raw_discovery_data: #{inspect(Map.has_key?(identified_info_with_debug, :_raw_discovery_data))}"
)
{:ok, identified_info_with_debug}
rescue
_error ->
Logger.error("Failed to identify device")
{:ok, system_info}
Logger.error("Failed to identify device with profile, falling back to Base profile")
# Fall back to Base profile identification to ensure manufacturer/model are set
{:ok, Base.identify_device(system_info)}
end
@spec discover_interfaces(Client.connection_opts(), profile()) ::
@ -342,15 +389,34 @@ defmodule Towerops.Snmp.Discovery do
@spec upsert_device(DeviceSchema.t(), device_info()) :: Device.t()
defp upsert_device(device, device_info) do
Logger.info("upsert_device called with device_info keys: #{inspect(Map.keys(device_info))}")
# Extract raw discovery data if present (using underscore prefix to distinguish from regular fields)
raw_discovery_data = Map.get(device_info, :_raw_discovery_data)
last_discovery_at = Map.get(device_info, :_last_discovery_at, DateTime.utc_now())
Logger.info("Extracted raw_discovery_data: #{inspect(raw_discovery_data != nil)}")
# Remove internal keys before saving
device_attrs =
device_info
|> Map.delete(:_raw_discovery_data)
|> Map.delete(:_last_discovery_at)
|> Map.put(:device_id, device.id)
|> Map.put(:raw_discovery_data, raw_discovery_data)
|> Map.put(:last_discovery_at, last_discovery_at)
Logger.info("device_attrs has raw_discovery_data: #{inspect(Map.has_key?(device_attrs, :raw_discovery_data))}")
case Repo.get_by(Device, device_id: device.id) do
nil ->
%Device{}
|> Device.changeset(Map.put(device_info, :device_id, device.id))
|> Device.changeset(device_attrs)
|> Repo.insert!()
existing_device ->
existing_device
|> Device.changeset(device_info)
|> Device.changeset(device_attrs)
|> Repo.update!()
end
end
@ -427,4 +493,44 @@ defmodule Towerops.Snmp.Discovery do
{:ok, device}
end
end
defp log_discovery_event(device, discovered_device, is_rediscovery) do
event_type = if is_rediscovery, do: "device_rediscovered", else: "device_discovered"
message =
if is_rediscovery do
"Device rediscovered: #{device.name} (#{discovered_device.manufacturer} #{discovered_device.model})"
else
"Device discovered: #{device.name} (#{discovered_device.manufacturer} #{discovered_device.model})"
end
metadata = %{
manufacturer: discovered_device.manufacturer,
model: discovered_device.model,
firmware_version: discovered_device.firmware_version,
serial_number: discovered_device.serial_number,
interface_count: length(discovered_device.interfaces || []),
sensor_count:
Repo.aggregate(
from(s in Sensor, where: s.snmp_device_id == ^discovered_device.id),
:count
)
}
event_attrs = %{
device_id: device.id,
event_type: event_type,
severity: "info",
message: message,
metadata: metadata,
occurred_at: DateTime.utc_now()
}
# Publish event for EventLogger to persist
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:events",
{:device_event, event_attrs}
)
end
end

View file

@ -58,7 +58,9 @@ defmodule Towerops.Snmp.MibTranslator do
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)
# snmptranslate requires colon-separated paths with + prefix for each directory
mib_path = Enum.map_join(mib_dirs, ":", fn dir -> "+#{dir}" end)
mib_dir_args = if mib_path == "", do: [], else: ["-M", mib_path]
# Use snmptranslate to convert MIB name to numeric OID
# -On: Output numeric OID

View file

@ -335,4 +335,50 @@ defmodule Towerops.Snmp.Profiles.Base do
defp parse_sensor_status(2), do: "unavailable"
defp parse_sensor_status(3), do: "nonoperational"
defp parse_sensor_status(_), do: "unknown"
@doc """
Collects raw debug data for troubleshooting.
Includes system OIDs and interface/sensor tables.
"""
def collect_raw_debug_data(client_opts) do
# Collect system OID values
system_oids =
Map.new(@system_oids, fn {key, oid} ->
value =
case Client.get(client_opts, oid) do
{:ok, val} -> val
{:error, reason} -> "ERROR: #{inspect(reason)}"
end
{key, %{oid: oid, value: value}}
end)
# Walk interface tables
if_table =
case Client.walk(client_opts, "1.3.6.1.2.1.2.2") do
{:ok, results} -> results
{:error, _} -> %{}
end
ifx_table =
case Client.walk(client_opts, "1.3.6.1.2.1.31.1.1") do
{:ok, results} -> results
{:error, _} -> %{}
end
# Walk entity sensor table
entity_sensor_table =
case Client.walk(client_opts, "1.3.6.1.2.1.99.1.1") do
{:ok, results} -> results
{:error, _} -> %{}
end
%{
timestamp: DateTime.utc_now(),
system_oids: system_oids,
if_table: if_table,
ifx_table: ifx_table,
entity_sensor_table: entity_sensor_table
}
end
end

View file

@ -30,12 +30,14 @@ defmodule Towerops.Snmp.Profiles.Cisco do
}
@doc """
Discovers system information using Base profile.
Discovers system information.
Cisco profile doesn't query additional system OIDs beyond Base profile.
"""
@spec discover_system_info(Client.connection_opts()) ::
{:ok, Discovery.system_info()} | {:error, term()}
def discover_system_info(client_opts) do
Base.discover_system_info(client_opts)
def discover_system_info(_client_opts) do
# No additional system info beyond Base profile
{:ok, %{}}
end
@doc """
@ -244,4 +246,31 @@ defmodule Towerops.Snmp.Profiles.Cisco do
defp parse_cisco_sensor_status(2), do: "unavailable"
defp parse_cisco_sensor_status(3), do: "nonoperational"
defp parse_cisco_sensor_status(_), do: "unknown"
@doc """
Collects raw debug data for troubleshooting.
Includes Cisco-specific sensor OIDs and entity tables.
"""
def collect_raw_debug_data(client_opts) do
# Walk Cisco entity sensor table
cisco_sensor_table =
case Client.walk(client_opts, "1.3.6.1.4.1.9.9.91.1.1.1") do
{:ok, results} -> results
{:error, _} -> %{}
end
# Walk entity physical table for descriptions
entity_phys_table =
case Client.walk(client_opts, "1.3.6.1.2.1.47.1.1.1") do
{:ok, results} -> results
{:error, _} -> %{}
end
%{
timestamp: DateTime.utc_now(),
cisco_sensor_oids: @cisco_sensor_oids,
cisco_sensor_table: cisco_sensor_table,
entity_phys_table: entity_phys_table
}
end
end

View file

@ -60,10 +60,18 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
total_memory: "1.3.6.1.2.1.25.2.2.0",
used_memory: "1.3.6.1.4.1.14988.1.1.1.2.0",
total_hdd: "1.3.6.1.4.1.14988.1.1.1.6.0",
used_hdd: "1.3.6.1.4.1.14988.1.1.1.7.0"
used_hdd: "1.3.6.1.4.1.14988.1.1.1.7.0",
# Network Services
dhcp_lease_count: "1.3.6.1.4.1.14988.1.1.1.3.0",
# Connection Tracking (mtxrFWConnection)
fw_connection_count: "1.3.6.1.4.1.14988.1.1.13.1.1.1.0",
fw_connection_max: "1.3.6.1.4.1.14988.1.1.13.1.1.2.0"
}
# Table OID bases for walking
@health_sensor_table "1.3.6.1.4.1.14988.1.1.3.100.1"
@poe_table "1.3.6.1.4.1.14988.1.1.15.1"
@optical_table "1.3.6.1.4.1.14988.1.1.16.1"
@ -129,17 +137,20 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
_ -> "MikroTik RouterOS"
end
# Build formatted firmware version like LibreNMS: "Mikrotik RouterOS 7.20.6 (Level 6)"
# Build formatted firmware version
firmware_version = Map.get(system_info, :firmware_version)
license_level = Map.get(system_info, :license_version)
# Treat empty string as nil for license level
license_level = if license_level == "", do: nil, else: license_level
formatted_firmware =
cond do
firmware_version && license_level ->
"Mikrotik RouterOS #{firmware_version} (Level #{license_level})"
"RouterOS #{firmware_version} (Level #{license_level})"
firmware_version ->
"Mikrotik RouterOS #{firmware_version}"
"RouterOS #{firmware_version}"
true ->
# Fallback to extracting from sysDescr
@ -206,13 +217,92 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
license_version: license_level
}}
{:error, _} ->
{:error, reason} ->
# MikroTik-specific OIDs not available or partially available
Logger.warning("Failed to get MikroTik device info: #{inspect(reason)}")
{:ok, %{}}
end
end
defp discover_health_sensors(client_opts) do
# First try the dynamic health sensor table (available on newer devices like CCR2004)
table_sensors = discover_health_table_sensors(client_opts)
# Also try individual health OIDs for older devices
oid_sensors = discover_health_oid_sensors(client_opts)
# Combine both, preferring table sensors (more accurate)
table_sensors ++ oid_sensors
end
defp discover_health_table_sensors(client_opts) do
# Walk health sensor table: mtxrHealthTable (1.3.6.1.4.1.14988.1.1.3.100.1)
# Column 2: Name, Column 3: Value, Column 4: Type
with {:ok, name_results} <- Client.walk(client_opts, "#{@health_sensor_table}.2"),
{:ok, value_results} <- Client.walk(client_opts, "#{@health_sensor_table}.3"),
{:ok, type_results} <- Client.walk(client_opts, "#{@health_sensor_table}.4") do
indices =
name_results
|> Map.keys()
|> Enum.map(fn oid -> oid |> String.split(".") |> List.last() end)
indices
|> Enum.map(fn index ->
name_oid = "#{@health_sensor_table}.2.#{index}"
value_oid = "#{@health_sensor_table}.3.#{index}"
type_oid = "#{@health_sensor_table}.4.#{index}"
name = Map.get(name_results, name_oid, "unknown")
value = Map.get(value_results, value_oid, 0)
type_code = Map.get(type_results, type_oid, 0)
{sensor_type, unit, divisor} = decode_health_sensor_type(type_code, name)
# Skip sensors with value 0 (disabled PSUs, etc.)
if value > 0 do
%{
sensor_type: sensor_type,
sensor_index: "health_#{index}",
sensor_oid: value_oid,
sensor_descr: format_health_sensor_name(name),
sensor_unit: unit,
sensor_divisor: divisor,
last_value: value / divisor,
status: "ok"
}
end
end)
|> Enum.reject(&is_nil/1)
else
_ -> []
end
end
defp decode_health_sensor_type(type_code, name) do
cond do
# Type 1 = Temperature (Celsius)
type_code == 1 -> {"temperature", "°C", 1}
# Type 3 = Voltage (V, value in decivolts)
type_code == 3 -> {"voltage", "V", 10}
# Type 2 = Current (mA)
type_code == 2 -> {"current", "mA", 1}
# Fallback: infer from name
String.contains?(name, "temperature") -> {"temperature", "°C", 1}
String.contains?(name, "voltage") -> {"voltage", "V", 10}
String.contains?(name, "current") -> {"current", "mA", 1}
String.contains?(name, "power") -> {"power", "W", 10}
true -> {"unknown", "", 1}
end
end
defp format_health_sensor_name(name) do
name
|> String.replace("-", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
defp discover_health_oid_sensors(client_opts) do
# Try to get all health OIDs - voltages, temperatures, power, fans
health_oids = [
# Voltages
@ -262,10 +352,11 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
defp discover_resource_sensors(client_opts) do
cpu_sensors = discover_cpu_sensors(client_opts)
storage_sensors = discover_storage_sensors(client_opts)
network_sensors = discover_network_sensors(client_opts)
poe_sensors = discover_poe_sensors(client_opts)
optical_sensors = discover_optical_sensors(client_opts)
cpu_sensors ++ storage_sensors ++ poe_sensors ++ optical_sensors
cpu_sensors ++ storage_sensors ++ network_sensors ++ poe_sensors ++ optical_sensors
end
defp discover_cpu_sensors(client_opts) do
@ -292,6 +383,35 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
end
end
defp discover_network_sensors(client_opts) do
# Get DHCP lease count and connection tracking stats
network_oids = [
{@mikrotik_oids.dhcp_lease_count, "dhcp_leases", "count", 1},
{@mikrotik_oids.fw_connection_count, "connection_count", "count", 1}
]
network_oids
|> Enum.map(fn {oid, type, unit, divisor} ->
case Client.get(client_opts, oid) do
{:ok, value} when is_integer(value) and value >= 0 ->
%{
sensor_type: type,
sensor_index: type,
sensor_oid: oid,
sensor_descr: format_network_sensor_name(type),
sensor_unit: unit,
sensor_divisor: divisor,
last_value: value / divisor,
status: "ok"
}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
end
defp discover_storage_sensors(client_opts) do
with {:ok, descr_results} <- Client.walk(client_opts, "1.3.6.1.2.1.25.2.3.1.3"),
{:ok, size_results} <- Client.walk(client_opts, "1.3.6.1.2.1.25.2.3.1.5"),
@ -544,6 +664,10 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
defp format_sensor_name("processor_frequency"), do: "Processor Frequency"
defp format_sensor_name(other), do: String.capitalize(other)
defp format_network_sensor_name("dhcp_leases"), do: "DHCP Leases"
defp format_network_sensor_name("connection_count"), do: "Active Connections"
defp format_network_sensor_name(other), do: String.capitalize(other)
defp cpu_status(load) when load < 70, do: "ok"
defp cpu_status(load) when load < 90, do: "warning"
defp cpu_status(_), do: "critical"
@ -568,4 +692,34 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do
_ -> nil
end
end
@doc """
Collects raw SNMP OID data for debugging purposes.
"""
def collect_raw_debug_data(client_opts) do
# Collect raw SNMP data for debugging
system_oids =
Map.new(@mikrotik_oids, fn {key, oid} ->
value =
case Client.get(client_opts, oid) do
{:ok, val} -> val
{:error, reason} -> "ERROR: #{inspect(reason)}"
end
{key, %{oid: oid, value: value}}
end)
# Walk health sensor table
health_table =
case Client.walk(client_opts, @health_sensor_table) do
{:ok, results} -> results
{:error, _} -> %{}
end
%{
timestamp: DateTime.utc_now(),
system_oids: system_oids,
health_sensor_table: health_table
}
end
end

View file

@ -38,12 +38,14 @@ defmodule Towerops.Snmp.Profiles.NetSnmp do
}
@doc """
Discovers system information using Base profile.
Discovers system information.
NetSnmp profile doesn't query additional system OIDs beyond Base profile.
"""
@spec discover_system_info(Client.connection_opts()) ::
{:ok, Discovery.system_info()} | {:error, term()}
def discover_system_info(client_opts) do
Base.discover_system_info(client_opts)
def discover_system_info(_client_opts) do
# No additional system info beyond Base profile
{:ok, %{}}
end
@doc """
@ -313,4 +315,53 @@ defmodule Towerops.Snmp.Profiles.NetSnmp do
defp parse_float(value) when is_integer(value), do: value / 1.0
defp parse_float(value) when is_binary(value), do: String.to_float(value)
defp parse_float(_), do: nil
@doc """
Collects raw debug data for troubleshooting.
Includes LM-SENSORS and UCD-SNMP tables.
"""
def collect_raw_debug_data(client_opts) do
# Walk LM-SENSORS tables
lm_temp_table =
case Client.walk(client_opts, "1.3.6.1.4.1.2021.13.16.2") do
{:ok, results} -> results
{:error, _} -> %{}
end
lm_fan_table =
case Client.walk(client_opts, "1.3.6.1.4.1.2021.13.16.3") do
{:ok, results} -> results
{:error, _} -> %{}
end
lm_voltage_table =
case Client.walk(client_opts, "1.3.6.1.4.1.2021.13.16.4") do
{:ok, results} -> results
{:error, _} -> %{}
end
# Walk UCD-SNMP tables
ucd_load_table =
case Client.walk(client_opts, "1.3.6.1.4.1.2021.10") do
{:ok, results} -> results
{:error, _} -> %{}
end
ucd_memory_table =
case Client.walk(client_opts, "1.3.6.1.4.1.2021.4") do
{:ok, results} -> results
{:error, _} -> %{}
end
%{
timestamp: DateTime.utc_now(),
lm_sensors_oids: @lm_sensors_oids,
ucd_snmp_oids: @ucd_snmp_oids,
lm_temp_table: lm_temp_table,
lm_fan_table: lm_fan_table,
lm_voltage_table: lm_voltage_table,
ucd_load_table: ucd_load_table,
ucd_memory_table: ucd_memory_table
}
end
end

View file

@ -18,6 +18,10 @@ defmodule Towerops.Snmp.Profiles.Ubiquiti do
# Ubiquiti enterprise MIB base: 1.3.6.1.4.1.41112
@ubnt_base "1.3.6.1.4.1.41112"
# UniFi specific OIDs (.1.6.3)
@unifi_system_model "#{@ubnt_base}.1.6.3.3.0"
@unifi_system_version "#{@ubnt_base}.1.6.3.6.0"
# Radio configuration OIDs (.1.3.1.1)
@radio_config_base "#{@ubnt_base}.1.3.1.1"
@radio_tx_freq "#{@radio_config_base}.5"
@ -36,10 +40,27 @@ defmodule Towerops.Snmp.Profiles.Ubiquiti do
@radio_remote_rx_power "#{@radio_stats_base}.14"
@doc """
Discovers system information using base profile.
Discovers system information, extending Base with UniFi-specific OIDs.
"""
def discover_system_info(client_opts) do
Base.discover_system_info(client_opts)
with {:ok, base_info} <- Base.discover_system_info(client_opts) do
# Try to get UniFi-specific model and version
unifi_info = discover_unifi_system_info(client_opts)
{:ok, Map.merge(base_info, unifi_info)}
end
end
defp discover_unifi_system_info(client_opts) do
case Client.get_multiple(client_opts, [@unifi_system_model, @unifi_system_version]) do
{:ok, [model, version]} when is_binary(model) or is_binary(version) ->
%{
unifi_model: model,
unifi_version: version
}
_ ->
%{}
end
end
@doc """
@ -70,21 +91,93 @@ defmodule Towerops.Snmp.Profiles.Ubiquiti do
end
@doc """
Identifies Ubiquiti device from sysDescr.
Identifies Ubiquiti device from sysDescr and UniFi-specific OIDs.
"""
def identify_device(system_info) do
sys_descr = Map.get(system_info, :sys_descr, "")
unifi_model = Map.get(system_info, :unifi_model)
unifi_version = Map.get(system_info, :unifi_version)
# Extract airOS version if present
firmware = extract_airos_version(sys_descr)
# Prefer UniFi-specific model/version if available, otherwise detect from sysDescr
{model, firmware} =
if unifi_model || unifi_version do
{unifi_model || "UniFi Device", unifi_version}
else
{detect_model(sys_descr), extract_airos_version(sys_descr)}
end
Map.merge(system_info, %{
manufacturer: "Ubiquiti",
model: detect_model(sys_descr),
model: model,
firmware_version: firmware
})
end
@doc """
Collects raw debug data for troubleshooting.
Includes UniFi system OIDs, radio configuration and statistics.
"""
def collect_raw_debug_data(client_opts) do
# UniFi system OIDs
unifi_system_oids = %{
unifi_model: @unifi_system_model,
unifi_version: @unifi_system_version
}
# Radio configuration OIDs
radio_config_oids = %{
radio_tx_freq: @radio_tx_freq,
radio_rx_freq: @radio_rx_freq,
radio_tx_power: @radio_tx_power
}
# Radio statistics OIDs
radio_stats_oids = %{
radio_tx_throughput: @radio_tx_throughput,
radio_rx_throughput: @radio_rx_throughput,
radio_tx_capacity: @radio_tx_capacity_bps,
radio_rx_capacity: @radio_rx_capacity_bps,
radio_tx_signal: @radio_tx_signal,
radio_rx_signal: @radio_rx_signal,
radio_remote_tx_power: @radio_remote_tx_power,
radio_remote_rx_power: @radio_remote_rx_power
}
# Get UniFi system values
unifi_system_values =
Map.new(unifi_system_oids, fn {key, oid} ->
value =
case Client.get(client_opts, oid) do
{:ok, val} -> val
{:error, reason} -> "ERROR: #{inspect(reason)}"
end
{key, %{oid: oid, value: value}}
end)
# Walk radio config and stats tables
radio_config_table =
case Client.walk(client_opts, @radio_config_base) do
{:ok, results} -> results
{:error, _} -> %{}
end
radio_stats_table =
case Client.walk(client_opts, @radio_stats_base) do
{:ok, results} -> results
{:error, _} -> %{}
end
%{
timestamp: DateTime.utc_now(),
unifi_system_oids: unifi_system_values,
radio_config_oids: radio_config_oids,
radio_stats_oids: radio_stats_oids,
radio_config_table: radio_config_table,
radio_stats_table: radio_stats_table
}
end
# Private functions
defp discover_radio_sensors(client_opts) do

View file

@ -97,6 +97,22 @@
>
Logs
</.link>
<%= if @snmp_device && @snmp_device.raw_discovery_data do %>
<.link
patch={~p"/devices/#{@device.id}?tab=debug"}
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if @active_tab == "debug" do
"border-blue-500 text-blue-600 dark:text-blue-400"
else
"border-transparent text-zinc-500 hover:text-zinc-700 hover:border-zinc-300 dark:text-zinc-400 dark:hover:text-zinc-300"
end
]}
>
Debug
</.link>
<% end %>
</nav>
</div>
</div>
@ -746,6 +762,116 @@
</div>
<% end %>
</div>
<% "debug" -> %>
<div class="bg-white dark:bg-zinc-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-zinc-900 dark:text-zinc-100">
Raw Discovery Data
</h3>
<div class="mt-2 max-w-xl text-sm text-zinc-500 dark:text-zinc-400">
<p>
Raw SNMP OID values collected during the last discovery run at
<%= if @snmp_device.last_discovery_at do %>
{Calendar.strftime(@snmp_device.last_discovery_at, "%Y-%m-%d %H:%M:%S UTC")}
<% else %>
unknown time
<% end %>.
</p>
</div>
<%= if @snmp_device.raw_discovery_data do %>
<div class="mt-5">
<h4 class="text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-3">
System OIDs
</h4>
<div class="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead class="bg-zinc-50 dark:bg-zinc-900">
<tr>
<th class="px-3 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
Field
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
OID
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
Value
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-zinc-800 divide-y divide-zinc-200 dark:divide-zinc-700">
<%= for {key, data} <- Map.get(@snmp_device.raw_discovery_data, "system_oids", %{}) do %>
<tr>
<td class="px-3 py-4 whitespace-nowrap text-sm font-medium text-zinc-900 dark:text-zinc-100">
{key}
</td>
<td class="px-3 py-4 whitespace-nowrap text-sm text-zinc-500 dark:text-zinc-400 font-mono">
{data["oid"]}
</td>
<td class="px-3 py-4 text-sm text-zinc-900 dark:text-zinc-100">
<code class="text-xs">{inspect(data["value"])}</code>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<%= if Map.get(@snmp_device.raw_discovery_data, "health_sensor_table") do %>
<h4 class="text-sm font-medium text-zinc-900 dark:text-zinc-100 mt-6 mb-3">
Health Sensor Table Walk
</h4>
<div class="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
<thead class="bg-zinc-50 dark:bg-zinc-900">
<tr>
<th class="px-3 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
OID
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
Value
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-zinc-800 divide-y divide-zinc-200 dark:divide-zinc-700">
<%= for {oid, value} <- Map.get(@snmp_device.raw_discovery_data, "health_sensor_table", %{}) do %>
<tr>
<td class="px-3 py-4 whitespace-nowrap text-sm font-mono text-zinc-500 dark:text-zinc-400">
{oid}
</td>
<td class="px-3 py-4 text-sm text-zinc-900 dark:text-zinc-100">
<code class="text-xs">{inspect(value)}</code>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<h4 class="text-sm font-medium text-zinc-900 dark:text-zinc-100 mt-6 mb-3">
Copyable Debug Data
</h4>
<div class="rounded-lg border border-zinc-200 dark:border-zinc-700">
<textarea
readonly
rows="10"
class="w-full p-3 font-mono text-xs bg-zinc-50 dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border-0 rounded-lg focus:ring-2 focus:ring-blue-500"
onclick="this.select()"
><%= Jason.encode!(@snmp_device.raw_discovery_data, pretty: true) %></textarea>
</div>
<p class="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Click the text box to select all data, then copy with Cmd+C or Ctrl+C
</p>
</div>
<% else %>
<div class="mt-5 p-4 bg-zinc-50 dark:bg-zinc-900 rounded-md">
<p class="text-sm text-zinc-500 dark:text-zinc-400">
No raw discovery data available. Run discovery again to collect debug data.
</p>
</div>
<% end %>
</div>
</div>
<% _ -> %>
<div class="text-center py-12">
<p class="text-zinc-500 dark:text-zinc-400">Tab not found</p>

View file

@ -0,0 +1,23 @@
defmodule Towerops.Repo.Migrations.AddMikrotikResourceSensors do
use Ecto.Migration
def up do
# Disable the database MikroTik profile to use the hardcoded profile instead
# The hardcoded profile has comprehensive sensor discovery including:
# - CPU load (per core)
# - Memory and storage (via HOST-RESOURCES-MIB walks)
# - DHCP leases
# - Connection tracking
# - POE sensors
# - Optical/SFP sensors
#
# The database profile system doesn't yet support table walks,
# so we fall back to the hardcoded profile for full discovery.
execute("UPDATE device_profiles SET enabled = false WHERE name = 'mikrotik'")
end
def down do
# Re-enable the database profile
execute("UPDATE device_profiles SET enabled = true WHERE name = 'mikrotik'")
end
end

View file

@ -0,0 +1,10 @@
defmodule Towerops.Repo.Migrations.AddRawDiscoveryDataToSnmpDevices do
use Ecto.Migration
def change do
alter table(:snmp_devices) do
add :raw_discovery_data, :map
add :last_discovery_at, :utc_datetime
end
end
end

View file

@ -51,14 +51,10 @@ defmodule Towerops.Snmp.DiscoveryTest do
snmp_port: 161
})
# Mock successful connection test
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:ok, {:timeticks, 12_345}}
end)
# Mock system info discovery
expect(SnmpMock, :get, 6, fn _, oid, _ ->
# Mock all GET calls (connection test + system info + interfaces + health + network sensors)
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
# Connection test + Base system info
"1.3.6.1.2.1.1.1.0" ->
{:ok, {:octet_string, "RouterOS RB750"}}
@ -77,77 +73,114 @@ defmodule Towerops.Snmp.DiscoveryTest do
"1.3.6.1.2.1.1.6.0" ->
{:ok, {:octet_string, "Data Center"}}
# MikroTik-specific system info
"1.3.6.1.4.1.14988.1.1.7.3.0" ->
{:ok, {:octet_string, "1234567890"}}
"1.3.6.1.4.1.14988.1.1.7.4.0" ->
{:ok, {:octet_string, "7.13.2"}}
"1.3.6.1.4.1.14988.1.1.7.9.0" ->
{:ok, {:octet_string, "RB750Gr3"}}
"1.3.6.1.4.1.14988.1.1.7.8.0" ->
{:ok, {:octet_string, "Main Router"}}
"1.3.6.1.4.1.14988.1.1.7.5.0" ->
{:ok, {:integer, 6}}
# Network sensors (MikroTik-specific)
"1.3.6.1.4.1.14988.1.1.1.3.0" ->
{:ok, {:integer, 10}}
"1.3.6.1.4.1.14988.1.1.13.1.1.1.0" ->
{:ok, {:integer, 500}}
# Catch-all for interface data and health sensors
_ ->
{:error, :no_such_object}
cond do
# Health sensor OIDs (all return error for this test)
String.starts_with?(oid, "1.3.6.1.4.1.14988.1.1.3.") and
not String.contains?(oid, "1.3.6.1.4.1.14988.1.1.13") ->
{:error, :no_such_object}
# Interface 1 data
String.ends_with?(oid, ".1") ->
parts = String.split(oid, ".")
second_to_last = Enum.at(parts, -2)
cond do
second_to_last == "2" -> {:ok, {:octet_string, "ether1"}}
second_to_last == "3" -> {:ok, {:integer, 6}}
second_to_last == "5" -> {:ok, {:gauge32, 1_000_000_000}}
second_to_last == "6" -> {:ok, {:octet_string, <<0x00, 0x11, 0x22, 0x33, 0x44, 0x55>>}}
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 1}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "ether1"}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "WAN"}}
true -> {:error, :no_such_object}
end
# Interface 2 data
String.ends_with?(oid, ".2") ->
parts = String.split(oid, ".")
second_to_last = Enum.at(parts, -2)
cond do
second_to_last == "2" -> {:ok, {:octet_string, "ether2"}}
second_to_last == "3" -> {:ok, {:integer, 6}}
second_to_last == "5" -> {:ok, {:gauge32, 1_000_000_000}}
second_to_last == "6" -> {:ok, {:octet_string, <<0x00, 0x11, 0x22, 0x33, 0x44, 0x56>>}}
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 1}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "ether2"}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "LAN"}}
true -> {:error, :no_such_object}
end
true ->
{:error, :no_such_object}
end
end
end)
# Mock interface discovery
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.2.2.1.1", _ ->
{:ok,
[
%{oid: "1.3.6.1.2.1.2.2.1.1.1", value: {:integer, 1}},
%{oid: "1.3.6.1.2.1.2.2.1.1.2", value: {:integer, 2}}
]}
end)
# Mock interface data fetches for each interface
expect(SnmpMock, :get, 16, fn _, oid, _ ->
cond do
String.ends_with?(oid, ".1") ->
parts = String.split(oid, ".")
second_to_last = Enum.at(parts, -2)
cond do
second_to_last == "2" -> {:ok, {:octet_string, "ether1"}}
second_to_last == "3" -> {:ok, {:integer, 6}}
second_to_last == "5" -> {:ok, {:gauge32, 1_000_000_000}}
second_to_last == "6" -> {:ok, {:octet_string, <<0x00, 0x11, 0x22, 0x33, 0x44, 0x55>>}}
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 1}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "ether1"}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "WAN"}}
true -> {:error, :no_such_object}
end
String.ends_with?(oid, ".2") ->
parts = String.split(oid, ".")
second_to_last = Enum.at(parts, -2)
cond do
second_to_last == "2" -> {:ok, {:octet_string, "ether2"}}
second_to_last == "3" -> {:ok, {:integer, 6}}
second_to_last == "5" -> {:ok, {:gauge32, 1_000_000_000}}
second_to_last == "6" -> {:ok, {:octet_string, <<0x00, 0x11, 0x22, 0x33, 0x44, 0x56>>}}
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 1}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "ether2"}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "LAN"}}
true -> {:error, :no_such_object}
end
true ->
{:error, :no_such_object}
end
end)
# Note: Dynamic profile would try to discover device OIDs and sensor OIDs from database,
# but MibTranslator fails in tests (MIKROTIK-MIB not installed), so no get calls are made
# Mock sensor discovery - MikroTik profiles make many walk calls for sensors
# CPU (1), storage (3), POE (4), optical (5), ENTITY-SENSOR-MIB (1) = 14 walks
# Mock all walk calls
stub(SnmpMock, :walk, fn _, oid, _ ->
if String.starts_with?(oid, "1.3.6.1.2.1.99") do
{:ok, []}
else
{:error, :no_such_object}
cond do
# Interface discovery - return 2 interfaces
oid == "1.3.6.1.2.1.2.2.1.1" ->
{:ok,
[
%{oid: "1.3.6.1.2.1.2.2.1.1.1", value: {:integer, 1}},
%{oid: "1.3.6.1.2.1.2.2.1.1.2", value: {:integer, 2}}
]}
# ENTITY-SENSOR-MIB
String.starts_with?(oid, "1.3.6.1.2.1.99") ->
{:ok, []}
# LLDP neighbor discovery
String.starts_with?(oid, "1.0.8802") ->
{:ok, []}
# CDP neighbor discovery
String.starts_with?(oid, "1.3.6.1.4.1.9.9.23") ->
{:ok, []}
# MikroTik health sensor table
String.starts_with?(oid, "1.3.6.1.4.1.14988.1.1.3.100") ->
{:ok, []}
# Other walks (debug data collection, sensors, etc.)
true ->
{:ok, []}
end
end)
assert {:ok, device} = Discovery.discover_device(device)
assert device.manufacturer == "MikroTik"
assert device.model == "RouterOS RB750"
assert device.model == "RB750"
assert device.sys_name == "router1"
# Verify interfaces were created
@ -172,23 +205,18 @@ defmodule Towerops.Snmp.DiscoveryTest do
snmp_port: 161
})
# Mock successful connection test
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:ok, {:timeticks, 54_321}}
end)
# Mock system info discovery
expect(SnmpMock, :get, 6, fn _, oid, _ ->
# Mock all GET calls (connection test + system info + debug data)
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.3.0" ->
{:ok, {:timeticks, 54_321}}
"1.3.6.1.2.1.1.1.0" ->
{:ok, {:octet_string, "Cisco IOS Software, C2960 Software"}}
"1.3.6.1.2.1.1.2.0" ->
{:ok, {:object_identifier, [1, 3, 6, 1, 4, 1, 9, 1, 1208]}}
"1.3.6.1.2.1.1.3.0" ->
{:ok, {:timeticks, 54_321}}
"1.3.6.1.2.1.1.4.0" ->
{:ok, {:octet_string, "netadmin@cisco.com"}}
@ -203,10 +231,8 @@ defmodule Towerops.Snmp.DiscoveryTest do
end
end)
# Mock interface discovery - no interfaces for simplicity
# Walk called 5 times: interfaces (1), cisco sensors (1), base sensors fallback (1),
# LLDP neighbors (1), CDP neighbors (1)
expect(SnmpMock, :walk, 5, fn _, _, _ ->
# Mock all walk calls (debug data collection, interfaces, sensors, neighbors)
stub(SnmpMock, :walk, fn _, _, _ ->
{:ok, []}
end)
@ -249,14 +275,13 @@ defmodule Towerops.Snmp.DiscoveryTest do
snmp_port: 161
})
# Mock successful connection test
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
{:ok, {:timeticks, 999}}
end)
# Mock system info discovery
expect(SnmpMock, :get, 6, fn _, oid, _ ->
# Mock all GET calls (connection + system info + debug data)
# Connection: 1, System info: 6, Debug data: 6 = 13 total
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.1.3.0" ->
{:ok, {:timeticks, 999}}
"1.3.6.1.2.1.1.1.0" ->
{:ok, {:octet_string, "Generic Device"}}
@ -265,23 +290,31 @@ defmodule Towerops.Snmp.DiscoveryTest do
end
end)
# Mock failed interface discovery
expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.2.2.1.1", _ ->
{:error, :timeout}
end)
# Mock failed sensor discovery and neighbor discovery (LLDP + CDP)
expect(SnmpMock, :walk, 3, fn _, oid, _ ->
# Mock all walk calls in execution order:
# 1. Debug data collection happens first (inside build_device_info)
# 2. Then interface discovery
# 3. Then sensor discovery
# 4. Then neighbor discovery (LLDP + CDP)
stub(SnmpMock, :walk, fn _, oid, _ ->
cond do
# Entity sensor queries (sensor discovery + debug entity_sensor_table)
String.starts_with?(oid, "1.3.6.1.2.1.99") ->
{:error, :timeout}
# Interface ifIndex
oid == "1.3.6.1.2.1.2.2.1.1" ->
{:error, :timeout}
# LLDP - return empty
String.starts_with?(oid, "1.0.8802") ->
# LLDP - return empty
{:ok, []}
# CDP - return empty
String.starts_with?(oid, "1.3.6.1.4.1.9.9.23") ->
# CDP - return empty
{:ok, []}
# Debug data collection (if_table, ifx_table)
String.starts_with?(oid, "1.3.6.1.2.1") ->
{:ok, []}
true ->
@ -533,13 +566,14 @@ defmodule Towerops.Snmp.DiscoveryTest do
end)
# Mock interface and sensor discovery - no interfaces/sensors for simplicity
# Walk called 10 times:
# Walk called 15 times:
# - LM sensors: temp devices (1), temp values (1), fan devices (1), fan values (1),
# voltage devices (1), voltage values (1)
# - Base sensors fallback (1)
# - Interfaces (1)
# - Neighbors: LLDP (1), CDP (1)
expect(SnmpMock, :walk, 10, fn _, _, _ ->
# - Debug data: lm_temp (1), lm_fan (1), lm_voltage (1), ucd_load (1), ucd_memory (1)
expect(SnmpMock, :walk, 15, fn _, _, _ ->
{:ok, []}
end)
@ -599,13 +633,14 @@ defmodule Towerops.Snmp.DiscoveryTest do
end)
# Mock interface and sensor discovery
# Walk called 10 times:
# Walk called 15 times:
# - LM sensors: temp devices (1), temp values (1), fan devices (1), fan values (1),
# voltage devices (1), voltage values (1)
# - Base sensors fallback (1)
# - Interfaces (1)
# - Neighbors: LLDP (1), CDP (1)
expect(SnmpMock, :walk, 10, fn _, _, _ ->
# - Debug data: lm_temp (1), lm_fan (1), lm_voltage (1), ucd_load (1), ucd_memory (1)
expect(SnmpMock, :walk, 15, fn _, _, _ ->
{:ok, []}
end)

View file

@ -60,7 +60,9 @@ defmodule Towerops.Snmp.ProfileBehaviourTest do
case profile.discover_system_info(@test_opts) do
{:ok, info} ->
assert is_map(info)
assert map_size(info) > 0
# Some profiles return empty map (no additional info beyond Base)
# Others return profile-specific data (e.g., MikroTik, Ubiquiti)
{:error, _reason} ->
# Some profiles might fail if required data is missing

View file

@ -11,33 +11,13 @@ defmodule Towerops.Snmp.Profiles.CiscoTest do
@client_opts [ip: "192.168.1.1", community: "public", version: :v2c, port: 161]
describe "discover_system_info/1" do
test "delegates to Base profile" do
# Mock the SNMP calls that Base.discover_system_info makes
# Base profile queries 6 system OIDs - match by OID string
expect(SnmpMock, :get, 6, fn _target, oid, _opts ->
case oid do
# sysDescr
"1.3.6.1.2.1.1.1.0" -> {:ok, "Cisco IOS Software"}
# sysObjectID
"1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1, 4, 1, 9]}
# sysUpTime
"1.3.6.1.2.1.1.3.0" -> {:ok, 12_345}
# sysContact
"1.3.6.1.2.1.1.4.0" -> {:ok, "admin@example.com"}
# sysName
"1.3.6.1.2.1.1.5.0" -> {:ok, "test-device"}
# sysLocation
"1.3.6.1.2.1.1.6.0" -> {:ok, "Test Location"}
_ -> {:error, :unknown_oid}
end
end)
test "returns empty map (no additional system info beyond Base)" do
# Cisco profile doesn't query additional system OIDs
# Base profile handles all standard system info discovery
result = Cisco.discover_system_info(@client_opts)
# Should delegate to Base and return its result
assert {:ok, system_info} = result
assert is_map(system_info)
assert system_info.sys_name == "test-device"
assert system_info == %{}
end
end

View file

@ -143,8 +143,9 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do
describe "discover_sensors/1" do
test "discovers health sensors (temperature, voltage, fan)" do
# Mock health sensor OIDs
expect(SnmpMock, :get, 15, fn _, oid, _ ->
# Mock health sensor OIDs + network sensors (DHCP leases, connection count)
# Health: 15 OIDs, Network: 2 OIDs = 17 total
expect(SnmpMock, :get, 17, fn _, oid, _ ->
case oid do
# Voltages
"1.3.6.1.4.1.14988.1.1.3.1.0" -> {:ok, {:integer, 12}}
@ -166,31 +167,84 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do
"1.3.6.1.4.1.14988.1.1.3.18.0" -> {:ok, {:integer, 0}}
# Processor frequency
"1.3.6.1.4.1.14988.1.1.3.14.0" -> {:ok, {:integer, 1400}}
# Network sensors
"1.3.6.1.4.1.14988.1.1.1.3.0" -> {:ok, {:integer, 42}}
"1.3.6.1.4.1.14988.1.1.13.1.1.1.0" -> {:ok, {:integer, 1500}}
end
end)
# Mock all walk calls - use stub to allow any number of calls (code may short-circuit)
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
# Health sensor table (new dynamic sensor discovery)
# SNMP adapter returns list of %{oid:, value:} maps
"1.3.6.1.4.1.14988.1.1.3.100.1.2" ->
{:ok,
[
%{oid: "1.3.6.1.4.1.14988.1.1.3.100.1.2.1", value: "CPU Temperature"},
%{oid: "1.3.6.1.4.1.14988.1.1.3.100.1.2.2", value: "Board Temperature"}
]}
"1.3.6.1.4.1.14988.1.1.3.100.1.3" ->
{:ok,
[
%{oid: "1.3.6.1.4.1.14988.1.1.3.100.1.3.1", value: 450},
%{oid: "1.3.6.1.4.1.14988.1.1.3.100.1.3.2", value: 480}
]}
"1.3.6.1.4.1.14988.1.1.3.100.1.4" ->
{:ok,
[
%{oid: "1.3.6.1.4.1.14988.1.1.3.100.1.4.1", value: 1},
%{oid: "1.3.6.1.4.1.14988.1.1.3.100.1.4.2", value: 1}
]}
# CPU sensors
"1.3.6.1.2.1.25.3.3.1.2" -> {:ok, [%{oid: "1.3.6.1.2.1.25.3.3.1.2.1", value: 45}]}
"1.3.6.1.2.1.25.3.3.1.2" ->
{:ok, [%{oid: "1.3.6.1.2.1.25.3.3.1.2.1", value: 45}]}
# Storage sensors
"1.3.6.1.2.1.25.2.3.1.3" -> {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.3.1", value: "RAM"}]}
"1.3.6.1.2.1.25.2.3.1.5" -> {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.5.1", value: 1000}]}
"1.3.6.1.2.1.25.2.3.1.6" -> {:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.6.1", value: 750}]}
"1.3.6.1.2.1.25.2.3.1.3" ->
{:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.3.1", value: "RAM"}]}
"1.3.6.1.2.1.25.2.3.1.5" ->
{:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.5.1", value: 1000}]}
"1.3.6.1.2.1.25.2.3.1.6" ->
{:ok, [%{oid: "1.3.6.1.2.1.25.2.3.1.6.1", value: 750}]}
# POE sensors (empty)
"1.3.6.1.4.1.14988.1.1.15.1.1.3" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.15.1.1.5" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.15.1.1.6" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.15.1.1.7" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.15.1.1.3" ->
{:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.15.1.1.5" ->
{:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.15.1.1.6" ->
{:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.15.1.1.7" ->
{:error, :no_such_object}
# Optical sensors (empty)
"1.3.6.1.4.1.14988.1.1.16.1.1.2" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.3" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.5" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.7" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.8" -> {:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.2" ->
{:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.3" ->
{:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.5" ->
{:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.7" ->
{:error, :no_such_object}
"1.3.6.1.4.1.14988.1.1.16.1.1.8" ->
{:error, :no_such_object}
# Entity sensors from Base
"1.3.6.1.2.1.99.1.1.1.1" -> {:ok, []}
"1.3.6.1.2.1.99.1.1.1.1" ->
{:ok, []}
end
end)
@ -217,8 +271,16 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do
end
test "discovers CPU load sensors" do
# Mock health sensors (none available)
expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end)
# Mock health sensors (15, all return error) + network sensors (2)
expect(SnmpMock, :get, 17, fn _, oid, _ ->
case oid do
# Network sensors
"1.3.6.1.4.1.14988.1.1.1.3.0" -> {:ok, {:integer, 10}}
"1.3.6.1.4.1.14988.1.1.13.1.1.1.0" -> {:ok, {:integer, 500}}
# All health sensors return error
_ -> {:error, :no_such_object}
end
end)
# Mock all walk calls
stub(SnmpMock, :walk, fn _, oid, _ ->
@ -255,8 +317,16 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do
end
test "discovers storage sensors with proper status" do
# Mock health sensors (none)
expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end)
# Mock health sensors (15, all return error) + network sensors (2)
expect(SnmpMock, :get, 17, fn _, oid, _ ->
case oid do
# Network sensors
"1.3.6.1.4.1.14988.1.1.1.3.0" -> {:ok, {:integer, 10}}
"1.3.6.1.4.1.14988.1.1.13.1.1.1.0" -> {:ok, {:integer, 500}}
# All health sensors return error
_ -> {:error, :no_such_object}
end
end)
# Mock all walk calls
stub(SnmpMock, :walk, fn _, oid, _ ->
@ -307,12 +377,30 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do
end
test "discovers POE sensors per port" do
# Mock health, cpu, storage as empty
expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end)
# Mock health sensors (15, all return error) + network sensors (2)
expect(SnmpMock, :get, 17, fn _, oid, _ ->
case oid do
# Network sensors
"1.3.6.1.4.1.14988.1.1.1.3.0" -> {:ok, {:integer, 10}}
"1.3.6.1.4.1.14988.1.1.13.1.1.1.0" -> {:ok, {:integer, 500}}
# All health sensors return error
_ -> {:error, :no_such_object}
end
end)
# Mock all walk calls
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
# Health sensor table (checked before POE)
"1.3.6.1.4.1.14988.1.1.3.100.1.2" ->
{:ok, []}
"1.3.6.1.4.1.14988.1.1.3.100.1.3" ->
{:ok, []}
"1.3.6.1.4.1.14988.1.1.3.100.1.4" ->
{:ok, []}
# CPU sensors
"1.3.6.1.2.1.25.3.3.1.2" ->
{:ok, []}
@ -385,7 +473,7 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do
assert result.manufacturer == "MikroTik"
assert result.model == "RB5009UG+S+"
assert result.firmware_version == "Mikrotik RouterOS 7.13.2"
assert result.firmware_version == "RouterOS 7.13.2"
end
test "identifies generic RouterOS device when model not found" do
@ -426,7 +514,16 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do
describe "sensor status determination" do
test "CPU status changes with load" do
expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end)
# Mock health sensors (15, all return error) + network sensors (2)
expect(SnmpMock, :get, 17, fn _, oid, _ ->
case oid do
# Network sensors
"1.3.6.1.4.1.14988.1.1.1.3.0" -> {:ok, {:integer, 10}}
"1.3.6.1.4.1.14988.1.1.13.1.1.1.0" -> {:ok, {:integer, 500}}
# All health sensors return error
_ -> {:error, :no_such_object}
end
end)
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do
@ -455,7 +552,16 @@ defmodule Towerops.Snmp.Profiles.MikrotikTest do
end
test "memory status changes with usage percentage" do
expect(SnmpMock, :get, 15, fn _, _, _ -> {:error, :no_such_object} end)
# Mock health sensors (15, all return error) + network sensors (2)
expect(SnmpMock, :get, 17, fn _, oid, _ ->
case oid do
# Network sensors
"1.3.6.1.4.1.14988.1.1.1.3.0" -> {:ok, {:integer, 10}}
"1.3.6.1.4.1.14988.1.1.13.1.1.1.0" -> {:ok, {:integer, 500}}
# All health sensors return error
_ -> {:error, :no_such_object}
end
end)
stub(SnmpMock, :walk, fn _, oid, _ ->
case oid do

View file

@ -11,26 +11,13 @@ defmodule Towerops.Snmp.Profiles.NetSnmpTest do
@client_opts [ip: "192.168.1.100", community: "public", version: :v2c, port: 161]
describe "discover_system_info/1" do
test "delegates to Base profile" do
# Mock the SNMP calls that Base.discover_system_info makes
# Base profile queries 6 system OIDs - match by OID string
expect(SnmpMock, :get, 6, fn _target, oid, _opts ->
case oid do
"1.3.6.1.2.1.1.1.0" -> {:ok, "Linux ubuntu 5.4.0-42-generic #46-Ubuntu"}
"1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1, 4, 1, 8072]}
"1.3.6.1.2.1.1.3.0" -> {:ok, 123_456}
"1.3.6.1.2.1.1.4.0" -> {:ok, "root@localhost"}
"1.3.6.1.2.1.1.5.0" -> {:ok, "ubuntu-server"}
"1.3.6.1.2.1.1.6.0" -> {:ok, "Data Center"}
_ -> {:error, :unknown_oid}
end
end)
test "returns empty map (no additional system info beyond Base)" do
# NetSnmp profile doesn't query additional system OIDs
# Base profile handles all standard system info discovery
result = NetSnmp.discover_system_info(@client_opts)
assert {:ok, system_info} = result
assert is_map(system_info)
assert system_info.sys_name == "ubuntu-server"
assert system_info == %{}
end
end