From d401e0b0360cd6447bbdd8786b389ab4af49ba11 Mon Sep 17 00:00:00 2001
From: Graham McIntire
Date: Thu, 22 Jan 2026 13:05:28 -0600
Subject: [PATCH] mikrotik improvements
---
lib/towerops/devices.ex | 25 +++
lib/towerops/profiles/yaml_profiles.ex | 15 ++
lib/towerops/snmp/discovery.ex | 20 +-
lib/towerops/snmp/mib_translator.ex | 3 +
lib/towerops/snmp/profiles/dynamic.ex | 37 +++-
.../snmp/profiles/vendors/routeros.ex | 200 ++++++++++++++++++
.../components/core_components.ex | 20 +-
lib/towerops_web/live/device_live/form.ex | 106 +++++++---
.../live/device_live/form.html.heex | 26 ++-
test/towerops/snmp/discovery_test.exs | 4 +-
.../snmp/profiles/vendors/routeros_test.exs | 14 ++
11 files changed, 437 insertions(+), 33 deletions(-)
diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex
index a908d09e..1b5fd239 100644
--- a/lib/towerops/devices.ex
+++ b/lib/towerops/devices.ex
@@ -142,6 +142,31 @@ defmodule Towerops.Devices do
end
end
+ @doc """
+ Finds a device by IP address within a site, optionally excluding a specific device ID.
+ Returns nil if no device found.
+ """
+ def get_device_by_ip(ip_address, site_id, exclude_id \\ nil)
+
+ def get_device_by_ip(_ip_address, nil, _exclude_id), do: nil
+
+ def get_device_by_ip(ip_address, site_id, exclude_id) do
+ query =
+ from(d in DeviceSchema,
+ where: d.ip_address == ^ip_address and d.site_id == ^site_id,
+ preload: [:site]
+ )
+
+ query =
+ if exclude_id do
+ from(d in query, where: d.id != ^exclude_id)
+ else
+ query
+ end
+
+ Repo.one(query)
+ end
+
@doc """
Gets a single device belonging to a specific site.
"""
diff --git a/lib/towerops/profiles/yaml_profiles.ex b/lib/towerops/profiles/yaml_profiles.ex
index d227788b..d409da39 100644
--- a/lib/towerops/profiles/yaml_profiles.ex
+++ b/lib/towerops/profiles/yaml_profiles.ex
@@ -148,6 +148,8 @@ defmodule Towerops.Profiles.YamlProfiles do
# Extract all detection blocks with their conditions for per-block matching
detection_blocks: extract_detection_blocks(detection),
device_oids: extract_device_oids(discovery),
+ # Regex to extract hardware model from sysDescr (e.g., "/RouterOS (?.*)/")
+ hardware_regex: extract_hardware_regex(discovery),
# Scalar sensors (direct OID polling)
sensor_oids: extract_sensor_oids(discovery),
# Table-based sensors (OID walks) - all standard sensor types
@@ -321,6 +323,7 @@ defmodule Towerops.Profiles.YamlProfiles do
field_mappings = [
{"serial", :serial_number},
{"version", :firmware_version},
+ {"features", :license_version},
{"hardware", :hardware},
{"lat", :latitude},
{"long", :longitude}
@@ -338,6 +341,18 @@ defmodule Towerops.Profiles.YamlProfiles do
|> Map.new()
end
+ # Extract sysDescr_regex from os module for hardware extraction
+ # Returns a compiled regex if present, nil otherwise
+ defp extract_hardware_regex(yaml) do
+ case get_in(yaml, ["modules", "os", "sysDescr_regex"]) do
+ pattern when is_binary(pattern) ->
+ parse_regex_pattern(pattern)
+
+ _ ->
+ nil
+ end
+ end
+
# All sensor types supported by LibreNMS
@sensor_types ~w(temperature voltage current power fanspeed humidity frequency
dbm signal snr airflow load runtime waterflow pressure cooling charge)
diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex
index 28e67dd1..c7f8ff22 100644
--- a/lib/towerops/snmp/discovery.ex
+++ b/lib/towerops/snmp/discovery.ex
@@ -519,7 +519,12 @@ defmodule Towerops.Snmp.Discovery do
@spec sync_sensors(Device.t(), [sensor_data()]) :: [Sensor.t()]
defp sync_sensors(device, discovered_sensors) do
- # Deduplicate sensors by sensor_index (keep first occurrence)
+ # Deduplicate sensors by OID first - when multiple sensors share the same OID,
+ # prefer the one with a proper description (not containing "MIB::")
+ # This handles cases where YAML profiles and vendor profiles discover the same sensor
+ discovered_sensors = deduplicate_sensors_by_oid(discovered_sensors)
+
+ # Then deduplicate by sensor_index (keep first occurrence)
# This prevents duplicate key errors when profiles generate duplicate indices
discovered_sensors = Enum.uniq_by(discovered_sensors, & &1.sensor_index)
@@ -744,4 +749,17 @@ defmodule Towerops.Snmp.Discovery do
end
defp sanitize_for_json(other), do: other
+
+ # Deduplicate sensors by OID - when multiple sensors share the same OID,
+ # prefer the one with a proper description (not containing "MIB::")
+ # This handles cases where YAML profiles and vendor profiles discover the same sensor
+ defp deduplicate_sensors_by_oid(sensors) do
+ sensors
+ |> Enum.group_by(& &1.sensor_oid)
+ |> Enum.map(fn {_oid, group} ->
+ # Prefer sensor with proper description (no MIB name)
+ Enum.find(group, fn s -> not String.contains?(s.sensor_descr, "MIB::") end) ||
+ List.last(group)
+ end)
+ end
end
diff --git a/lib/towerops/snmp/mib_translator.ex b/lib/towerops/snmp/mib_translator.ex
index 4519e922..4f75a69f 100644
--- a/lib/towerops/snmp/mib_translator.ex
+++ b/lib/towerops/snmp/mib_translator.ex
@@ -31,6 +31,9 @@ defmodule Towerops.Snmp.MibTranslator do
"MIKROTIK-MIB::mtxrSystemDisplayName.0" => "1.3.6.1.4.1.14988.1.1.7.8.0",
"MIKROTIK-MIB::mtxrBoardName.0" => "1.3.6.1.4.1.14988.1.1.7.9.0",
"MIKROTIK-MIB::mtxrLicenseVersion.0" => "1.3.6.1.4.1.14988.1.1.7.5.0",
+ # MikroTik License info (short names used in YAML profiles)
+ "MIKROTIK-MIB::mtxrLicVersion.0" => "1.3.6.1.4.1.14988.1.1.4.3.0",
+ "MIKROTIK-MIB::mtxrLicLevel.0" => "1.3.6.1.4.1.14988.1.1.4.4.0",
# MikroTik Health sensors
"MIKROTIK-MIB::mtxrHlCoreVoltage.0" => "1.3.6.1.4.1.14988.1.1.3.1.0",
"MIKROTIK-MIB::mtxrHlThreeDotThreeVoltage.0" => "1.3.6.1.4.1.14988.1.1.3.2.0",
diff --git a/lib/towerops/snmp/profiles/dynamic.ex b/lib/towerops/snmp/profiles/dynamic.ex
index 5a7afb7c..67263344 100644
--- a/lib/towerops/snmp/profiles/dynamic.ex
+++ b/lib/towerops/snmp/profiles/dynamic.ex
@@ -29,10 +29,11 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
# Format firmware version (MikroTik has special formatting with license level)
firmware_version = format_firmware_version(profile, device_data)
- # Get hardware model - use vendor-specific detection, then YAML hardware OID, then sysDescr
+ # Get hardware model - use YAML hardware OID, then sysDescr_regex capture, then vendor-specific, then sysDescr
model =
- detect_vendor_hardware(profile, client_opts) ||
- Map.get(device_data, :hardware) ||
+ Map.get(device_data, :hardware) ||
+ extract_hardware_from_regex(profile, system_info[:sys_descr]) ||
+ detect_vendor_hardware(profile, client_opts) ||
system_info[:sys_descr]
# Merge with system_info
@@ -93,7 +94,11 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
count_results ++
state_results ++ wireless_results ++ base_sensors
- {:ok, all_sensors}
+ # Deduplicate by OID - when multiple sensors have the same OID,
+ # prefer the one with a proper description (not a MIB name)
+ deduped_sensors = deduplicate_by_oid(all_sensors)
+
+ {:ok, deduped_sensors}
end
@doc """
@@ -128,6 +133,18 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
defp detect_vendor_hardware(_profile, _client_opts), do: nil
+ # Extract hardware model from sysDescr using profile's sysDescr_regex
+ # LibreNMS uses named capture groups like (?...) in the regex
+ defp extract_hardware_from_regex(%{hardware_regex: regex}, sys_descr)
+ when is_struct(regex, Regex) and is_binary(sys_descr) do
+ case Regex.named_captures(regex, sys_descr) do
+ %{"hardware" => hardware} when hardware != "" -> hardware
+ _ -> nil
+ end
+ end
+
+ defp extract_hardware_from_regex(_profile, _sys_descr), do: nil
+
defp format_firmware_version(%{vendor: vendor}, device_data) when vendor in ["MikroTik", "Mikrotik RouterOS"] do
case {Map.get(device_data, :firmware_version), Map.get(device_data, :license_version)} do
{fw, license} when not is_nil(fw) and not is_nil(license) ->
@@ -344,4 +361,16 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
end
defp discover_vendor_wireless_sensors(_profile, _client_opts), do: []
+
+ # Deduplicate sensors by OID - when multiple sensors share the same OID,
+ # prefer the one with a proper description (not containing "MIB::")
+ defp deduplicate_by_oid(sensors) do
+ sensors
+ |> Enum.group_by(& &1.sensor_oid)
+ |> Enum.map(fn {_oid, group} ->
+ # Prefer sensor with proper description (no MIB name)
+ Enum.find(group, fn s -> not String.contains?(s.sensor_descr, "MIB::") end) ||
+ List.last(group)
+ end)
+ end
end
diff --git a/lib/towerops/snmp/profiles/vendors/routeros.ex b/lib/towerops/snmp/profiles/vendors/routeros.ex
index 81c43846..fc9c18f3 100644
--- a/lib/towerops/snmp/profiles/vendors/routeros.ex
+++ b/lib/towerops/snmp/profiles/vendors/routeros.ex
@@ -27,6 +27,23 @@ defmodule Towerops.Snmp.Profiles.Vendors.Routeros do
@wl_60g_table "1.3.6.1.4.1.14988.1.1.1.8.1"
@lte_modem_table "1.3.6.1.4.1.14988.1.1.16.1.1"
+ # mtxrGaugeTable - System health sensors (temperature, voltage, current, power, fan speed)
+ # .2.{idx} = mtxrGaugeName, .3.{idx} = mtxrGaugeValue, .4.{idx} = mtxrGaugeUnit
+ @gauge_table "1.3.6.1.4.1.14988.1.1.3.100.1"
+
+ # mtxrOpticalTable - SFP/optical transceiver sensors
+ # .2.{idx} = mtxrOpticalName, .6.{idx} = Temperature, .7.{idx} = Supply Voltage
+ # .8.{idx} = Tx Bias Current, .9.{idx} = Tx Power, .10.{idx} = Rx Power
+ @optical_table "1.3.6.1.4.1.14988.1.1.19.1.1"
+
+ # mtxrGaugeUnit values
+ @gauge_unit_celsius 1
+ @gauge_unit_rpm 2
+ @gauge_unit_dv 3
+ @gauge_unit_da 4
+ @gauge_unit_dw 5
+ @gauge_unit_status 6
+
@impl true
def profile_names, do: ["routeros"]
@@ -51,6 +68,8 @@ defmodule Towerops.Snmp.Profiles.Vendors.Routeros do
discover_station_sensors(client_opts) ++
discover_60g_sensors(client_opts) ++
discover_lte_sensors(client_opts) ++
+ discover_gauge_sensors(client_opts) ++
+ discover_optical_sensors(client_opts) ++
discover_system_sensors(client_opts)
Logger.debug("RouterOS: Discovered #{length(sensors)} wireless sensors")
@@ -537,6 +556,187 @@ defmodule Towerops.Snmp.Profiles.Vendors.Routeros do
sensors
end
+ # Discover sensors from mtxrGaugeTable (system health: temperature, voltage, current, power, fan speed)
+ defp discover_gauge_sensors(client_opts) do
+ case Client.walk(client_opts, @gauge_table) do
+ {:ok, results} when is_map(results) and map_size(results) > 0 ->
+ parse_gauge_table(results)
+
+ _ ->
+ []
+ end
+ end
+
+ defp parse_gauge_table(results) do
+ # Group by index (last OID component)
+ grouped = group_by_index(results)
+
+ grouped
+ |> Enum.flat_map(fn {idx, values} ->
+ build_gauge_sensor(idx, values)
+ end)
+ |> Enum.reject(&is_nil/1)
+ end
+
+ defp build_gauge_sensor(idx, values) do
+ # .2 = mtxrGaugeName, .3 = mtxrGaugeValue, .4 = mtxrGaugeUnit
+ name = get_string_value(values, 2)
+ value = get_int_value(values, 3)
+ unit_type = get_int_value(values, 4)
+
+ if name && value do
+ {sensor_type, sensor_unit, divisor} = gauge_unit_to_sensor_type(unit_type)
+
+ [
+ %{
+ sensor_type: sensor_type,
+ sensor_index: "mikrotik_gauge_#{idx}",
+ sensor_oid: "#{@gauge_table}.3.#{idx}",
+ sensor_descr: name,
+ sensor_unit: sensor_unit,
+ sensor_divisor: divisor,
+ last_value: value / divisor
+ }
+ ]
+ else
+ []
+ end
+ end
+
+ # Map mtxrGaugeUnit value to sensor type, unit string, and divisor
+ defp gauge_unit_to_sensor_type(@gauge_unit_celsius), do: {"temperature", "°C", 10}
+ defp gauge_unit_to_sensor_type(@gauge_unit_rpm), do: {"fanspeed", "RPM", 1}
+ defp gauge_unit_to_sensor_type(@gauge_unit_dv), do: {"voltage", "V", 10}
+ defp gauge_unit_to_sensor_type(@gauge_unit_da), do: {"current", "A", 1000}
+ defp gauge_unit_to_sensor_type(@gauge_unit_dw), do: {"power", "W", 10}
+ defp gauge_unit_to_sensor_type(@gauge_unit_status), do: {"state", "", 1}
+ defp gauge_unit_to_sensor_type(_), do: {"unknown", "", 1}
+
+ # Discover sensors from mtxrOpticalTable (SFP/optical transceiver sensors)
+ defp discover_optical_sensors(client_opts) do
+ case Client.walk(client_opts, @optical_table) do
+ {:ok, results} when is_map(results) and map_size(results) > 0 ->
+ parse_optical_table(results)
+
+ _ ->
+ []
+ end
+ end
+
+ defp parse_optical_table(results) do
+ # Group by index (last OID component)
+ grouped = group_by_index(results)
+
+ Enum.flat_map(grouped, fn {idx, values} ->
+ build_optical_sensors(idx, values)
+ end)
+ end
+
+ defp build_optical_sensors(idx, values) do
+ # .2 = mtxrOpticalName, .6 = Temperature, .7 = Supply Voltage
+ # .8 = Tx Bias Current, .9 = Tx Power, .10 = Rx Power
+ name = get_string_value(values, 2) || "SFP #{idx}"
+
+ sensors = []
+
+ # Temperature (.6)
+ sensors =
+ if temp = get_int_value(values, 6) do
+ [
+ %{
+ sensor_type: "temperature",
+ sensor_index: "mikrotik_optical_temp_#{idx}",
+ sensor_oid: "#{@optical_table}.6.#{idx}",
+ sensor_descr: "#{name} Temperature",
+ sensor_unit: "°C",
+ sensor_divisor: 1,
+ last_value: temp / 1
+ }
+ | sensors
+ ]
+ else
+ sensors
+ end
+
+ # Supply Voltage (.7) - in mV, convert to V
+ sensors =
+ if voltage = get_int_value(values, 7) do
+ [
+ %{
+ sensor_type: "voltage",
+ sensor_index: "mikrotik_optical_voltage_#{idx}",
+ sensor_oid: "#{@optical_table}.7.#{idx}",
+ sensor_descr: "#{name} Supply Voltage",
+ sensor_unit: "V",
+ sensor_divisor: 1000,
+ last_value: voltage / 1000
+ }
+ | sensors
+ ]
+ else
+ sensors
+ end
+
+ # Tx Bias Current (.8) - in uA, convert to mA
+ sensors =
+ if bias = get_int_value(values, 8) do
+ [
+ %{
+ sensor_type: "current",
+ sensor_index: "mikrotik_optical_tx_bias_#{idx}",
+ sensor_oid: "#{@optical_table}.8.#{idx}",
+ sensor_descr: "#{name} Tx Bias",
+ sensor_unit: "mA",
+ sensor_divisor: 1000,
+ last_value: bias / 1000
+ }
+ | sensors
+ ]
+ else
+ sensors
+ end
+
+ # Tx Power (.9) - in thousandths of dBm
+ sensors =
+ if tx_power = get_int_value(values, 9) do
+ [
+ %{
+ sensor_type: "dbm",
+ sensor_index: "mikrotik_optical_tx_power_#{idx}",
+ sensor_oid: "#{@optical_table}.9.#{idx}",
+ sensor_descr: "#{name} Tx Power",
+ sensor_unit: "dBm",
+ sensor_divisor: 1000,
+ last_value: tx_power / 1000
+ }
+ | sensors
+ ]
+ else
+ sensors
+ end
+
+ # Rx Power (.10) - in thousandths of dBm
+ sensors =
+ if rx_power = get_int_value(values, 10) do
+ [
+ %{
+ sensor_type: "dbm",
+ sensor_index: "mikrotik_optical_rx_power_#{idx}",
+ sensor_oid: "#{@optical_table}.10.#{idx}",
+ sensor_descr: "#{name} Rx Power",
+ sensor_unit: "dBm",
+ sensor_divisor: 1000,
+ last_value: rx_power / 1000
+ }
+ | sensors
+ ]
+ else
+ sensors
+ end
+
+ sensors
+ end
+
# Discover system-level sensors (DHCP leases, PPPoE sessions)
defp discover_system_sensors(client_opts) do
Vendor.fetch_sensors(wireless_oid_defs(), client_opts)
diff --git a/lib/towerops_web/components/core_components.ex b/lib/towerops_web/components/core_components.ex
index 8474ba02..18b56dd0 100644
--- a/lib/towerops_web/components/core_components.ex
+++ b/lib/towerops_web/components/core_components.ex
@@ -84,7 +84,7 @@ defmodule ToweropsWeb.CoreComponents do
"text-sm text-gray-500 dark:text-gray-400",
@title && "mt-1"
]}>
- {msg}
+ {render_flash_message(msg)}
@@ -105,6 +105,24 @@ defmodule ToweropsWeb.CoreComponents do
"""
end
+ # Renders flash message content, supporting both plain strings and maps with links
+ # Map format: %{text: "Message", link_text: "Link", link_url: "/path"}
+ defp render_flash_message(%{text: text, link_text: link_text, link_url: link_url}) do
+ assigns = %{text: text, link_text: link_text, link_url: link_url}
+
+ ~H"""
+ {@text}
+ <.link
+ navigate={@link_url}
+ class="font-medium text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
+ >
+ {@link_text}
+
+ """
+ end
+
+ defp render_flash_message(msg), do: msg
+
@doc """
Renders a button with navigation support.
diff --git a/lib/towerops_web/live/device_live/form.ex b/lib/towerops_web/live/device_live/form.ex
index 2a87ee0d..319d0a35 100644
--- a/lib/towerops_web/live/device_live/form.ex
+++ b/lib/towerops_web/live/device_live/form.ex
@@ -28,7 +28,8 @@ defmodule ToweropsWeb.DeviceLive.Form do
|> assign(:available_sites, sites)
|> assign(:available_agents, agents)
|> assign(:preselected_site_id, params["site_id"])
- |> assign(:snmp_test_result, nil)}
+ |> assign(:snmp_test_result, nil)
+ |> assign(:duplicate_device, nil)}
end
end
@@ -143,16 +144,32 @@ defmodule ToweropsWeb.DeviceLive.Form do
@impl true
def handle_event("validate", %{"device" => device_params}, socket) do
+ device_params = sanitize_device_params(device_params)
+
changeset =
socket.assigns.device
|> Devices.change_device(device_params)
|> Map.put(:action, :validate)
- {:noreply, assign(socket, :form, to_form(changeset))}
+ # Check for duplicate IP address within the same site
+ ip_address = device_params["ip_address"]
+ site_id = device_params["site_id"]
+ exclude_id = if socket.assigns.live_action == :edit, do: socket.assigns.device.id
+
+ duplicate_device =
+ if ip_address && String.trim(ip_address) != "" && site_id do
+ Devices.get_device_by_ip(String.trim(ip_address), site_id, exclude_id)
+ end
+
+ {:noreply,
+ socket
+ |> assign(:form, to_form(changeset))
+ |> assign(:duplicate_device, duplicate_device)}
end
@impl true
def handle_event("save", %{"device" => device_params}, socket) do
+ device_params = sanitize_device_params(device_params)
save_device(socket, socket.assigns.live_action, device_params)
end
@@ -279,12 +296,24 @@ defmodule ToweropsWeb.DeviceLive.Form do
end
defp handle_device_creation(device) do
+ device_label = device.name || device.ip_address
+
+ base_message =
+ if device.snmp_enabled do
+ "Device created. SNMP discovery started. "
+ else
+ "Device created. "
+ end
+
if device.snmp_enabled do
_ = enqueue_discovery(device.id)
- "Device created successfully. SNMP discovery started in background."
- else
- "Device created successfully"
end
+
+ %{
+ text: base_message,
+ link_text: device_label,
+ link_url: "/devices/#{device.id}"
+ }
end
defp handle_device_update(old_device, device) do
@@ -335,7 +364,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
# If device community is nil or empty, resolve from site/org hierarchy
effective_community =
if is_nil(device_community) or device_community == "" do
- resolve_inherited_community(assigns)
+ resolve_inherited_community(params, assigns)
else
device_community
end
@@ -348,27 +377,45 @@ defmodule ToweropsWeb.DeviceLive.Form do
}
end
- defp resolve_inherited_community(assigns) do
- cond do
- # For new devices, check preselected site
- assigns[:preselected_site_id] ->
- site = Enum.find(assigns.available_sites, &(&1.id == assigns.preselected_site_id))
- site && site.snmp_community
-
- # For edit mode, use effective SNMP community
- assigns[:effective_snmp_community] && assigns.effective_snmp_community != "(not set)" ->
- assigns.effective_snmp_community
-
- # Fallback: check if there's only one site
- length(assigns[:available_sites] || []) == 1 ->
- site = List.first(assigns.available_sites)
- site && site.snmp_community
-
- true ->
- nil
+ defp resolve_inherited_community(params, assigns) do
+ # For edit mode, use effective SNMP community (already resolved)
+ if has_effective_community?(assigns) do
+ assigns.effective_snmp_community
+ else
+ resolve_community_from_hierarchy(params, assigns)
end
end
+ defp has_effective_community?(assigns) do
+ assigns[:effective_snmp_community] && assigns.effective_snmp_community != "(not set)"
+ end
+
+ defp resolve_community_from_hierarchy(params, assigns) do
+ selected_site_id = get_selected_site_id(params, assigns)
+ site_community = get_site_community(selected_site_id, assigns)
+ site_community || get_org_community(assigns)
+ end
+
+ defp get_selected_site_id(params, assigns) do
+ sites = assigns[:available_sites] || []
+
+ params["site_id"] ||
+ assigns[:preselected_site_id] ||
+ (length(sites) == 1 && List.first(sites).id)
+ end
+
+ defp get_site_community(nil, _assigns), do: nil
+
+ defp get_site_community(site_id, assigns) do
+ site = Enum.find(assigns.available_sites, &(&1.id == site_id))
+ if site && site.snmp_community && site.snmp_community != "", do: site.snmp_community
+ end
+
+ defp get_org_community(assigns) do
+ org = assigns[:organization]
+ org && org.snmp_community
+ end
+
@spec normalize_port(integer() | String.t() | any()) :: integer()
defp normalize_port(port) when is_integer(port) and port >= 1 and port <= 65_535, do: port
defp normalize_port(port) when is_integer(port), do: 161
@@ -451,4 +498,15 @@ defmodule ToweropsWeb.DeviceLive.Form do
end
defp handle_agent_assignment(_device_id, _), do: :ok
+
+ # Sanitize device params - trim whitespace from IP address
+ defp sanitize_device_params(params) do
+ case Map.get(params, "ip_address") do
+ ip when is_binary(ip) ->
+ Map.put(params, "ip_address", String.trim(ip))
+
+ _ ->
+ params
+ end
+ end
end
diff --git a/lib/towerops_web/live/device_live/form.html.heex b/lib/towerops_web/live/device_live/form.html.heex
index ad6ac145..6da03c59 100644
--- a/lib/towerops_web/live/device_live/form.html.heex
+++ b/lib/towerops_web/live/device_live/form.html.heex
@@ -44,6 +44,24 @@
<.input field={@form[:ip_address]} type="text" label="IP Address" required />
+
+
+ <.icon name="hero-exclamation-triangle" class="h-5 w-5 flex-shrink-0" />
+
+ A device with this IP address already exists:
+ <.link
+ navigate={~p"/devices/#{@duplicate_device.id}"}
+ class="font-medium underline hover:text-amber-900 dark:hover:text-amber-100"
+ >
+ {@duplicate_device.name || @duplicate_device.ip_address}
+
+
+
+
+
<.input
field={@form[:site_id]}
type="select"
@@ -240,7 +258,13 @@
- <.button phx-disable-with="Saving..." variant="primary">Save Device
+ <.button
+ phx-disable-with="Saving..."
+ variant="primary"
+ disabled={@duplicate_device != nil}
+ >
+ Save Device
+
<.button navigate={~p"/devices"}>Cancel
diff --git a/test/towerops/snmp/discovery_test.exs b/test/towerops/snmp/discovery_test.exs
index e6afa625..2787d9f6 100644
--- a/test/towerops/snmp/discovery_test.exs
+++ b/test/towerops/snmp/discovery_test.exs
@@ -190,9 +190,9 @@ defmodule Towerops.Snmp.DiscoveryTest do
assert {:ok, device} = Discovery.discover_device(device)
# YAML profile "routeros" returns "Mikrotik RouterOS" as vendor
- # Model comes from sysDescr when using YAML profiles
+ # Model comes from sysDescr_regex capture (hardware group extracts model from sysDescr)
assert device.manufacturer == "Mikrotik RouterOS"
- assert device.model == "RouterOS RB750"
+ assert device.model == "RB750"
assert device.sys_name == "router1"
# Verify interfaces were created
diff --git a/test/towerops/snmp/profiles/vendors/routeros_test.exs b/test/towerops/snmp/profiles/vendors/routeros_test.exs
index 98bd6ed3..6ca7ac68 100644
--- a/test/towerops/snmp/profiles/vendors/routeros_test.exs
+++ b/test/towerops/snmp/profiles/vendors/routeros_test.exs
@@ -22,6 +22,8 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do
@wl_stat_table "1.3.6.1.4.1.14988.1.1.1.1.1"
@wl_60g_table "1.3.6.1.4.1.14988.1.1.1.8.1"
@lte_modem_table "1.3.6.1.4.1.14988.1.1.16.1.1"
+ @gauge_table "1.3.6.1.4.1.14988.1.1.3.100.1"
+ @optical_table "1.3.6.1.4.1.14988.1.1.19.1.1"
describe "profile_names/0" do
test "returns expected profile names" do
@@ -113,6 +115,8 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do
expect(SnmpMock, :walk, fn _, @wl_stat_table, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, @wl_60g_table, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, @lte_modem_table, _ -> {:ok, []} end)
+ expect(SnmpMock, :walk, fn _, @gauge_table, _ -> {:ok, []} end)
+ expect(SnmpMock, :walk, fn _, @optical_table, _ -> {:ok, []} end)
# System sensors (DHCP leases and PPPoE)
expect(SnmpMock, :get, 2, fn _, _oid, _ -> {:ok, 100} end)
@@ -150,6 +154,8 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do
expect(SnmpMock, :walk, fn _, @wl_60g_table, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, @lte_modem_table, _ -> {:ok, []} end)
+ expect(SnmpMock, :walk, fn _, @gauge_table, _ -> {:ok, []} end)
+ expect(SnmpMock, :walk, fn _, @optical_table, _ -> {:ok, []} end)
expect(SnmpMock, :get, 2, fn _, _oid, _ -> {:ok, 50} end)
sensors = Routeros.discover_wireless_sensors(@client_opts)
@@ -180,6 +186,8 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do
end)
expect(SnmpMock, :walk, fn _, @lte_modem_table, _ -> {:ok, []} end)
+ expect(SnmpMock, :walk, fn _, @gauge_table, _ -> {:ok, []} end)
+ expect(SnmpMock, :walk, fn _, @optical_table, _ -> {:ok, []} end)
expect(SnmpMock, :get, 2, fn _, _oid, _ -> {:ok, 25} end)
sensors = Routeros.discover_wireless_sensors(@client_opts)
@@ -209,6 +217,8 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do
]}
end)
+ expect(SnmpMock, :walk, fn _, @gauge_table, _ -> {:ok, []} end)
+ expect(SnmpMock, :walk, fn _, @optical_table, _ -> {:ok, []} end)
expect(SnmpMock, :get, 2, fn _, _oid, _ -> {:ok, 10} end)
sensors = Routeros.discover_wireless_sensors(@client_opts)
@@ -228,6 +238,8 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do
expect(SnmpMock, :walk, fn _, @wl_stat_table, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, @wl_60g_table, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, @lte_modem_table, _ -> {:ok, []} end)
+ expect(SnmpMock, :walk, fn _, @gauge_table, _ -> {:ok, []} end)
+ expect(SnmpMock, :walk, fn _, @optical_table, _ -> {:ok, []} end)
# System sensors (DHCP leases and PPPoE)
expect(SnmpMock, :get, 2, fn _, _oid, _ -> {:ok, 100} end)
@@ -247,6 +259,8 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do
expect(SnmpMock, :walk, fn _, @wl_stat_table, _ -> {:error, :timeout} end)
expect(SnmpMock, :walk, fn _, @wl_60g_table, _ -> {:error, :timeout} end)
expect(SnmpMock, :walk, fn _, @lte_modem_table, _ -> {:error, :timeout} end)
+ expect(SnmpMock, :walk, fn _, @gauge_table, _ -> {:error, :timeout} end)
+ expect(SnmpMock, :walk, fn _, @optical_table, _ -> {:error, :timeout} end)
expect(SnmpMock, :get, 2, fn _, _, _ -> {:error, :timeout} end)
sensors = Routeros.discover_wireless_sensors(@client_opts)