diff --git a/Dockerfile b/Dockerfile index c28436bb..f0825cd3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/config/dev.exs b/config/dev.exs index 77f8d4ef..635b2804 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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 diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 0f998f39..93c3a48a 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -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 diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index 79abcc76..2b2362fc 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -3,6 +3,7 @@ kind: Kustomization namespace: towerops resources: - namespace.yaml + - mib-pvc.yaml - deployment.yaml - service.yaml - service-headless.yaml diff --git a/k8s/mib-pvc.yaml b/k8s/mib-pvc.yaml new file mode 100644 index 00000000..158f613c --- /dev/null +++ b/k8s/mib-pvc.yaml @@ -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) diff --git a/lib/towerops/device_profiles.ex b/lib/towerops/device_profiles.ex index 027450e8..9afe6226 100644 --- a/lib/towerops/device_profiles.ex +++ b/lib/towerops/device_profiles.ex @@ -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 diff --git a/lib/towerops/device_profiles/importer.ex b/lib/towerops/device_profiles/importer.ex index e8b0a8df..c95e3acc 100644 --- a/lib/towerops/device_profiles/importer.ex +++ b/lib/towerops/device_profiles/importer.ex @@ -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 diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index c0febb23..de83ccd4 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -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 diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index 68c297a1..165ae270 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -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!() diff --git a/lib/towerops/snmp/mib_translator.ex b/lib/towerops/snmp/mib_translator.ex new file mode 100644 index 00000000..29aea440 --- /dev/null +++ b/lib/towerops/snmp/mib_translator.ex @@ -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 diff --git a/lib/towerops/snmp/poller.ex b/lib/towerops/snmp/poller.ex index 3535c17c..db1a721b 100644 --- a/lib/towerops/snmp/poller.ex +++ b/lib/towerops/snmp/poller.ex @@ -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 diff --git a/lib/towerops/snmp/poller_worker.ex b/lib/towerops/snmp/poller_worker.ex index ce872570..5d455863 100644 --- a/lib/towerops/snmp/poller_worker.ex +++ b/lib/towerops/snmp/poller_worker.ex @@ -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 diff --git a/lib/towerops/snmp/profiles/base.ex b/lib/towerops/snmp/profiles/base.ex index 128cb0ee..19c4ab74 100644 --- a/lib/towerops/snmp/profiles/base.ex +++ b/lib/towerops/snmp/profiles/base.ex @@ -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 diff --git a/lib/towerops/snmp/profiles/dynamic.ex b/lib/towerops/snmp/profiles/dynamic.ex index 0f41705d..6ea6b463 100644 --- a/lib/towerops/snmp/profiles/dynamic.ex +++ b/lib/towerops/snmp/profiles/dynamic.ex @@ -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 diff --git a/lib/towerops/snmp/profiles/mikrotik.ex b/lib/towerops/snmp/profiles/mikrotik.ex index 7cb5f299..e2cf0291 100644 --- a/lib/towerops/snmp/profiles/mikrotik.ex +++ b/lib/towerops/snmp/profiles/mikrotik.ex @@ -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, _} -> diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index 5cc09570..35413e7d 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -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 diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index ec2d0c62..7822921c 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -433,6 +433,76 @@ <% end %> + + <%= if @grouped_transceivers && length(@grouped_transceivers) > 0 do %> +