From 96fc48802359b99ca0496670d9839ab552721e85 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 22 Jan 2026 16:53:01 -0600 Subject: [PATCH] sanitize snmp data and fix some cpu things --- lib/towerops/monitoring/check.ex | 2 +- lib/towerops/profiles/yaml_profiles.ex | 11 +- lib/towerops/snmp.ex | 96 +++- lib/towerops/snmp/device.ex | 3 + lib/towerops/snmp/discovery.ex | 148 +++++ lib/towerops/snmp/neighbor_discovery.ex | 32 +- lib/towerops/snmp/poller_worker.ex | 178 +++++- lib/towerops/snmp/processor.ex | 79 +++ lib/towerops/snmp/processor_reading.ex | 44 ++ lib/towerops/snmp/profiles/base.ex | 527 +++++++++++++++++- lib/towerops/snmp/profiles/dynamic.ex | 25 +- lib/towerops/snmp/sanitizer.ex | 183 ++++++ lib/towerops_web/components/layouts.ex | 2 +- lib/towerops_web/live/device_live/show.ex | 63 ++- .../live/device_live/show.html.heex | 206 ++++++- lib/towerops_web/live/graph_live/show.ex | 16 +- .../20260122214253_create_snmp_processors.exs | 24 + .../20260122223758_add_processor_readings.exs | 22 + priv/static/android-icon-144x144.png | Bin 0 -> 1963 bytes priv/static/android-icon-192x192.png | Bin 0 -> 1670 bytes priv/static/android-icon-36x36.png | Bin 0 -> 1087 bytes priv/static/android-icon-48x48.png | Bin 0 -> 1180 bytes priv/static/android-icon-72x72.png | Bin 0 -> 1285 bytes priv/static/android-icon-96x96.png | Bin 0 -> 1508 bytes priv/static/apple-icon-114x114.png | Bin 0 -> 1592 bytes priv/static/apple-icon-120x120.png | Bin 0 -> 1658 bytes priv/static/apple-icon-144x144.png | Bin 0 -> 1963 bytes priv/static/apple-icon-152x152.png | Bin 0 -> 2004 bytes priv/static/apple-icon-180x180.png | Bin 0 -> 2419 bytes priv/static/apple-icon-57x57.png | Bin 0 -> 1179 bytes priv/static/apple-icon-60x60.png | Bin 0 -> 1187 bytes priv/static/apple-icon-72x72.png | Bin 0 -> 1285 bytes priv/static/apple-icon-76x76.png | Bin 0 -> 1309 bytes priv/static/apple-icon-precomposed.png | Bin 0 -> 2208 bytes priv/static/apple-icon.png | Bin 0 -> 2208 bytes priv/static/favicon-16x16.png | Bin 0 -> 843 bytes priv/static/favicon-32x32.png | Bin 0 -> 990 bytes priv/static/favicon-96x96.png | Bin 0 -> 1508 bytes priv/static/favicon.ico | Bin 1150 -> 1150 bytes priv/static/ms-icon-144x144.png | Bin 0 -> 1963 bytes priv/static/ms-icon-150x150.png | Bin 0 -> 1966 bytes priv/static/ms-icon-310x310.png | Bin 0 -> 4428 bytes priv/static/ms-icon-70x70.png | Bin 0 -> 1283 bytes test/towerops/monitoring/check_test.exs | 3 +- test/towerops/snmp/processor_test.exs | 214 +++++++ test/towerops/snmp/profiles/base_test.exs | 273 ++++++++- test/towerops/snmp/sanitizer_test.exs | 137 +++++ .../workers/discovery_worker_test.exs | 19 +- 48 files changed, 2230 insertions(+), 77 deletions(-) create mode 100644 lib/towerops/snmp/processor.ex create mode 100644 lib/towerops/snmp/processor_reading.ex create mode 100644 lib/towerops/snmp/sanitizer.ex create mode 100644 priv/repo/migrations/20260122214253_create_snmp_processors.exs create mode 100644 priv/repo/migrations/20260122223758_add_processor_readings.exs create mode 100644 priv/static/android-icon-144x144.png create mode 100644 priv/static/android-icon-192x192.png create mode 100644 priv/static/android-icon-36x36.png create mode 100644 priv/static/android-icon-48x48.png create mode 100644 priv/static/android-icon-72x72.png create mode 100644 priv/static/android-icon-96x96.png create mode 100644 priv/static/apple-icon-114x114.png create mode 100644 priv/static/apple-icon-120x120.png create mode 100644 priv/static/apple-icon-144x144.png create mode 100644 priv/static/apple-icon-152x152.png create mode 100644 priv/static/apple-icon-180x180.png create mode 100644 priv/static/apple-icon-57x57.png create mode 100644 priv/static/apple-icon-60x60.png create mode 100644 priv/static/apple-icon-72x72.png create mode 100644 priv/static/apple-icon-76x76.png create mode 100644 priv/static/apple-icon-precomposed.png create mode 100644 priv/static/apple-icon.png create mode 100644 priv/static/favicon-16x16.png create mode 100644 priv/static/favicon-32x32.png create mode 100644 priv/static/favicon-96x96.png create mode 100644 priv/static/ms-icon-144x144.png create mode 100644 priv/static/ms-icon-150x150.png create mode 100644 priv/static/ms-icon-310x310.png create mode 100644 priv/static/ms-icon-70x70.png create mode 100644 test/towerops/snmp/processor_test.exs create mode 100644 test/towerops/snmp/sanitizer_test.exs diff --git a/lib/towerops/monitoring/check.ex b/lib/towerops/monitoring/check.ex index 00247a05..e629345b 100644 --- a/lib/towerops/monitoring/check.ex +++ b/lib/towerops/monitoring/check.ex @@ -25,6 +25,6 @@ defmodule Towerops.Monitoring.Check do check |> cast(attrs, [:device_id, :status, :response_time_ms, :checked_at]) |> validate_required([:device_id, :status, :checked_at]) - |> foreign_key_constraint(:device_id) + |> foreign_key_constraint(:device_id, match: :suffix) end end diff --git a/lib/towerops/profiles/yaml_profiles.ex b/lib/towerops/profiles/yaml_profiles.ex index d409da39..ed28e0c8 100644 --- a/lib/towerops/profiles/yaml_profiles.ex +++ b/lib/towerops/profiles/yaml_profiles.ex @@ -441,8 +441,17 @@ defmodule Towerops.Profiles.YamlProfiles do default descr when is_binary(descr) -> + # Remove template variables like {{ MIKROTIK-MIB::mtxrOpticalName }} cleaned = descr |> String.replace(~r/\{\{ [^}]+ \}\}/, "") |> String.trim() - if cleaned == "", do: default, else: cleaned + + # If the cleaned descr is a MIB symbolic name (contains "::"), use the default + # MIB names like "MIKROTIK-MIB::mtxrOpticalName" should be resolved to actual values + # by vendor modules, not used as literal descriptions + cond do + cleaned == "" -> default + String.contains?(cleaned, "::") -> default + true -> cleaned + end _ -> default diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index 8f7c01e5..7a30bf56 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -14,7 +14,10 @@ defmodule Towerops.Snmp do alias Towerops.Snmp.Discovery alias Towerops.Snmp.Interface alias Towerops.Snmp.InterfaceStat + alias Towerops.Snmp.IpAddress alias Towerops.Snmp.Neighbor + alias Towerops.Snmp.Processor + alias Towerops.Snmp.ProcessorReading alias Towerops.Snmp.Sensor alias Towerops.Snmp.SensorReading alias Towerops.Snmp.StateSensor @@ -96,7 +99,7 @@ defmodule Towerops.Snmp do def get_device_with_associations(device_id) do Device |> where([d], d.device_id == ^device_id) - |> preload([:interfaces, :sensors, :state_sensors]) + |> preload([:interfaces, :sensors, :state_sensors, :processors]) |> Repo.one() end @@ -539,4 +542,95 @@ defmodule Towerops.Snmp do def get_vlan(vlan_id) do Repo.get(Vlan, vlan_id) end + + # IP Address queries + + @doc """ + Lists all IP addresses for a device (across all its interfaces). + """ + def list_ip_addresses(snmp_device_id) do + IpAddress + |> join(:inner, [ip], i in Interface, on: ip.snmp_interface_id == i.id) + |> where([ip, i], i.snmp_device_id == ^snmp_device_id) + |> preload(:snmp_interface) + |> order_by([ip], [ip.ip_type, ip.ip_address]) + |> Repo.all() + end + + @doc """ + Lists IP addresses for a specific interface. + """ + def list_interface_ip_addresses(interface_id) do + IpAddress + |> where([ip], ip.snmp_interface_id == ^interface_id) + |> order_by([ip], [ip.ip_type, ip.ip_address]) + |> Repo.all() + end + + # Processor queries + + @doc """ + Lists all processors for a device. + """ + def list_processors(snmp_device_id) do + Processor + |> where([p], p.snmp_device_id == ^snmp_device_id) + |> order_by([p], p.processor_index) + |> Repo.all() + end + + @doc """ + Gets a specific processor. + """ + def get_processor(processor_id) do + Repo.get(Processor, processor_id) + end + + @doc """ + Updates a processor. + """ + def update_processor(processor, attrs) do + processor + |> Processor.changeset(attrs) + |> Repo.update() + end + + # Processor reading queries + + @doc """ + Gets recent processor readings for a processor. + + ## Options + + - `:limit` - Maximum number of readings to return (default: 100) + - `:since` - Only return readings after this datetime + """ + def get_processor_readings(processor_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 100) + since = Keyword.get(opts, :since) + + query = + ProcessorReading + |> where([r], r.processor_id == ^processor_id) + |> order_by([r], desc: r.checked_at) + |> limit(^limit) + + query = + if since do + where(query, [r], r.checked_at >= ^since) + else + query + end + + Repo.all(query) + end + + @doc """ + Records a new processor reading. + """ + def create_processor_reading(attrs) do + %ProcessorReading{} + |> ProcessorReading.changeset(attrs) + |> Repo.insert() + end end diff --git a/lib/towerops/snmp/device.ex b/lib/towerops/snmp/device.ex index f48be436..a4743862 100644 --- a/lib/towerops/snmp/device.ex +++ b/lib/towerops/snmp/device.ex @@ -12,6 +12,7 @@ defmodule Towerops.Snmp.Device do alias Ecto.Association.NotLoaded alias Towerops.Devices.Device, as: DeviceSchema alias Towerops.Snmp.Interface + alias Towerops.Snmp.Processor alias Towerops.Snmp.Sensor alias Towerops.Snmp.StateSensor @@ -34,6 +35,7 @@ defmodule Towerops.Snmp.Device do belongs_to :device, DeviceSchema has_many :interfaces, Interface, foreign_key: :snmp_device_id + has_many :processors, Processor, foreign_key: :snmp_device_id has_many :sensors, Sensor, foreign_key: :snmp_device_id has_many :state_sensors, StateSensor, foreign_key: :snmp_device_id @@ -57,6 +59,7 @@ defmodule Towerops.Snmp.Device do device_id: Ecto.UUID.t(), device: NotLoaded.t() | DeviceSchema.t(), interfaces: NotLoaded.t() | [Interface.t()], + processors: NotLoaded.t() | [Processor.t()], sensors: NotLoaded.t() | [Sensor.t()], state_sensors: NotLoaded.t() | [StateSensor.t()], inserted_at: DateTime.t(), diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index c7f8ff22..9f00201a 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -22,7 +22,9 @@ defmodule Towerops.Snmp.Discovery do alias Towerops.Snmp.DeferredDiscovery alias Towerops.Snmp.Device alias Towerops.Snmp.Interface + alias Towerops.Snmp.IpAddress alias Towerops.Snmp.NeighborDiscovery + alias Towerops.Snmp.Processor alias Towerops.Snmp.Profiles.Base alias Towerops.Snmp.Profiles.Dynamic alias Towerops.Snmp.Sensor @@ -127,7 +129,11 @@ defmodule Towerops.Snmp.Discovery do {:ok, interfaces} <- discover_interfaces_with_timeout(client_opts, profile, timeouts), {:ok, sensors} <- discover_sensors_with_timeout(client_opts, profile, timeouts), {:ok, vlans} <- discover_vlans_with_timeout(client_opts, profile, timeouts), + {:ok, ip_addresses} <- discover_ip_addresses_with_timeout(client_opts, timeouts), + {:ok, processors} <- discover_processors_with_timeout(client_opts, timeouts), {:ok, discovered_device} <- save_discovery_results(device, device_info, interfaces, sensors, vlans), + :ok <- sync_ip_addresses(discovered_device, ip_addresses), + :ok <- sync_processors(discovered_device, processors), {:ok, neighbors} <- discover_neighbors_with_timeout(client_opts, discovered_device.interfaces, timeouts), :ok <- save_neighbors(discovered_device.device_id, neighbors) do _ = update_device_discovery_time(device) @@ -196,6 +202,26 @@ defmodule Towerops.Snmp.Discovery do ) end + # IP address discovery with timeout - falls back to empty list on timeout + defp discover_ip_addresses_with_timeout(client_opts, timeouts) do + DeferredDiscovery.slow_check( + client_opts, + fn -> Base.discover_all_ip_addresses(client_opts) end, + timeout: timeouts[:slow], + default: [] + ) + end + + # Processor discovery with timeout - falls back to empty list on timeout + defp discover_processors_with_timeout(client_opts, timeouts) do + DeferredDiscovery.slow_check( + client_opts, + fn -> Base.discover_processors(client_opts) end, + timeout: timeouts[:slow], + default: [] + ) + end + @doc """ Runs discovery for all SNMP-enabled devices in an organization. Returns a summary of successful and failed discoveries. @@ -614,6 +640,128 @@ defmodule Towerops.Snmp.Discovery do end) end + @spec sync_ip_addresses(Device.t(), [map()]) :: :ok + defp sync_ip_addresses(device, discovered_ip_addresses) do + # Build a map of if_index to interface ID from the device's interfaces + if_index_to_interface = + Map.new(device.interfaces, fn iface -> {iface.if_index, iface.id} end) + + # Get existing IP addresses for all interfaces of this device + interface_ids = Map.values(if_index_to_interface) + + existing_ip_addresses = + from(ip in IpAddress, where: ip.snmp_interface_id in ^interface_ids) + |> Repo.all() + |> Map.new(fn ip -> {{ip.snmp_interface_id, ip.ip_address}, ip} end) + + # Track which IP addresses we've seen + discovered_keys = + discovered_ip_addresses + |> Enum.map(fn ip_data -> + interface_id = Map.get(if_index_to_interface, ip_data.if_index) + {interface_id, ip_data.ip_address} + end) + |> Enum.reject(fn {iface_id, _} -> is_nil(iface_id) end) + |> MapSet.new() + + # Delete IP addresses that no longer exist + existing_keys = MapSet.new(Map.keys(existing_ip_addresses)) + removed_keys = MapSet.difference(existing_keys, discovered_keys) + + if MapSet.size(removed_keys) > 0 do + removed_list = MapSet.to_list(removed_keys) + + Enum.each(removed_list, fn {interface_id, ip_address} -> + Repo.delete_all( + from ip in IpAddress, + where: ip.snmp_interface_id == ^interface_id and ip.ip_address == ^ip_address + ) + end) + + Logger.debug("Deleted #{MapSet.size(removed_keys)} removed IP addresses") + end + + # Upsert each discovered IP address + Enum.each(discovered_ip_addresses, fn ip_data -> + upsert_ip_address(ip_data, if_index_to_interface, existing_ip_addresses) + end) + + Logger.info("Synced #{length(discovered_ip_addresses)} IP addresses for device") + :ok + end + + defp upsert_ip_address(ip_data, if_index_to_interface, existing_ip_addresses) do + interface_id = Map.get(if_index_to_interface, ip_data.if_index) + + if interface_id do + ip_attrs = Map.put(ip_data, :snmp_interface_id, interface_id) + do_upsert_ip_address(ip_attrs, existing_ip_addresses, interface_id, ip_data.ip_address) + end + end + + defp do_upsert_ip_address(ip_attrs, existing_ip_addresses, interface_id, ip_address) do + case Map.get(existing_ip_addresses, {interface_id, ip_address}) do + nil -> + # New IP address - insert + %IpAddress{} + |> IpAddress.changeset(ip_attrs) + |> Repo.insert!() + + existing -> + # Existing IP address - update + existing + |> IpAddress.changeset(ip_attrs) + |> Repo.update!() + end + end + + @spec sync_processors(Device.t(), [map()]) :: :ok + defp sync_processors(device, discovered_processors) do + # Get existing processors for this device, indexed by processor_index + existing_processors = + from(p in Processor, where: p.snmp_device_id == ^device.id) + |> Repo.all() + |> Map.new(&{&1.processor_index, &1}) + + # Get the set of discovered processor_indices + discovered_indices = MapSet.new(discovered_processors, & &1.processor_index) + + # Delete processors that no longer exist on the device + existing_indices = MapSet.new(Map.keys(existing_processors)) + removed_indices = MapSet.difference(existing_indices, discovered_indices) + + if MapSet.size(removed_indices) > 0 do + removed_list = MapSet.to_list(removed_indices) + + Repo.delete_all( + from p in Processor, + where: p.snmp_device_id == ^device.id and p.processor_index in ^removed_list + ) + + Logger.debug("Deleted #{MapSet.size(removed_indices)} removed processors") + end + + # Upsert each discovered processor + Enum.each(discovered_processors, fn processor_data -> + case Map.get(existing_processors, processor_data.processor_index) do + nil -> + # New processor - insert + %Processor{} + |> Processor.changeset(Map.put(processor_data, :snmp_device_id, device.id)) + |> Repo.insert!() + + existing -> + # Existing processor - update + existing + |> Processor.changeset(processor_data) + |> Repo.update!() + end + end) + + Logger.info("Synced #{length(discovered_processors)} processors for device") + :ok + end + @spec update_device_discovery_time(DeviceSchema.t()) :: {:ok, DeviceSchema.t()} | {:error, Ecto.Changeset.t()} defp update_device_discovery_time(device) do diff --git a/lib/towerops/snmp/neighbor_discovery.ex b/lib/towerops/snmp/neighbor_discovery.ex index b68b699a..bd46f725 100644 --- a/lib/towerops/snmp/neighbor_discovery.ex +++ b/lib/towerops/snmp/neighbor_discovery.ex @@ -8,6 +8,7 @@ defmodule Towerops.Snmp.NeighborDiscovery do """ alias Towerops.Snmp.Client + alias Towerops.Snmp.Sanitizer require Logger @@ -125,36 +126,19 @@ defmodule Towerops.Snmp.NeighborDiscovery do protocol: protocol, interface_id: interface.id, device_id: interface.device_id, - remote_chassis_id: sanitize_string_field(neighbor_map[:remote_chassis_id]), - remote_system_name: sanitize_string_field(neighbor_map[:remote_system_name]), - remote_system_description: sanitize_string_field(neighbor_map[:remote_system_description]), - remote_platform: sanitize_string_field(neighbor_map[:remote_platform]), - remote_port_id: sanitize_string_field(neighbor_map[:remote_port_id]), - remote_port_description: sanitize_string_field(neighbor_map[:remote_port_description]), - remote_address: sanitize_string_field(neighbor_map[:remote_address]), + remote_chassis_id: Sanitizer.sanitize_string(neighbor_map[:remote_chassis_id]), + remote_system_name: Sanitizer.sanitize_string(neighbor_map[:remote_system_name]), + remote_system_description: Sanitizer.sanitize_string(neighbor_map[:remote_system_description]), + remote_platform: Sanitizer.sanitize_string(neighbor_map[:remote_platform]), + remote_port_id: Sanitizer.sanitize_string(neighbor_map[:remote_port_id]), + remote_port_description: Sanitizer.sanitize_string(neighbor_map[:remote_port_description]), + remote_address: Sanitizer.sanitize_string(neighbor_map[:remote_address]), remote_capabilities: neighbor_map[:remote_capabilities] || [], last_discovered_at: DateTime.truncate(DateTime.utc_now(), :second) } end end - # Ensure all string fields are actually strings, not raw binaries - defp sanitize_string_field(nil), do: nil - - defp sanitize_string_field(value) when is_binary(value) do - if String.valid?(value) and String.printable?(value) do - String.trim(value) - else - # Convert non-printable binary to hex string - value - |> :binary.bin_to_list() - |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0")) - |> String.downcase() - end - end - - defp sanitize_string_field(value), do: to_string(value) - # CDP Discovery (Cisco proprietary) @cdp_cache_table_oid "1.3.6.1.4.1.9.9.23.1.2.1.1" diff --git a/lib/towerops/snmp/poller_worker.ex b/lib/towerops/snmp/poller_worker.ex index 6b2b17f7..c7f9f145 100644 --- a/lib/towerops/snmp/poller_worker.ex +++ b/lib/towerops/snmp/poller_worker.ex @@ -166,6 +166,9 @@ defmodule Towerops.Snmp.PollerWorker do end), Task.Supervisor.async_nolink(PollerTaskSupervisor, fn -> poll_device_neighbors(device, snmp_device, client_opts) + end), + Task.Supervisor.async_nolink(PollerTaskSupervisor, fn -> + poll_device_processors(device, snmp_device, client_opts, now) end) ] @@ -263,6 +266,122 @@ defmodule Towerops.Snmp.PollerWorker do Logger.error("Error polling neighbors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}") end + defp poll_device_processors(device, snmp_device, client_opts, now) do + processors = snmp_device.processors || [] + + if processors != [] do + poll_processors(processors, client_opts, now) + Logger.debug("Polled processors for #{device.name}") + + # Broadcast processor update event to device-specific topic + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "device:#{device.id}", + {:processors_updated, device.id} + ) + end + rescue + error -> + Logger.error("Error polling processors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}") + end + + defp poll_processors(processors, client_opts, timestamp) do + # Poll processors in parallel with max_concurrency + processors + |> Task.async_stream( + fn processor -> + result = poll_processor_value(processor, client_opts) + handle_processor_poll_result(processor, result, timestamp) + end, + max_concurrency: 2, + timeout: 40_000, + on_timeout: :kill_task + ) + |> Stream.run() + end + + defp poll_processor_value(processor, client_opts) do + # Different OIDs for different processor types + oid = + case processor.processor_type do + "hr_processor" -> + # HOST-RESOURCES-MIB hrProcessorLoad + "1.3.6.1.2.1.25.3.3.1.2.#{processor.processor_index}" + + "cisco_cpu" -> + # CISCO-PROCESS-MIB cpmCPUTotal5minRev + "1.3.6.1.4.1.9.9.109.1.1.1.1.8.#{processor.processor_index}" + + "ucd_cpu" -> + # UCD-SNMP-MIB - need to calculate from user/system/idle + # For simplicity, use ssCpuUser + ssCpuSystem as approximation + poll_ucd_cpu(processor, client_opts) + + _ -> + {:error, :unknown_processor_type} + end + + case oid do + {:ok, value} -> {:ok, value} + {:error, _} = error -> error + oid_string when is_binary(oid_string) -> poll_snmp_load(client_opts, oid_string) + end + end + + defp poll_snmp_load(client_opts, oid) do + case Client.get(client_opts, oid) do + {:ok, value} when is_integer(value) -> + {:ok, value / 1.0} + + {:ok, _non_integer} -> + {:error, :non_integer} + + {:error, reason} -> + {:error, reason} + end + end + + defp poll_ucd_cpu(_processor, client_opts) do + # UCD-SNMP-MIB: ssCpuUser + ssCpuSystem = CPU usage + user_oid = "1.3.6.1.4.1.2021.11.9.0" + system_oid = "1.3.6.1.4.1.2021.11.10.0" + + with {:ok, user} <- Client.get(client_opts, user_oid), + {:ok, system} <- Client.get(client_opts, system_oid), + true <- is_integer(user) and is_integer(system) do + {:ok, (user + system) / 1.0} + else + _ -> {:error, :failed_to_calculate} + end + end + + defp handle_processor_poll_result(processor, {:ok, load_percent}, timestamp) do + # Create a reading + Snmp.create_processor_reading(%{ + processor_id: processor.id, + load_percent: load_percent, + status: "ok", + checked_at: timestamp + }) + + # Update processor with latest value + Snmp.update_processor(processor, %{ + load_percent: load_percent, + last_checked_at: timestamp + }) + end + + defp handle_processor_poll_result(processor, {:error, reason}, timestamp) do + Logger.debug("Failed to poll processor #{processor.description}: #{inspect(reason)}") + + Snmp.create_processor_reading(%{ + processor_id: processor.id, + load_percent: nil, + status: "error", + checked_at: timestamp + }) + end + 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 @@ -796,24 +915,73 @@ defmodule Towerops.Snmp.PollerWorker do defp format_speed(_), do: "Unknown" defp get_interface_stats(client_opts, if_index) do - oids = [ + # Try High-Capacity 64-bit counters first (ifHCInOctets, ifHCOutOctets) + # These are essential for 10G+ interfaces to avoid counter wrapping + # HC counters are from IF-MIB::ifXTable (RFC 2863) + hc_oids = [ + if_in_octets: "1.3.6.1.2.1.31.1.1.1.6.#{if_index}", + if_out_octets: "1.3.6.1.2.1.31.1.1.1.10.#{if_index}" + ] + + # Standard 32-bit counters (fallback) + std_oids = [ if_in_octets: "1.3.6.1.2.1.2.2.1.10.#{if_index}", - if_out_octets: "1.3.6.1.2.1.2.2.1.16.#{if_index}", + if_out_octets: "1.3.6.1.2.1.2.2.1.16.#{if_index}" + ] + + # Error/discard counters (always 32-bit, no HC versions) + error_oids = [ if_in_errors: "1.3.6.1.2.1.2.2.1.14.#{if_index}", if_out_errors: "1.3.6.1.2.1.2.2.1.20.#{if_index}", if_in_discards: "1.3.6.1.2.1.2.2.1.13.#{if_index}", if_out_discards: "1.3.6.1.2.1.2.2.1.19.#{if_index}" ] - results = - Enum.map(oids, fn {key, oid} -> + # Try HC counters first for octet counts + octet_results = fetch_octet_counters(client_opts, hc_oids, std_oids) + + # Fetch error/discard counters + error_results = + Enum.map(error_oids, fn {key, oid} -> case Client.get(client_opts, oid) do {:ok, value} -> {key, decode_snmp_value(value)} _ -> {key, nil} end end) - {:ok, Map.new(results)} + {:ok, Map.new(octet_results ++ error_results)} + end + + # Try HC (64-bit) counters first, fall back to standard 32-bit if not available + defp fetch_octet_counters(client_opts, hc_oids, std_oids) do + hc_results = Enum.map(hc_oids, &try_fetch_hc_counter(client_opts, &1)) + + hc_available = Enum.all?(hc_results, fn {_key, value} -> value != :not_available end) + + if hc_available do + Enum.map(hc_results, fn {key, value} -> {key, value} end) + else + Logger.debug("HC counters not available, using standard 32-bit counters") + Enum.map(std_oids, &fetch_standard_counter(client_opts, &1)) + end + end + + defp try_fetch_hc_counter(client_opts, {key, oid}) do + case Client.get(client_opts, oid) do + {:ok, value} -> + decoded = decode_snmp_value(value) + if decoded == nil, do: {key, :not_available}, else: {key, decoded} + + _ -> + {key, :not_available} + end + end + + defp fetch_standard_counter(client_opts, {key, oid}) do + case Client.get(client_opts, oid) do + {:ok, value} -> {key, decode_snmp_value(value)} + _ -> {key, nil} + end end # Decode SNMP values, handling Counter64 and other binary types diff --git a/lib/towerops/snmp/processor.ex b/lib/towerops/snmp/processor.ex new file mode 100644 index 00000000..34e7586e --- /dev/null +++ b/lib/towerops/snmp/processor.ex @@ -0,0 +1,79 @@ +defmodule Towerops.Snmp.Processor do + @moduledoc """ + SNMP processor schema for tracking CPU/processor information. + + Processors are discovered via: + - HOST-RESOURCES-MIB hrProcessorTable + - CISCO-PROCESS-MIB cpmCPUTotalTable + - UCD-SNMP-MIB laTable (for system load averages) + + Processor types: + - "hr_processor" - HOST-RESOURCES-MIB hrProcessorTable entry + - "cisco_cpu" - Cisco CPU from CISCO-PROCESS-MIB + - "ucd_cpu" - UCD-SNMP-MIB CPU statistics + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Ecto.Association.NotLoaded + alias Towerops.Snmp.Device + + @valid_processor_types ~w(hr_processor cisco_cpu ucd_cpu) + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "snmp_processors" do + field :processor_index, :string + field :description, :string + field :processor_type, :string + field :load_percent, :float + field :last_checked_at, :utc_datetime + field :metadata, :map, default: %{} + + belongs_to :snmp_device, Device + + timestamps(type: :utc_datetime) + end + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + processor_index: String.t(), + description: String.t() | nil, + processor_type: String.t(), + load_percent: float() | nil, + last_checked_at: DateTime.t() | nil, + metadata: map(), + snmp_device_id: Ecto.UUID.t(), + snmp_device: NotLoaded.t() | Device.t(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + @doc false + def changeset(processor, attrs) do + processor + |> cast(attrs, [ + :snmp_device_id, + :processor_index, + :description, + :processor_type, + :load_percent, + :last_checked_at, + :metadata + ]) + |> validate_required([:snmp_device_id, :processor_index, :processor_type]) + |> validate_inclusion(:processor_type, @valid_processor_types) + |> validate_load_percent() + |> unique_constraint([:snmp_device_id, :processor_index]) + |> foreign_key_constraint(:snmp_device_id, match: :suffix) + end + + defp validate_load_percent(changeset) do + case get_field(changeset, :load_percent) do + nil -> changeset + load when load >= 0 and load <= 100 -> changeset + _ -> add_error(changeset, :load_percent, "must be between 0 and 100") + end + end +end diff --git a/lib/towerops/snmp/processor_reading.ex b/lib/towerops/snmp/processor_reading.ex new file mode 100644 index 00000000..e38b83ae --- /dev/null +++ b/lib/towerops/snmp/processor_reading.ex @@ -0,0 +1,44 @@ +defmodule Towerops.Snmp.ProcessorReading do + @moduledoc """ + Time-series processor reading schema. + + Stores individual processor load readings collected during SNMP polling cycles. + Similar to SensorReading but specifically for processor/CPU load data. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Towerops.Snmp.Processor + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "snmp_processor_readings" do + field :load_percent, :float + field :status, :string + field :checked_at, :utc_datetime + + belongs_to :processor, Processor + + timestamps(type: :utc_datetime, updated_at: false) + end + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + load_percent: float() | nil, + status: String.t(), + checked_at: DateTime.t(), + processor_id: Ecto.UUID.t(), + processor: Ecto.Association.NotLoaded.t() | Processor.t(), + inserted_at: DateTime.t() + } + + @doc false + def changeset(reading, attrs) do + reading + |> cast(attrs, [:processor_id, :load_percent, :status, :checked_at]) + |> validate_required([:processor_id, :status, :checked_at]) + |> validate_inclusion(:status, ["ok", "warning", "critical", "error"]) + |> foreign_key_constraint(:processor_id) + end +end diff --git a/lib/towerops/snmp/profiles/base.ex b/lib/towerops/snmp/profiles/base.ex index b7a22042..133bb6a5 100644 --- a/lib/towerops/snmp/profiles/base.ex +++ b/lib/towerops/snmp/profiles/base.ex @@ -11,6 +11,7 @@ defmodule Towerops.Snmp.Profiles.Base do alias Towerops.Snmp.Client alias Towerops.Snmp.Discovery + alias Towerops.Snmp.Sanitizer require Logger @@ -37,7 +38,12 @@ defmodule Towerops.Snmp.Profiles.Base do # IF-MIB::ifXTable - Extended interface table (64-bit counters, names) if_name: "1.3.6.1.2.1.31.1.1.1.1", - if_alias: "1.3.6.1.2.1.31.1.1.1.18" + if_high_speed: "1.3.6.1.2.1.31.1.1.1.15", + if_alias: "1.3.6.1.2.1.31.1.1.1.18", + + # IF-MIB::ifXTable - High-Capacity 64-bit counters (for 10G+ interfaces) + if_hc_in_octets: "1.3.6.1.2.1.31.1.1.1.6", + if_hc_out_octets: "1.3.6.1.2.1.31.1.1.1.10" } @entity_sensor_oids %{ @@ -79,11 +85,26 @@ defmodule Towerops.Snmp.Profiles.Base do } @ip_mib_oids %{ - # IP-MIB - IPv4 address discovery + # IP-MIB - IPv4 address discovery (ipAddrTable, deprecated but widely supported) ip_ad_ent_if_index: "1.3.6.1.2.1.4.20.1.2", ip_ad_ent_net_mask: "1.3.6.1.2.1.4.20.1.3" } + @ipv6_mib_oids %{ + # IPV6-MIB - IPv6 address discovery (RFC 2465) + # ipv6AddrTable indexed by: ifIndex.ipv6Address (16 octets) + ipv6_addr_pfx_length: "1.3.6.1.2.1.55.1.8.1.2", + ipv6_addr_type: "1.3.6.1.2.1.55.1.8.1.3" + } + + @ip_address_mib_oids %{ + # IP-MIB ipAddressTable (RFC 4293) - unified IPv4/IPv6 table + # Indexed by: addressType.address (1=ipv4, 2=ipv6) + ip_address_if_index: "1.3.6.1.2.1.4.34.1.3", + ip_address_prefix: "1.3.6.1.2.1.4.34.1.5", + ip_address_type: "1.3.6.1.2.1.4.34.1.4" + } + @host_resources_oids %{ # HOST-RESOURCES-MIB - hrStorageTable for memory and storage hr_storage_type: "1.3.6.1.2.1.25.2.3.1.2", @@ -93,6 +114,31 @@ defmodule Towerops.Snmp.Profiles.Base do hr_storage_used: "1.3.6.1.2.1.25.2.3.1.6" } + @hr_processor_oids %{ + # HOST-RESOURCES-MIB - hrProcessorTable for CPU discovery + # hrProcessorFrwID: 1.3.6.1.2.1.25.3.3.1.1 (reference to hrDeviceEntry) + hr_processor_load: "1.3.6.1.2.1.25.3.3.1.2" + } + + @hr_device_oids %{ + # HOST-RESOURCES-MIB - hrDeviceTable for device descriptions + hr_device_descr: "1.3.6.1.2.1.25.3.2.1.3" + } + + @cisco_cpu_oids %{ + # CISCO-PROCESS-MIB - cpmCPUTotalTable + cpm_cpu_total_5sec: "1.3.6.1.4.1.9.9.109.1.1.1.1.3", + cpm_cpu_total_1min: "1.3.6.1.4.1.9.9.109.1.1.1.1.4", + cpm_cpu_total_5min: "1.3.6.1.4.1.9.9.109.1.1.1.1.5" + } + + @ucd_cpu_oids %{ + # UCD-SNMP-MIB - System CPU statistics + ss_cpu_user: "1.3.6.1.4.1.2021.11.9.0", + ss_cpu_system: "1.3.6.1.4.1.2021.11.10.0", + ss_cpu_idle: "1.3.6.1.4.1.2021.11.11.0" + } + # HOST-RESOURCES-MIB storage type OIDs mapping # Memory types (for discover_memory_pools) @memory_type_oids %{ @@ -440,6 +486,231 @@ defmodule Towerops.Snmp.Profiles.Base do {:ok, ip_addresses} end + @doc """ + Discovers IPv6 addresses from IPV6-MIB or IP-MIB ipAddressTable. + Returns a list of IP address maps with ip_address, if_index, prefix_length, and ip_type. + """ + @spec discover_ipv6_addresses(Client.connection_opts()) :: {:ok, [map()]} + def discover_ipv6_addresses(client_opts) do + # Try modern IP-MIB ipAddressTable first (RFC 4293, supports both IPv4/IPv6) + case discover_from_ip_address_table(client_opts) do + {:ok, [_ | _] = addresses} -> + {:ok, addresses} + + _ -> + # Fall back to IPV6-MIB ipv6AddrTable (RFC 2465) + discover_from_ipv6_addr_table(client_opts) + end + end + + # Discover from RFC 4293 ipAddressTable (unified IPv4/IPv6) + defp discover_from_ip_address_table(client_opts) do + case Client.walk(client_opts, @ip_address_mib_oids.ip_address_if_index) do + {:ok, results} when is_map(results) and map_size(results) > 0 -> + do_discover_from_ip_address_table(client_opts, results) + + {:ok, _} -> + Logger.debug("No addresses found in ipAddressTable") + {:ok, []} + + {:error, reason} -> + Logger.debug("ipAddressTable walk failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp do_discover_from_ip_address_table(client_opts, if_index_results) do + # Fetch prefix lengths + prefix_map = + case Client.walk(client_opts, @ip_address_mib_oids.ip_address_prefix) do + {:ok, results} when is_map(results) -> results + _ -> %{} + end + + # Build address list - only IPv6 (type 2) + addresses = + if_index_results + |> Enum.map(fn {oid, if_index} -> + {ip_type, ip_address} = parse_ip_address_table_oid(oid) + + if ip_type == "ipv6" do + prefix_length = extract_prefix_from_map(prefix_map, oid) + + %{ + ip_address: ip_address, + if_index: if_index, + subnet_mask: nil, + prefix_length: prefix_length, + ip_type: "ipv6", + last_checked_at: DateTime.utc_now() + } + end + end) + |> Enum.reject(&is_nil/1) + + Logger.debug("Discovered #{length(addresses)} IPv6 addresses from ipAddressTable") + {:ok, addresses} + end + + # Discover from RFC 2465 IPV6-MIB ipv6AddrTable + defp discover_from_ipv6_addr_table(client_opts) do + case Client.walk(client_opts, @ipv6_mib_oids.ipv6_addr_pfx_length) do + {:ok, results} when is_map(results) and map_size(results) > 0 -> + do_discover_from_ipv6_addr_table(results) + + {:ok, _} -> + Logger.debug("No IPv6 addresses found in IPV6-MIB") + {:ok, []} + + {:error, reason} -> + Logger.debug("IPV6-MIB walk failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp do_discover_from_ipv6_addr_table(prefix_results) do + # ipv6AddrTable is indexed by ifIndex.ipv6Address (16 octets) + addresses = + prefix_results + |> Enum.map(fn {oid, prefix_length} -> + {if_index, ipv6_address} = parse_ipv6_addr_table_oid(oid) + + if ipv6_address do + %{ + ip_address: ipv6_address, + if_index: if_index, + subnet_mask: nil, + prefix_length: prefix_length, + ip_type: "ipv6", + last_checked_at: DateTime.utc_now() + } + end + end) + |> Enum.reject(&is_nil/1) + + Logger.debug("Discovered #{length(addresses)} IPv6 addresses from IPV6-MIB") + {:ok, addresses} + end + + # Parse ipAddressTable OID to extract address type and address + # OID format: base.addressType.addressLength.address... + # addressType: 1=ipv4, 2=ipv6 + defp parse_ip_address_table_oid(oid) when is_binary(oid) do + parts = String.split(oid, ".") + + # Find where the address starts (after the base OID) + # The format is: base.type.length.addr1.addr2... + case find_address_in_oid(parts) do + {:ipv4, octets} when length(octets) == 4 -> + {"ipv4", Enum.join(octets, ".")} + + {:ipv6, octets} when length(octets) == 16 -> + {"ipv6", format_ipv6_address(octets)} + + _ -> + {nil, nil} + end + end + + defp parse_ip_address_table_oid(_), do: {nil, nil} + + defp find_address_in_oid(parts) do + # Look for type indicator (1 or 2) followed by length + # Pattern: ...1.4.a.b.c.d (IPv4) or ...2.16.a.b.c... (IPv6) + parts + |> Enum.chunk_every(2, 1, :discard) + |> Enum.find_value(fn + ["1", "4" | _] -> + idx = Enum.find_index(parts, &(&1 == "1")) + + if idx do + octets = Enum.slice(parts, idx + 2, 4) + {:ipv4, octets} + end + + ["2", "16" | _] -> + idx = Enum.find_index(parts, &(&1 == "2")) + + if idx do + octets = Enum.slice(parts, idx + 2, 16) + {:ipv6, octets} + end + + _ -> + nil + end) + end + + # Parse ipv6AddrTable OID to extract ifIndex and IPv6 address + # OID format: base.ifIndex.ipv6Addr (16 octets) + defp parse_ipv6_addr_table_oid(oid) when is_binary(oid) do + parts = String.split(oid, ".") + + # Last 17 parts: ifIndex + 16 address octets + if length(parts) >= 17 do + suffix = Enum.take(parts, -17) + [if_index_str | addr_octets] = suffix + + if_index = String.to_integer(if_index_str) + ipv6_address = format_ipv6_address(addr_octets) + + {if_index, ipv6_address} + else + {nil, nil} + end + rescue + _ -> {nil, nil} + end + + defp parse_ipv6_addr_table_oid(_), do: {nil, nil} + + # Format IPv6 address from decimal octets to standard notation + defp format_ipv6_address(octets) when is_list(octets) and length(octets) == 16 do + octets + |> Enum.map(&String.to_integer/1) + |> Enum.chunk_every(2) + |> Enum.map_join(":", fn [hi, lo] -> + (hi * 256 + lo) + |> Integer.to_string(16) + |> String.downcase() + end) + rescue + _ -> nil + end + + defp format_ipv6_address(_), do: nil + + defp extract_prefix_from_map(prefix_map, oid) do + # Try to find matching prefix entry + # The OID suffix should match + Enum.find_value(prefix_map, fn {prefix_oid, value} -> + if String.ends_with?(prefix_oid, String.slice(oid, -20..-1)) do + value + end + end) + end + + @doc """ + Discovers all IP addresses (both IPv4 and IPv6) from SNMP. + Combines results from discover_ip_addresses/1 and discover_ipv6_addresses/1. + """ + @spec discover_all_ip_addresses(Client.connection_opts()) :: {:ok, [map()]} + def discover_all_ip_addresses(client_opts) do + ipv4_result = discover_ip_addresses(client_opts) + ipv6_result = discover_ipv6_addresses(client_opts) + + ipv4_addresses = elem(ipv4_result, 1) + ipv6_addresses = elem(ipv6_result, 1) + + all_addresses = ipv4_addresses ++ ipv6_addresses + + Logger.debug( + "Discovered #{length(all_addresses)} total IP addresses (#{length(ipv4_addresses)} IPv4, #{length(ipv6_addresses)} IPv6)" + ) + + {:ok, all_addresses} + end + @doc """ Discovers memory pools from HOST-RESOURCES-MIB hrStorageTable. Returns a list of memory pool maps (RAM, swap/virtual memory). @@ -535,10 +806,11 @@ defmodule Towerops.Snmp.Profiles.Base do allocation_units = Map.get(alloc_map, index, 1) size_units = Map.get(size_map, index, 0) used_units = Map.get(used_map, index, 0) + raw_description = Map.get(descr_map, index) %{ storage_index: index, - description: Map.get(descr_map, index), + description: Sanitizer.sanitize_string(raw_description), storage_type: storage_type, allocation_units: allocation_units, total_bytes: size_units * allocation_units, @@ -693,6 +965,181 @@ defmodule Towerops.Snmp.Profiles.Base do defp to_string_or_nil(value) when is_binary(value), do: value defp to_string_or_nil(value), do: to_string(value) + @doc """ + Discovers processor/CPU information from multiple MIBs. + + Tries in order: + 1. HOST-RESOURCES-MIB hrProcessorTable (standard, most compatible) + 2. CISCO-PROCESS-MIB cpmCPUTotalTable (Cisco-specific) + 3. UCD-SNMP-MIB CPU statistics (Linux/Unix systems) + + Returns a list of processor maps with processor_index, description, processor_type, and load_percent. + """ + @spec discover_processors(Client.connection_opts()) :: {:ok, [map()]} + def discover_processors(client_opts) do + # Try HOST-RESOURCES-MIB first (most widely supported) + case discover_hr_processors(client_opts) do + {:ok, [_ | _] = processors} -> + {:ok, processors} + + _ -> + # Try Cisco-specific OIDs + case discover_cisco_processors(client_opts) do + {:ok, [_ | _] = processors} -> + {:ok, processors} + + _ -> + # Try UCD-SNMP-MIB + discover_ucd_cpu(client_opts) + end + end + end + + # Discover processors from HOST-RESOURCES-MIB hrProcessorTable + defp discover_hr_processors(client_opts) do + case Client.walk(client_opts, @hr_processor_oids.hr_processor_load) do + {:ok, results} when map_size(results) > 0 -> + do_discover_hr_processors(client_opts, results) + + {:ok, _} -> + Logger.debug("No processors found in HOST-RESOURCES-MIB") + {:ok, []} + + {:error, reason} -> + Logger.debug("hrProcessorTable walk failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp do_discover_hr_processors(client_opts, load_results) do + # Get device descriptions for processors + descr_map = walk_hr_device_descr(client_opts) + + processors = + load_results + |> Enum.map(fn {oid, load_value} -> + index = extract_processor_index(oid) + raw_description = Map.get(descr_map, index, "CPU #{index}") + description = Sanitizer.sanitize_string(raw_description) + load_percent = parse_integer(load_value) + + %{ + processor_index: "hr_#{index}", + description: description, + processor_type: "hr_processor", + load_percent: load_percent && load_percent * 1.0, + last_checked_at: DateTime.utc_now() + } + end) + |> Enum.reject(fn p -> is_nil(p.processor_index) end) + + Logger.debug("Discovered #{length(processors)} processors from HOST-RESOURCES-MIB") + {:ok, processors} + end + + defp walk_hr_device_descr(client_opts) do + case Client.walk(client_opts, @hr_device_oids.hr_device_descr) do + {:ok, results} -> + Map.new(results, fn {oid, value} -> + index = extract_processor_index(oid) + {index, extract_string_value(value)} + end) + + _ -> + %{} + end + end + + # Discover processors from CISCO-PROCESS-MIB cpmCPUTotalTable + defp discover_cisco_processors(client_opts) do + case Client.walk(client_opts, @cisco_cpu_oids.cpm_cpu_total_5min) do + {:ok, results} when map_size(results) > 0 -> + do_discover_cisco_processors(results) + + {:ok, _} -> + Logger.debug("No Cisco CPU data found") + {:ok, []} + + {:error, reason} -> + Logger.debug("CISCO-PROCESS-MIB walk failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp do_discover_cisco_processors(cpu_results) do + processors = + Enum.map(cpu_results, fn {oid, load_value} -> + index = extract_processor_index(oid) + load_percent = parse_integer(load_value) + + %{ + processor_index: "cisco_#{index}", + description: "Cisco CPU #{index}", + processor_type: "cisco_cpu", + load_percent: load_percent && load_percent * 1.0, + last_checked_at: DateTime.utc_now() + } + end) + + Logger.debug("Discovered #{length(processors)} Cisco CPUs") + {:ok, processors} + end + + # Discover CPU stats from UCD-SNMP-MIB + defp discover_ucd_cpu(client_opts) do + # Get user, system, and idle percentages + oids = [@ucd_cpu_oids.ss_cpu_user, @ucd_cpu_oids.ss_cpu_system, @ucd_cpu_oids.ss_cpu_idle] + + case Client.get_multiple(client_opts, oids) do + {:ok, values} when is_list(values) and length(values) == 3 -> + [user, system, _idle] = values + user_pct = parse_integer(user) || 0 + system_pct = parse_integer(system) || 0 + total_load = user_pct + system_pct + + processors = [ + %{ + processor_index: "ucd_0", + description: "System CPU", + processor_type: "ucd_cpu", + load_percent: total_load * 1.0, + last_checked_at: DateTime.utc_now(), + metadata: %{ + user_percent: user_pct, + system_percent: system_pct + } + } + ] + + Logger.debug("Discovered UCD-SNMP CPU with #{total_load}% load") + {:ok, processors} + + {:ok, _} -> + Logger.debug("No UCD-SNMP CPU data found") + {:ok, []} + + {:error, reason} -> + Logger.debug("UCD-SNMP-MIB get failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp extract_processor_index(oid) when is_binary(oid) do + oid + |> String.split(".") + |> List.last() + |> String.to_integer() + rescue + _ -> nil + end + + defp extract_processor_index(_), do: nil + + # Extract string value from SNMP result (could be binary, charlist, or other) + defp extract_string_value(value) when is_binary(value), do: value + defp extract_string_value(value) when is_list(value), do: List.to_string(value) + defp extract_string_value(value), do: to_string(value) + @doc """ Identifies device manufacturer and model from sysDescr and sysObjectID. Can be overridden by vendor-specific profiles. @@ -714,12 +1161,12 @@ defmodule Towerops.Snmp.Profiles.Base do defp parse_system_info(raw_info) do %{ - sys_descr: Map.get(raw_info, :sys_descr), + sys_descr: raw_info |> Map.get(:sys_descr) |> Sanitizer.sanitize_string(), sys_object_id: parse_oid(Map.get(raw_info, :sys_object_id)), - sys_name: Map.get(raw_info, :sys_name), + sys_name: raw_info |> Map.get(:sys_name) |> Sanitizer.sanitize_string(), sys_uptime: parse_integer(Map.get(raw_info, :sys_uptime)), - sys_contact: Map.get(raw_info, :sys_contact), - sys_location: Map.get(raw_info, :sys_location) + sys_contact: raw_info |> Map.get(:sys_contact) |> Sanitizer.sanitize_string(), + sys_location: raw_info |> Map.get(:sys_location) |> Sanitizer.sanitize_string() } end @@ -743,26 +1190,54 @@ defmodule Towerops.Snmp.Profiles.Base do with {:ok, [if_descr, if_type, if_speed, if_phys_addr, if_admin, if_oper]} <- Client.get_multiple(client_opts, required_oids) do - # Try to fetch optional IF-MIB extension fields (ifName, ifAlias) + # Try to fetch optional IF-MIB extension fields (ifName, ifAlias, ifHighSpeed) # These are from RFC 2863 and not all devices support them if_name = fetch_optional_field(client_opts, @interface_oids.if_name <> ".#{index}") if_alias = fetch_optional_field(client_opts, @interface_oids.if_alias <> ".#{index}") + if_high_speed = fetch_optional_field(client_opts, @interface_oids.if_high_speed <> ".#{index}") + + # Use ifHighSpeed (Mbps) for 10G+ interfaces where ifSpeed (bps) maxes out at ~4.3Gbps + # ifHighSpeed is in Mbps, convert to bps for consistency + actual_speed = calculate_interface_speed(if_speed, if_high_speed) {:ok, %{ if_index: index, - if_descr: if_descr, - if_name: if_name, - if_alias: if_alias, + if_descr: Sanitizer.sanitize_string(if_descr), + if_name: Sanitizer.sanitize_string(if_name), + if_alias: Sanitizer.sanitize_string(if_alias), if_type: parse_integer(if_type), - if_speed: parse_integer(if_speed), - if_phys_address: format_mac_address(if_phys_addr), + if_speed: actual_speed, + if_phys_address: Sanitizer.sanitize_mac(if_phys_addr) || format_mac_address(if_phys_addr), if_admin_status: parse_if_status(if_admin), if_oper_status: parse_if_status(if_oper) }} end end + # Calculate actual interface speed, preferring ifHighSpeed for 10G+ interfaces + # ifSpeed maxes out at 4,294,967,295 bps (~4.3 Gbps) due to 32-bit gauge + # ifHighSpeed is in Mbps and supports up to 4.3 Tbps + defp calculate_interface_speed(if_speed, if_high_speed) do + speed_bps = parse_integer(if_speed) + high_speed_mbps = parse_integer(if_high_speed) + + cond do + # If ifHighSpeed is available and indicates 10G+ (or ifSpeed maxed out) + high_speed_mbps != nil and high_speed_mbps >= 10_000 -> + # Convert Mbps to bps + high_speed_mbps * 1_000_000 + + # If ifSpeed is at or near max (4.3 Gbps), use ifHighSpeed if available + speed_bps != nil and speed_bps >= 4_000_000_000 and high_speed_mbps != nil -> + high_speed_mbps * 1_000_000 + + # Otherwise use standard ifSpeed + true -> + speed_bps + end + end + # Fetch an optional SNMP field - returns nil if not supported defp fetch_optional_field(client_opts, oid) do case Client.get(client_opts, oid) do @@ -806,13 +1281,13 @@ defmodule Towerops.Snmp.Profiles.Base do end end - # Get best available description from ENTITY-MIB + # Get best available description from ENTITY-MIB (sanitized) defp get_sensor_description(%{name: name}, _index) when is_binary(name) and byte_size(name) > 0 do - name + Sanitizer.sanitize_string(name) end defp get_sensor_description(%{descr: descr}, _index) when is_binary(descr) and byte_size(descr) > 0 do - descr + Sanitizer.sanitize_string(descr) end defp get_sensor_description(_, index), do: "Sensor #{index}" @@ -1152,7 +1627,25 @@ defmodule Towerops.Snmp.Profiles.Base do "#{a}.#{b}.#{c}.#{d}" end - defp format_ip_address(value) when is_binary(value), do: value + # Handle raw 4-byte binary (IPv4 address bytes) - common from SNMP + defp format_ip_address(<>) do + "#{a}.#{b}.#{c}.#{d}" + end + + # Handle already-formatted string, but sanitize non-printable characters + defp format_ip_address(value) when is_binary(value) do + if String.printable?(value) do + value + else + # Try to convert as raw bytes + bytes = :binary.bin_to_list(value) + + if length(bytes) == 4 and Enum.all?(bytes, &(&1 >= 0 and &1 <= 255)) do + Enum.join(bytes, ".") + end + end + end + defp format_ip_address(_), do: nil defp subnet_mask_to_prefix(nil), do: nil diff --git a/lib/towerops/snmp/profiles/dynamic.ex b/lib/towerops/snmp/profiles/dynamic.ex index 67263344..61fdaf06 100644 --- a/lib/towerops/snmp/profiles/dynamic.ex +++ b/lib/towerops/snmp/profiles/dynamic.ex @@ -292,6 +292,15 @@ defmodule Towerops.Snmp.Profiles.Dynamic do defp build_sensor_descr(sensor_def, idx) do base_descr = sensor_def[:sensor_descr] || sensor_def[:oid_name] || sensor_def[:sensor_type] + # If the description contains a MIB symbolic name (e.g., "MIKROTIK-MIB::mtxrOpticalName"), + # use the sensor_type as fallback since we can't resolve MIB names to actual values here + base_descr = + if is_binary(base_descr) and String.contains?(base_descr, "::") do + sensor_def[:sensor_type] || "sensor" + else + base_descr + end + # If there's only one instance, don't add index if idx == 1 do String.capitalize(to_string(base_descr)) @@ -363,14 +372,24 @@ defmodule Towerops.Snmp.Profiles.Dynamic do 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::") + # prefer the one with a proper description (not containing MIB symbolic names) 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) || + # Prefer sensor with proper description (no MIB symbolic name like "MIKROTIK-MIB::xxx") + # Check case-insensitively since build_sensor_descr capitalizes only first letter + Enum.find(group, fn s -> not has_mib_symbolic_name?(s.sensor_descr) end) || List.last(group) end) end + + # Check if a description contains a MIB symbolic name (e.g., "MIKROTIK-MIB::mtxrOpticalName") + # These should be filtered out in favor of actual sensor names from the device + defp has_mib_symbolic_name?(descr) when is_binary(descr) do + # MIB symbolic names contain "::" pattern (case-insensitive check) + String.contains?(String.downcase(descr), "::") + end + + defp has_mib_symbolic_name?(_), do: false end diff --git a/lib/towerops/snmp/sanitizer.ex b/lib/towerops/snmp/sanitizer.ex new file mode 100644 index 00000000..82f75be4 --- /dev/null +++ b/lib/towerops/snmp/sanitizer.ex @@ -0,0 +1,183 @@ +defmodule Towerops.Snmp.Sanitizer do + @moduledoc """ + Sanitizes SNMP data to ensure it's safe to store in the database. + + SNMP devices may return: + - Non-UTF8 binary data + - Non-printable characters + - Malformed strings + - Raw binary values (MAC addresses, IP addresses) + + This module provides sanitization functions to convert these to safe, + printable strings suitable for PostgreSQL text/varchar columns. + """ + + @doc """ + Sanitizes a string field from SNMP data. + + - Returns nil for nil input + - Returns printable strings as-is (trimmed) + - Converts non-printable binaries to colon-separated hex format + - Converts other values to strings + + ## Examples + + iex> Sanitizer.sanitize_string(nil) + nil + + iex> Sanitizer.sanitize_string("Hello World") + "Hello World" + + iex> Sanitizer.sanitize_string(<<255, 255, 255, 0>>) + "ff:ff:ff:00" + + """ + @spec sanitize_string(any()) :: String.t() | nil + def sanitize_string(nil), do: nil + + def sanitize_string(value) when is_binary(value) do + if String.valid?(value) and String.printable?(value) do + String.trim(value) + else + # Convert non-printable binary to hex string + value + |> :binary.bin_to_list() + |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0")) + |> String.downcase() + end + end + + def sanitize_string(value) when is_list(value) do + # Erlang charlists from SNMP + value + |> List.to_string() + |> sanitize_string() + rescue + _ -> inspect(value) + end + + def sanitize_string(value), do: to_string(value) + + @doc """ + Sanitizes an IPv4 address from various SNMP formats. + + Handles: + - Tuple format: {a, b, c, d} + - Raw 4-byte binary: <> + - String format: "a.b.c.d" + - Longer binary (takes first 4 bytes as IP) + + Returns dotted-decimal string or nil if invalid. + """ + @spec sanitize_ipv4(any()) :: String.t() | nil + def sanitize_ipv4({a, b, c, d}) when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) do + "#{a}.#{b}.#{c}.#{d}" + end + + def sanitize_ipv4(<>) do + "#{a}.#{b}.#{c}.#{d}" + end + + def sanitize_ipv4(value) when is_binary(value) do + cond do + # Already formatted string - valid IP + String.printable?(value) and String.match?(value, ~r/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/) -> + value + + # Printable string that doesn't match IP format - invalid + String.printable?(value) -> + nil + + # Raw bytes - try to convert (only for non-printable binary data) + byte_size(value) >= 4 -> + <> = value + "#{a}.#{b}.#{c}.#{d}" + + true -> + nil + end + end + + def sanitize_ipv4(_), do: nil + + @doc """ + Sanitizes an IPv6 address from various SNMP formats. + + Handles: + - Raw 16-byte binary + - String format (already formatted) + - Tuple format (8 16-bit values) + + Returns formatted IPv6 string or nil if invalid. + """ + @spec sanitize_ipv6(any()) :: String.t() | nil + def sanitize_ipv6(value) when is_binary(value) and byte_size(value) == 16 do + # Raw 16-byte IPv6 address + bytes = :binary.bin_to_list(value) + + bytes + |> Enum.chunk_every(2) + |> Enum.map_join(":", fn [high, low] -> + (high * 256 + low) + |> Integer.to_string(16) + |> String.downcase() + end) + end + + def sanitize_ipv6(value) when is_binary(value) do + # Already formatted - validate and return + if String.printable?(value) and String.contains?(value, ":") do + String.trim(value) + end + end + + def sanitize_ipv6(_), do: nil + + @doc """ + Sanitizes a MAC address from various SNMP formats. + + Handles: + - Raw 6-byte binary + - Colon-separated hex string + - Dash-separated hex string + + Returns colon-separated lowercase hex format or nil if invalid. + """ + @spec sanitize_mac(any()) :: String.t() | nil + def sanitize_mac(value) when is_binary(value) and byte_size(value) == 6 do + value + |> :binary.bin_to_list() + |> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0")) + |> String.downcase() + end + + def sanitize_mac(value) when is_binary(value) do + # Already formatted - validate pattern + cleaned = String.replace(value, "-", ":") + + if String.match?(cleaned, ~r/^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/) do + String.downcase(cleaned) + end + end + + def sanitize_mac(_), do: nil + + @doc """ + Sanitizes all string fields in a map recursively. + + Applies sanitize_string/1 to all string values in the map. + """ + @spec sanitize_map(map()) :: map() + def sanitize_map(map) when is_map(map) do + Map.new(map, fn + {key, value} when is_binary(value) -> + {key, sanitize_string(value)} + + {key, value} when is_map(value) -> + {key, sanitize_map(value)} + + {key, value} -> + {key, value} + end) + end +end diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index d33e1dd9..bc12c1f4 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -342,7 +342,7 @@ defmodule ToweropsWeb.Layouts do <.link navigate={@navigate} class={[ - "inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium", + "inline-flex items-center border-b-2 px-1 pt-1 pb-4 text-sm font-medium", if(@active, do: "border-gray-900 text-gray-900 dark:border-white dark:text-white", else: diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index d540e5aa..6e479bf9 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -113,6 +113,11 @@ defmodule ToweropsWeb.DeviceLive.Show do {:noreply, load_equipment_data(socket, socket.assigns.device.id)} end + @impl true + def handle_info({:processors_updated, _device_id}, socket) do + {:noreply, load_equipment_data(socket, socket.assigns.device.id)} + end + @impl true def handle_info({:monitoring_check_updated, _device_id}, socket) do {:noreply, load_equipment_data(socket, socket.assigns.device.id)} @@ -134,8 +139,10 @@ defmodule ToweropsWeb.DeviceLive.Show do events = Devices.list_devices_events(device_id, 100) neighbors = Snmp.list_neighbors_with_equipment(device_id) vlans = load_vlans(snmp_data.device) + ip_addresses = load_ip_addresses(snmp_data.device) + processors = load_processors(snmp_data.device) latency_chart_data = load_latency_chart_data(device_id) - cpu_chart_data = load_sensor_chart_data(device_id, ["cpu_load"]) + processor_chart_data = load_processor_chart_data(processors) memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"]) storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"]) traffic_chart_data = load_overall_traffic_chart_data(device_id) @@ -176,9 +183,12 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:snmp_sensors, snmp_data.sensors) |> assign(:neighbors, neighbors) |> assign(:vlans, vlans) + |> assign(:ipv4_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv4"))) + |> assign(:ipv6_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv6"))) + |> assign(:processors, processors) |> assign(:events, events) |> assign(:latency_chart_data, latency_chart_data) - |> assign(:cpu_chart_data, cpu_chart_data) + |> assign(:processor_chart_data, processor_chart_data) |> assign(:memory_chart_data, memory_chart_data) |> assign(:storage_chart_data, storage_chart_data) |> assign(:traffic_chart_data, traffic_chart_data) @@ -349,6 +359,55 @@ defmodule ToweropsWeb.DeviceLive.Show do defp load_vlans(nil), do: [] defp load_vlans(snmp_device), do: Snmp.list_vlans(snmp_device.id) + defp load_ip_addresses(nil), do: [] + defp load_ip_addresses(snmp_device), do: Snmp.list_ip_addresses(snmp_device.id) + + defp load_processors(nil), do: [] + defp load_processors(snmp_device), do: Snmp.list_processors(snmp_device.id) + + defp load_processor_chart_data([]), do: nil + + defp load_processor_chart_data(processors) do + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + + datasets = + processors + |> Enum.map(&processor_to_chart_dataset(&1, twenty_four_hours_ago)) + |> Enum.reject(&Enum.empty?(&1.data)) + + if Enum.empty?(datasets) do + nil + else + Jason.encode!(%{datasets: datasets}) + end + end + + defp processor_to_chart_dataset(processor, since) do + readings = + processor.id + |> Snmp.get_processor_readings(since: since, limit: 1000) + |> Enum.reverse() + + label = + if processor.description do + processor.description + else + "CPU #{processor.processor_index}" + end + + %{ + label: label, + data: Enum.map(readings, &processor_reading_to_data_point/1) + } + end + + defp processor_reading_to_data_point(reading) do + %{ + x: DateTime.to_unix(reading.checked_at, :millisecond), + y: if(reading.load_percent, do: Float.round(reading.load_percent, 1)) + } + end + defp load_sensor_chart_data(device_id, sensor_types) when is_list(sensor_types) do case Snmp.get_device_with_associations(device_id) do nil -> nil diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index e05dcc0e..d5c6b229 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -100,6 +100,22 @@ <% end %> + <%= if length(@ipv4_addresses) > 0 || length(@ipv6_addresses) > 0 do %> + <.link + patch={~p"/devices/#{@device.id}?tab=ip_addresses"} + class={[ + "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + if @active_tab == "ip_addresses" do + "border-blue-500 text-blue-600 dark:text-blue-400" + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300" + end + ]} + > + IP Addresses + + <% end %> + <.link patch={~p"/devices/#{@device.id}?tab=logs"} class={[ @@ -304,8 +320,17 @@
- - <%= if @cpu_chart_data do %> + + <%= if @processor_chart_data || (@processors && length(@processors) > 0) do %> + <% processor_count = if @processors, do: length(@processors), else: 0 + + avg_load = + if processor_count > 0 do + loads = @processors |> Enum.map(& &1.load_percent) |> Enum.reject(&is_nil/1) + if length(loads) > 0, do: Enum.sum(loads) / length(loads), else: nil + else + nil + end %>
<.link navigate={~p"/devices/#{@device.id}/graph/processors"} @@ -313,21 +338,61 @@ >

- Processors + <%= if processor_count > 0 do %> + {processor_count} {if processor_count == 1, + do: "Processor", + else: "Processors"} + <% else %> + Processors + <% end %>

<.icon name="hero-arrow-right" class="h-4 w-4 text-gray-400" />
-
-
- + + <%= if @processor_chart_data do %> +
+
+ +
-
+ <% end %> + + <%= if avg_load do %> +
+
+ + Average Load + +
+
+
= 50 && avg_load < 80 && "bg-yellow-500", + avg_load >= 80 && "bg-red-500" + ]} + style={"width: #{min(avg_load, 100)}%"} + > +
+
+ + {Float.round(avg_load, 0)}% + +
+
+
+ <% end %>
<% end %> @@ -787,6 +852,123 @@
<% end %> + <% "ip_addresses" -> %> +
+ + <%= if length(@ipv4_addresses) > 0 do %> +
+
+

+ + IPv4 + + Addresses + + ({length(@ipv4_addresses)}) + +

+
+
+ + + + + + + + + + + <%= for ip <- @ipv4_addresses do %> + + + + + + + <% end %> + +
IP AddressSubnetInterfacePrimary
+ {ip.ip_address} + + <%= if ip.prefix_length do %> + /{ip.prefix_length} + <% else %> + {ip.subnet_mask || "-"} + <% end %> + + {(ip.snmp_interface && + (ip.snmp_interface.if_name || ip.snmp_interface.if_descr)) || "-"} + + <%= if ip.is_primary do %> + + Primary + + <% else %> + - + <% end %> +
+
+
+ <% end %> + + <%= if length(@ipv6_addresses) > 0 do %> +
+
+

+ + IPv6 + + Addresses + + ({length(@ipv6_addresses)}) + +

+
+
+ + + + + + + + + + <%= for ip <- @ipv6_addresses do %> + + + + + + <% end %> + +
IP AddressPrefixInterface
+ {ip.ip_address} + + /{ip.prefix_length || "-"} + + {(ip.snmp_interface && + (ip.snmp_interface.if_name || ip.snmp_interface.if_descr)) || "-"} +
+
+
+ <% end %> + + <%= if length(@ipv4_addresses) == 0 && length(@ipv6_addresses) == 0 do %> +
+
+ <.icon name="hero-globe-alt" class="mx-auto h-12 w-12 text-gray-400" /> +

+ No IP addresses discovered +

+

+ This device doesn't have any IP addresses discovered, or IP discovery hasn't run yet. +

+
+
+ <% end %> +
<% "logs" -> %>
diff --git a/lib/towerops_web/live/graph_live/show.ex b/lib/towerops_web/live/graph_live/show.ex index 2a1cd597..df2f4e66 100644 --- a/lib/towerops_web/live/graph_live/show.ex +++ b/lib/towerops_web/live/graph_live/show.ex @@ -98,7 +98,8 @@ defmodule ToweropsWeb.GraphLive.Show do defp get_sensor_types_for_chart("temperature"), do: ["temperature", "cpu_temperature", "celsius"] defp get_sensor_types_for_chart("voltage"), do: ["voltage", "volts"] defp get_sensor_types_for_chart("traffic"), do: nil - defp get_sensor_types_for_chart(_), do: [] + # For unknown sensor types, use the type directly (supports count, pppoe_sessions, etc.) + defp get_sensor_types_for_chart(sensor_type), do: [sensor_type] defp get_chart_config("latency"), do: {"ICMP Latency", "ms", true} defp get_chart_config("processors"), do: {"Processor Usage", "%", false} @@ -107,7 +108,18 @@ defmodule ToweropsWeb.GraphLive.Show do defp get_chart_config("temperature"), do: {"Temperature", "°C", true} defp get_chart_config("voltage"), do: {"Voltage", "V", true} defp get_chart_config("traffic"), do: {"Overall Traffic", "bps", true} - defp get_chart_config(_), do: {"Sensor Data", "", false} + defp get_chart_config("count"), do: {"Count", "", true} + defp get_chart_config("pppoe_sessions"), do: {"PPPoE Sessions", "", true} + defp get_chart_config("connections"), do: {"Connections", "", true} + # Humanize unknown sensor types for title + defp get_chart_config(sensor_type), do: {humanize_sensor_type(sensor_type), "", true} + + defp humanize_sensor_type(sensor_type) do + sensor_type + |> String.replace("_", " ") + |> String.split() + |> Enum.map_join(" ", &String.capitalize/1) + end defp load_sensor_chart_data(device_id, sensor_types, range) do case Snmp.get_device_with_associations(device_id) do diff --git a/priv/repo/migrations/20260122214253_create_snmp_processors.exs b/priv/repo/migrations/20260122214253_create_snmp_processors.exs new file mode 100644 index 00000000..5bffad39 --- /dev/null +++ b/priv/repo/migrations/20260122214253_create_snmp_processors.exs @@ -0,0 +1,24 @@ +defmodule Towerops.Repo.Migrations.CreateSnmpProcessors do + use Ecto.Migration + + def change do + create table(:snmp_processors, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :snmp_device_id, references(:snmp_devices, type: :binary_id, on_delete: :delete_all), + null: false + + add :processor_index, :string, null: false + add :description, :string + add :processor_type, :string, null: false + add :load_percent, :float + add :last_checked_at, :utc_datetime + add :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + create index(:snmp_processors, [:snmp_device_id]) + create unique_index(:snmp_processors, [:snmp_device_id, :processor_index]) + end +end diff --git a/priv/repo/migrations/20260122223758_add_processor_readings.exs b/priv/repo/migrations/20260122223758_add_processor_readings.exs new file mode 100644 index 00000000..dee8e663 --- /dev/null +++ b/priv/repo/migrations/20260122223758_add_processor_readings.exs @@ -0,0 +1,22 @@ +defmodule Towerops.Repo.Migrations.AddProcessorReadings do + use Ecto.Migration + + def change do + create table(:snmp_processor_readings, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :processor_id, references(:snmp_processors, type: :binary_id, on_delete: :delete_all), + null: false + + add :load_percent, :float + add :status, :string, null: false, default: "ok" + add :checked_at, :utc_datetime, null: false + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:snmp_processor_readings, [:processor_id]) + create index(:snmp_processor_readings, [:checked_at]) + create index(:snmp_processor_readings, [:processor_id, :checked_at]) + end +end diff --git a/priv/static/android-icon-144x144.png b/priv/static/android-icon-144x144.png new file mode 100644 index 0000000000000000000000000000000000000000..8f39b461d5f01fa4fd04c7eb73b03241747f3f14 GIT binary patch literal 1963 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q1xWh(YZ(J6mUKs7M+SzC{oH>NS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fF5lweBoc6VX;-`;;_Kaj^+ z;1OBOz`%C|gc+x5^GO2**-JcqUD;nTiizsVw_IyAV_;x)@^oJ^W zGv8jH#<8Vg?UaV3B~3{|&5c=qIt&@@m6lZ4!BX)!$>b!brq1$v9!^P4J+s^t z;{>Mkb8~DI5}qn0E-k2%(f#+%+dlE4jhp57RHx0@-2G>s=d3d~jcwoCSnn<_x@Vm3 z$f4LG-~=S{o2G4BW6C){s(XQ6V!p@|0%abW)f>l~k(+ZSx9>^xDPc6j@O>@&-(>iS;SF@Mv2 zQGIT?h1DYy`^vr>#)`Wwy)^^x$mDPqa@J-ea+g$ghxL@M`UiHhcr~gj_?<2{|6~M&aT{Z zHLzMGVao4U`>(8cy}fm7sOssMM_e~;0%ksDycRn3@42NtQQVQ|+g9aY=e=`&N%sOq ztJPk5pVY*SICq@!y7%mBMC1|iJ@VDJo|g#>2XiCYdI?3{kqz0Uw3HmDUBO$Z&QD6@jp_uIdt~^iU0psyOi#?C!6htn&Vyy&P%yNcdv3@xRMwrFLn3JFR0df5X*VtkbfN zfBy94{x;TWpT2~|a0%9$Dz9YJe$g+fVdT%r#ALQ{`tq0Enm128({jE3L(z;cJY~{( z%Y$>0H|(4K;?+~hxTgniNK}jWhy6X|JyZVY;w1Y=`uiRla~Ygbd?vIkSN-Pw44v(a z`@}0>r`b>DJfGL+%yanQ5nz5%t2m_jptooFsm1kYq?Ugx`w-jp?U{vyk=X7Vv65+5 zUi$5rJ5gjUYtN>m3+qm3@GsBMKOYhC`DXdz$&AZ0L$f0%uG$;-OE&Ad#F~@ucN$78 z`?q%P1=SMND!{{4AXGS}3r{>%7i#pJx_64}1k;=T9k!FIZb1%UL9In8*Fbvl~(D$#ar51KFNxu}g3@l}h{dVO=Q-&I461K<^*=7$>{Bo$e(w7Her`9;s_mL{PVZ6vxeuoGZ06jLRz6SD zFrIHAI!F0^%~R2t{(la;&wV2M{`&u=^KK`VHGm5+0jE2)f4JSmdMExk@-GEgz^azG zMwFx^mZVxG7o`Fz1|tI_GhG8iT_d9qBSR|_Q!67QZ36=<0|VEEyG&3t*W`v?>R3!7pPVOq&6g@ zG&jl0$|XO!G&eP`#L6lFsI-{DaQXk;`eQj!5ua(Rs+2T+M5k`mv{+|-iFf>Z{utMm)<((N~e`JtKP8v<0tU}$b;X=rF+ zY-F^$)T0upgdfSA;LNI21_LLjDU0mh0Hwl^q(Xx{8PYOyQh{FA&&?~z$xqi$ODxMw z&d)2>M+CXPky&D*sZpw>Ns6UOTC%Bufn`#PiDg={iIGWiB9PVt>d*taZ;pQ}s1j!I MboFyt=akR{06~Qv&;S4c literal 0 HcmV?d00001 diff --git a/priv/static/android-icon-192x192.png b/priv/static/android-icon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..12fff5763a3e56351b6eab675c51efb4cac5c56d GIT binary patch literal 1670 zcmb7_X*d)J7{}ekxN4c?c-(R|HH~Y8VGXioWHjTL$sL1SIg^qcVGz^EJ!p*4OcDmM z?!^pJ(xOCilNA~4Qe^Fip56VjZ6DtM`#kUSet56v_omv}T8Rir3vqFAiCCY5+aLIj zzw9vIfo_jb)48|=kk)WB^wl>X$Zylo-m+{Mg!z~doc6eBdfeY<&Zb=Pm!nRp!O=wV zC78lF#aqXwF|N^=+Y&<<7XjBqY0bp>Br}>gRjJSu(9=M9*!=$IU`Pdk*^v*yZy8mr zrQd)p1}#Jv@9h;2=I2A0?Tb+NbTCWG>u))uS|M60^hmpuOv^5tC@T!I9O>&uJ>&_k z^bjK>?q`Kk!9gMMnT?gU^tRU>7)l%)>OjB1II$3Bvb1Rhm-S{CKKe8rNa*AjuEmfY zR5{CBnyfldLL=6>%(gPLw%&?2FhBjQ3)xB4A7u(EyUeU)AuUb{ZM%7Jdup4e^QY8s zfq@Dvo^Gc&DS)`we+B7CZlYn_{vE7@UH^mZ$VOn#C?yUR+jnU~Yq?t!mBuq=u-D06 zSvVVk-7*Ix^Ef7p);zI1-yrQRkh~%nn&;=v;Y3*D%IHHC&Qy5_qDX{NnM{ayN{M&?NIacIt)D~{D$A}a{ zfUN==1lFw3CEGC1?>*M9E8NA^->tBtl}X@^p-Y=8jWj7zjSe6yb7zEC&3qNK!1t>9 zOp5)-dnA4#F^qzRPS2={WWQ(p(Vj!hsCJx;%+-5M-XhAy85*Du^NY1?C4p_C@LldY^|2o zx^6d6pRFu>2wUtxNt5HAeC-pG^2I%E;o2Cxdl)=FX69I3PUw;*cETn zkH#6m!M+Id732+Uzzs>mPEW{RnK}8!4`F;E zY9}>wr(oz1FOrtvOee`rX&QGlW0v2H#DxF)%(pa;GvINAbm5LYHCIcRkI?r| fjvo*mJ$owJyh%@M$8+)Ezi?Sw*utxRbdUcNvLNpF literal 0 HcmV?d00001 diff --git a/priv/static/android-icon-36x36.png b/priv/static/android-icon-36x36.png new file mode 100644 index 0000000000000000000000000000000000000000..6de7302056d11ef1f6dffaf9ffae7ecac00e8ae4 GIT binary patch literal 1087 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 literal 0 HcmV?d00001 diff --git a/priv/static/android-icon-96x96.png b/priv/static/android-icon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..9afd32e9e35f29c0b0951c0fba740dc3b3dfbd2e GIT binary patch literal 1508 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD0wg^q?%xcgSkfJR9T^xl_H+M9WCijSl0AZa z85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YDR+ueoXe|!I#{XiaP zfk$L90|Va?5N4dJ%_j{MWH0gbb!C6WC?={a-*T#T5HAh^eH4PSSbo|e#+tXTh?9{n7mmCd~fPfhZI$IWe zxGK?W_;I4gBWb~hd=`AuqPee~tvYn)&PD@)y7`$m_U?S2zt6aO_kPJVM*$Yc8z&+b zt~#2zNG4qHZ0e+bpS8ZszA;ZVmR4r@8lN!T*CJuAl5|I*dv)Nm9Zj-fH!ifSc(UMG zg`1_z&s)X($U0(IkKu@ zatOnNpo4lz_tGWrP5f4Lm6L6Ubkvmz=_!JbrZ5?$=mN`q$Q zHJ;_%_P<)|yWSO<6$VF{_lZ@$FSvP3^VFWjBHtynqgFOP{--vNt?W}|=wF`8|3tsf zk-g#4r6rd8=fl*to9&@XCY{Z@_rptgPC&oIhUu4lbdy!5I83{K^uR9%o*8Z+q|>?Df@`9Nz|u?JnleI->1jQ>E*lx9^Jwk(=e%q!OPkQVf9F296ie?s9Wcc^W|{BIbUl;x^X*c5E%SmF|1^5>)+TtC zoz#m3M*Wv$`gi6rhMEMm{ZhSE*!I~cUE$32LhsBaT;40ErdezVj|OFg34a*pz0}v_ zE}8Wfm@`#NTq8@X{iK&&5k+y+>m4Sil!d)gP z8glbfGSez?YcTo4)B)6>0J5PtKdq!Zu_%=xqokyu*h*hNGdD3k6^JvFv-R?e()XN~ zoeNYe0a6>1QJR}%W#y8eT$-DjS7K!q090DcV7UDMZhbU0o*+}KtUOXP(=$pK3@wcu zVhcloYQ&MufvO45Oex6#DY?8xkprki5=n_~W^QUpWkD(f*j4%kdFl3>!u-(8@eKj0 zVlXr}voticFg7w;UFuN@RKkyBPH<*bDuaQO)09PaZ-7!^NK&Ceo(yT3IjKOe>*wZ` zm!0(-^eU6(bOo_(j>*wBrVz0z`!yo#l$i#*~G{sIT1+f0d?pB V-8aX-6;ygNc)I$ztaD0e0svtzRdN6T literal 0 HcmV?d00001 diff --git a/priv/static/apple-icon-114x114.png b/priv/static/apple-icon-114x114.png new file mode 100644 index 0000000000000000000000000000000000000000..4e638b84a1398955fefd1e0b8fd03cc3f3edcc36 GIT binary patch literal 1592 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 MboFyt=akR{06~Qv&;S4c literal 0 HcmV?d00001 diff --git a/priv/static/apple-icon-152x152.png b/priv/static/apple-icon-152x152.png new file mode 100644 index 0000000000000000000000000000000000000000..35844d8be4207f6ff310a399318bc631bfe9f264 GIT binary patch literal 2004 zcmb`Ido)}57RL`)ub^dAJEGN>pbU}6Br2)PAkt8@-mUbJlNT9{q)9x7G6YqsRYf!8 zDpT4S!wP!QHhQ%TqsdAdEJn@hv_o$cgN}N{qrE4yy4G}N&D_84AN#l0Z|~3ko_&7j zoP7$8h8)^ry2BIzfGq(OQW)f~=?!KCtu2bluOVj?>l5q)0L_=o*1=8CelLR(77PGs zjsTD)27py4lr;$eiD&?rq5%LA60qi1-3|4G5{C4{he*KN{7cGzLpu{b<+u<4%zN}^ zQ08L36ABuO0)qXGrwl&aj@%<2&UFWXP4NLFpNNzvKTF4W+gR}YYH1QRCZ?k8=oaUE zyZs27pOwXsQRml>q>73V(A~tk+#OlPSH*Tk|O;by13!;Wy zpM=wP&XHnn8>i%G>>3HZ{kp(=!_hQfN6Cp66wOiablXVB3#0<(GEH!e6jZBslP#ml zWszBb+UWlySe9hkR0nld5`wNUq#oZnS_yhH3YLUYR`%HzWwb!xP=J$_=De2W43lzw zkjN5PYfwazbF+k1B*hTEEnqwY$waFA1{mg$Vl_ z>$z8#Uy3Z3;HQboh>jiYwtHk8f&*S*8?OyE-+v;$5jQYnr`G+Fcsvy}$dHG$&bg#K zyD{3sTR%0+*J)+ur@)-KnNC@5#qra#v{dlq%(C>({xHw(CV{cSq~YdtW?H6!NY-8@ z5gb|Fa;9*ImwN7}t-79p&77KwC5O-Qb>(NWkE|wo?$QiW_Adre#dU|3SJI^I9u?0# zzBjx#YGs*J)~-9~1LEa9&6J@J2i(E0wjLtG=^r)s7ziBRm^>;CPFeqXyQAI1ffx7f z6k``Av<36{uA%vcW*9XT!N{mt{_D5tcMqvhhJMA_(S2oYorLUuSU8Ely&ZYcW*~~r z?IgFg)=%Ppavcxqm0u4lB_|+PKl|LVt+naXQgVG^Or;Cj1dTNvOAQ0UEDYH`%Y7^ z9~W#!Xa_ce`ex-AwS)P$w@fmE(s%OD7n)DUgbb<^Jjlb%eToAjcsNfh9?z~4%427j zH4jFLqrZ3o-+VCHa>omQ#*`X6VY?_#)r?rf6N`zqGEI6ss;(seA~LYibHWziO&ruY z@2=4hrg~|kyXj^1bR^=IZ>coHNA<-=q()6n!~gyQe>*dyNO;yMbVX=aXQSr+-dKFO zfz+bo`3XvKCE0-S!`b4r%CY(dzl10drn;C_%G_mUGtu(O%5Qygg{%Jd*yROmj=pjG znP(k-e$;=Oz3#nbV!?j`u54_+spOJBc&ylzKm6FWZ?wd)O(52C3N@t5$6c$n^nFmlR^*dNv|1kxM5MNjyd(mz5wQ5|2Wt5Ww7g zdk^&oCP1%3BvM$M*fM2^fsW$5?DbA|(|74=>! zGAsaKbI!1!TSsvdL}z%hC^neHVek@!sNaqpg>?r(JeEbkF$p*}1CK@%=u8}e&A?%C c3=r~eP=y=RU)7OZ{ofi0@DCw1`O>og3#KGYK>z>% literal 0 HcmV?d00001 diff --git a/priv/static/apple-icon-180x180.png b/priv/static/apple-icon-180x180.png new file mode 100644 index 0000000000000000000000000000000000000000..8b3b1ac61a904d97b87234b4b3717214dc89968b GIT binary patch literal 2419 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 gVq}t>2&DCZI`n|#yi54i_A_cz9PH29;cR$jU zKQ7sDePiM^-L)r=nXkEWw4_*P=cJVCBeQqiVmr9nt9jd(iVF%eK1O7=uVal}_wu-S zf7zSD3$Iow@<@NEt^Tda-IV|5C+GpVE{&iub|IxQgLS0we z8K(KEeA+zelIlc{Cwo708t=~wQ`wXeE3hV7q_d{fve7nc*`lyjD&IDFq@0_f^|Qua z?aG@2kWAMSXkt~bf>e(N2rBg3UR-S@(j19|?}|Eb@5b>~OD={=9Ns-c&PwN$RG z$}pedb3#4BM0B!N<@BP5Ax5IRzj7`Mdbi=U8aG?ag}DZ|`QsvbZGHD^%r}rv-Y!|H zlvwK?cWUC|xF5Wa#3LBrPuDyOOjxQVt`Q|Ei6yC4$wjF^iowXh$V}J3P}j&P#K_Rf z#MH{jNZY``%D}*N;Vu&t4Y~O#nQ4`{HJJQi>Hum`0NGHSpH@3h!0&IPKK0I3bhD9uf>vU15!F3nBNE3vW)04gnJFkJqBw?3L0 zPmn2ARvxLD=@}&qhL%PSv4x>PHR4F-K-Gk2rj%rWlw4k;$N^L$iKN6gGdH!QvLKZK z>?-|&ymb3bVSZ@l_=W&gF&LVgSsEHz7#kU_F7>DcD&a>mCpfbzmBGNtY04tIH$bT{ zB&pCKPlmM2oK&FK^>gz|a`Myl(-O-vlk@Y6^$|g?Z)BF3Xlj&dX_8`Tl9p_0U|^Y) lVq%$=Y+_`RoCu`#fI9Sm?wjM^3MzaUJYD@<);T3K0RV2g&*A_8 literal 0 HcmV?d00001 diff --git a/priv/static/apple-icon-72x72.png b/priv/static/apple-icon-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..2b04a2dfaa7254f397d1b735d3085e89f95bb2a0 GIT binary patch literal 1285 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY0wn)GsXhawSkfJR9T^xl_H+M9WCijSl0AZa z85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YDR+ueoXe|!I#{XiaP zfk$L90|Va?5N4dJ%_j{MWH0gbb!C6WC?={a-*T=VyOxa1(^!7LlH zVE&E9r)FO(8tpy_J^#MrPSERnX`NRy`WE}It6X-HQE&Rrc1{bMhq9at4jIj!X#0K5 z@3`oAKO&TD-3{p&Gr`MwLb zeId(z{92PgahQkAdB&CM$67Lnf8%2gmVC)<=O5NzJ^L!dPwOaJ@!XV-d7`KG!07yM zzWvdSCv(>^8XUBUPvO72q|4#Krw!ffBx@y2o^QP%cKz>Px473DugWA~oa36Z;^K#(>blH();g1>?>f7wVek2MrSZ#;B)Xc46^i6%2P|d@ zdvGK?)$P>}rrC{U**;!x)AqddoT_8|FXO!0_+=kT=Ta0ehSc@JoiA+>{TZBxh&tV+^l_eeVdMIxlm7G+m(5Zduop* ze{Py2vhx&6x}j*97`M&wi8uZ(IHAeYdbj)(n{v#e1sV5p<2El`;eBO))RUb~VTKOt zKC|iSu4Q~JK5N<;?bYV~uYpNdwZt`|BqgyV)hf9t6-Y4{85o)A8W`#t8HE@bTA7$y z85wCC7+4t?xGvmff}$ZeKP5A*61N7EKTI7!4GJI|iu2P-$`gxH88S*r3W}}t^)qu5 z(^G*sGdWu?zbJjrdD*!@wGtq;AsMB)Nmf=a`N^fZsd*(zbVWQ%^cqlpehDKb2CdrLknXgqt&Gzl|Uu@Nah4*R;4l+I5|yOWcLOr6^0}g z8sy24mYI_Z^tyg-UP(@Vx_(+>S!Qy6Ua>wR$n}lP5))00QY}qVEKSmqO$`hzlTu79 i(~?b$Op+6Uv>s509?*Sr{98fA8-u5-pUXO@geCx~G3QtS literal 0 HcmV?d00001 diff --git a/priv/static/apple-icon-76x76.png b/priv/static/apple-icon-76x76.png new file mode 100644 index 0000000000000000000000000000000000000000..d9205d1d0ffbc982ad4f11a1dcd61db59af2f285 GIT binary patch literal 1309 zcmeAS@N?(olHy`uVBq!ia0vp^J|N7&0wi~r;B4q#=W<*d^3a{CEDiS zRofD{dZX_KEu|>dY607hBu)=Uk?Jo`0$Kc(f*cpJv+k`Bb_uY#vbajXMpHMAMgNuW zLWL!c8@!HQxz&`GT~}tapl5PqmDq%s(HDI{LA`VlUv`{EZ$>u`^W1V`}7kR-hbZyCUx)RsGLVHSH_uD#ud!Z zEZrJ-Puu+QtJNunbk_2`8Af8c`mZfNZHZ+lx^CA^PXpmX07RemtQ<{H3P@H!t2TT+b-_xKc2&; z;JV#9BDW{%Np0WS_^VeootN!iuM-}%K zuhROV<#Q>lr>@VqqcLG^F?*XyvJT^i*7TZbEjf2y{ZVcf&dEtyU97#BCm~_7(#t2g zkA=k8nq-0v8kf%atZlPOD*Tm%{M6U2l14)5liEW>{JJ?cDsY%8qvq&-~%;mf8Xg*cd(bC02lWitWdAj3VWzeNC`-0O9 zlX525iT+J+{Ft8c;rbSzUlHn>!a5r}rcDbw@{&D?z{CwyywNm{kUHKHUXu_VKYk^7#Uibm|7VbX&V?=85p=O++~8IAvZrIGp!Q029rNb9Y75V zARCJF(@M${i&7afN=gcft@QOXa}(23fjBccTQ9#Teb0H>xj?lNAhjVGrMXE~RxbI; zrManjC014eK&8bDhRgr&)<;w02{OgX$|E&1J)?xd(9*~uwlEZ^MjXi;sG9K1l#&dP zlFMrpIecG219c*OG85oVmdKI;Vst E06>HPOaK4? literal 0 HcmV?d00001 diff --git a/priv/static/apple-icon-precomposed.png b/priv/static/apple-icon-precomposed.png new file mode 100644 index 0000000000000000000000000000000000000000..df396cfa209361798f2c6e9a4e24b48f2272d1f7 GIT binary patch literal 2208 zcmb7_dpOkj9>;&=Qbd=-kjq1fbsckOX4Q~!J7%oQjLX<$jJcID(_Cn}tx)bOjfxl* z8cVjtU@@8|h`KCjRFd-?sI&o?75 zz(-HVLJwIPUFz7orY^w%1t_+uFgHD6xLGl2A+FadLnkG26r27Vw0N|tz03@XX zzy~mtGzS1;7yziE0RUG307m@cyFqSXLOsUchXAanH5IK0C^M;eTuI zUv`IQ@FaOV=kS)kd(Rn7Qlcc33kDAssQ_*dT2@$HKHt_T2R;EvxQrj{pn#@xb@K>cWBu$MRgC!%SmVD z2u&@<(LGx;y%vB&EPK?u!jp`3chdh?u#%*fhf7-jl4=mx|5biM6Qp~jG%4t8U+kEJ zs>?noOMSw1ty4Ka^QoA+?5Ufk9+swG`>pq9jV27Ow7DIawYGVBDehMPP;~A1PxX)b zY;gUdy#yl5t6L#ST^h4{z`mGe_S})KN>$~V<)H8)gC{-F1Co207t*LM4{rpN;u_4O z$ZwNHsK({3^yLKk&nbR?X#fBJNq>hdP(HgOKaa6_Xq(a0+giYtn}ieh<8FD+%|fhl z$KM7e4qWTit)69+FsZ7RHawI>tTIl_P#cZ+@O*bX2U$F&em`O@kBM0{Jk!Z*Sbbq; z{c%D#65Jc};rf*2x6^hb_aELrMcj5^%^&OGS3+$wvpU^5|CThr66I)qy61z*IIVWX zwx&2C0O?vH?;}ooVr~nokOIfD4o;?@p3*AXXd!l~c99@`j)fk_Rz@s#K_|B`s}z;~ z)PipKYYH!awPBicHC9@6uUa zYR^ysYu^Pa{yFldyFZcOnNQe-R(@VSNZb3kN=(`f!!sv54o)@^-1Ee(p9#;i;+LRd z*7zQ@LMk(KW>X6y+m2mIs)XmRcbxrbP_6^R&bV1jI$kBf#kKOOBJsflt#wYItm(nf z-^L7YQT2XIT#kI#?Iz`C;$Q1FYAyJXudm=^{^-y8%O_loY`9brC@(XJG$P+9x~$u-DipY^TzL-&~ez>g=FcdZD^U_8a!cB=7FlRwTAZnwp-t+|1Ck zD#@{fzM7t`Q|{AR$+P-gKNvmq=TC~}Y$Lau!exerN1CmQX9GqF+XZ>_V!hJ6%HZ*6 zwDf@nstzI7x|8*~t{`*udza$VfddWsR@=3yC84ER=4#_lnEdpQ(OEMmN0nVe@aa*H zu$o($_syD*47gsEo_oACisj!=j&vo!*+kEI(iv*pO_w)BSuAv(g?dx7>!(Lo?1vvC z!}C@h1}ic-CKbs_RR2e}XBNWt^rNl4i{v$z^GD_e+@k!3zrZ?c=2o72C4p#KKx}F( zy%xM7tet-nm8;q=ZrjsUZ+pG(is?)5?-=zv{DoDE%W~p9B>H_;!S8j1UoQDqy`xXs z{hAllAEX$~gB~VxgqeHL7A?(XgmGh7whWsD) zr72IX=iA3k9Q}bjSZgmPDzIkt%%34Gb}y2=F^ZzgL}K$&T$gS8TkGqB1L8XDNKIOR zy!>9G(%8kduk*#Ji0qWY`6;KT9O8kH#P=Dw@2@@7-a?Y6L{v)6C+rV&j<-ifxSvuz z9X=)c>v!xM1⁡UDA**gUhcvY}d%_+uX;&j!u5=Eb(l{IJ}RU1>YKLkyp4VmL_80 z=mG}l00azyae~2}5C}2?jzghw2!sO+hJ(SpZr(-xs({OjjRZmb~W zF-3_q0Rv!*M0_F6*_p$ou^6Cn=*OTu0jqV;Tn4hn8!VYE=EmS~1RhsSHtGDE3FE|y*c*u1=`CX*vS$-Q;&2%v z37-M{Ygf+vcox1cG~hQnBr-?=a2E^~4tGT&5Vys?5)j$3NhgGJk^#UD9m+2~I1i@W zH>D`SegKmb#{iG+EaLK=nY08Bofj{3Mqp?(G=hOe#bQxRIvNJU#>AqqOgajIqSHV> TB=H2L0Km^HfN;&=Qbd=-kjq1fbsckOX4Q~!J7%oQjLX<$jJcID(_Cn}tx)bOjfxl* z8cVjtU@@8|h`KCjRFd-?sI&o?75 zz(-HVLJwIPUFz7orY^w%1t_+uFgHD6xLGl2A+FadLnkG26r27Vw0N|tz03@XX zzy~mtGzS1;7yziE0RUG307m@cyFqSXLOsUchXAanH5IK0C^M;eTuI zUv`IQ@FaOV=kS)kd(Rn7Qlcc33kDAssQ_*dT2@$HKHt_T2R;EvxQrj{pn#@xb@K>cWBu$MRgC!%SmVD z2u&@<(LGx;y%vB&EPK?u!jp`3chdh?u#%*fhf7-jl4=mx|5biM6Qp~jG%4t8U+kEJ zs>?noOMSw1ty4Ka^QoA+?5Ufk9+swG`>pq9jV27Ow7DIawYGVBDehMPP;~A1PxX)b zY;gUdy#yl5t6L#ST^h4{z`mGe_S})KN>$~V<)H8)gC{-F1Co207t*LM4{rpN;u_4O z$ZwNHsK({3^yLKk&nbR?X#fBJNq>hdP(HgOKaa6_Xq(a0+giYtn}ieh<8FD+%|fhl z$KM7e4qWTit)69+FsZ7RHawI>tTIl_P#cZ+@O*bX2U$F&em`O@kBM0{Jk!Z*Sbbq; z{c%D#65Jc};rf*2x6^hb_aELrMcj5^%^&OGS3+$wvpU^5|CThr66I)qy61z*IIVWX zwx&2C0O?vH?;}ooVr~nokOIfD4o;?@p3*AXXd!l~c99@`j)fk_Rz@s#K_|B`s}z;~ z)PipKYYH!awPBicHC9@6uUa zYR^ysYu^Pa{yFldyFZcOnNQe-R(@VSNZb3kN=(`f!!sv54o)@^-1Ee(p9#;i;+LRd z*7zQ@LMk(KW>X6y+m2mIs)XmRcbxrbP_6^R&bV1jI$kBf#kKOOBJsflt#wYItm(nf z-^L7YQT2XIT#kI#?Iz`C;$Q1FYAyJXudm=^{^-y8%O_loY`9brC@(XJG$P+9x~$u-DipY^TzL-&~ez>g=FcdZD^U_8a!cB=7FlRwTAZnwp-t+|1Ck zD#@{fzM7t`Q|{AR$+P-gKNvmq=TC~}Y$Lau!exerN1CmQX9GqF+XZ>_V!hJ6%HZ*6 zwDf@nstzI7x|8*~t{`*udza$VfddWsR@=3yC84ER=4#_lnEdpQ(OEMmN0nVe@aa*H zu$o($_syD*47gsEo_oACisj!=j&vo!*+kEI(iv*pO_w)BSuAv(g?dx7>!(Lo?1vvC z!}C@h1}ic-CKbs_RR2e}XBNWt^rNl4i{v$z^GD_e+@k!3zrZ?c=2o72C4p#KKx}F( zy%xM7tet-nm8;q=ZrjsUZ+pG(is?)5?-=zv{DoDE%W~p9B>H_;!S8j1UoQDqy`xXs z{hAllAEX$~gB~VxgqeHL7A?(XgmGh7whWsD) zr72IX=iA3k9Q}bjSZgmPDzIkt%%34Gb}y2=F^ZzgL}K$&T$gS8TkGqB1L8XDNKIOR zy!>9G(%8kduk*#Ji0qWY`6;KT9O8kH#P=Dw@2@@7-a?Y6L{v)6C+rV&j<-ifxSvuz z9X=)c>v!xM1⁡UDA**gUhcvY}d%_+uX;&j!u5=Eb(l{IJ}RU1>YKLkyp4VmL_80 z=mG}l00azyae~2}5C}2?jzghw2!sO+hJ(SpZr(-xs({OjjRZmb~W zF-3_q0Rv!*M0_F6*_p$ou^6Cn=*OTu0jqV;Tn4hn8!VYE=EmS~1RhsSHtGDE3FE|y*c*u1=`CX*vS$-Q;&2%v z37-M{Ygf+vcox1cG~hQnBr-?=a2E^~4tGT&5Vys?5)j$3NhgGJk^#UD9m+2~I1i@W zH>D`SegKmb#{iG+EaLK=nY08Bofj{3Mqp?(G=hOe#bQxRIvNJU#>AqqOgajIqSHV> TB=H2L0Km^HfN+N`*Ie)SyFTJ(W=>4@XGQVfEWip&YYItzH-6CTSu1^x;{&J z#Q%mvboVxvT4qP@+pG5Zn`~YCJ4r>nG@x9)EAQ$>!KZF3U;W>FGG*$P2_eDm(^TX> zJia2l%Wa2|0I&Gj2mH%-+Bi(SVKNu!Th$WRh?11Vl2ohYqEsNoU}RuqrfXoRYh)B+ zWN2k#YGq`kZD3$!VBorNmkEl7-29Zxv`X9>O#U!+05vFpY$(o8D=AMbN@d6>DJdwn z($~+-O-xS(;>_f1z5Jr|J?CXXu8{z#4aq3YO|r6b$xklLP0cH@vI+nyEoLxW{(rYV zni@}#DOOexsp+Gg_NajG*glDFdWPp@hUZcnXR3eF_#5XfHwWP8j zl>zK3{erx7`%PhfXy*8a097#&BSw9K4Tpx5k4UEa{HEjtmSN`?>!lvVtU&J%W50 z7^>757#dm_7=8hT8eT9klo~KFyh>nTu$sZZAYL$MSD+10f+@+{-G$+Qd;gjJKptm- zM`SSr1K$x4W}K?cCk+&2FY)wsWq-veCaNpna;?>jfq}8q)5S5wm*H=%o%oDzuFZL8RSv(K%9b~nUtB0DS<&inVbRkMC;mS9E3-mo(^)o7 zCY6&xPwswLQ*b~nvdJZ4srS(X&u^r6mp)?gn9L}vxYgM&Xg<$_ChpGXFL)m8I=?MU z^v|c;`|{R){&@SG*XMnmQv5onOkPh}y4=H8O}k=J#Z334W30g|G|mW3W%14W^lqnZ zE|=l5)R5#$Tx!$vlRIlZw_WgC3XBug64!{5l*E!$tK_0oAjM#0U}UCiV5n&Fm(VmD1dAz&QB{TPb^Ah$S5f(D7Mnq z&&*9sPX*%48@mC9h?-xEQB{})&`e}(}naTNi#rlXK*EcdtOf)r0wKPewG)YS~H88MD lN-?oaOExhwNlpaPdO#g|K=;k@Zw2LB22WQ%mvv4FO#sf0WS#&3 literal 0 HcmV?d00001 diff --git a/priv/static/favicon-96x96.png b/priv/static/favicon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..9afd32e9e35f29c0b0951c0fba740dc3b3dfbd2e GIT binary patch literal 1508 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD0wg^q?%xcgSkfJR9T^xl_H+M9WCijSl0AZa z85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YDR+ueoXe|!I#{XiaP zfk$L90|Va?5N4dJ%_j{MWH0gbb!C6WC?={a-*T#T5HAh^eH4PSSbo|e#+tXTh?9{n7mmCd~fPfhZI$IWe zxGK?W_;I4gBWb~hd=`AuqPee~tvYn)&PD@)y7`$m_U?S2zt6aO_kPJVM*$Yc8z&+b zt~#2zNG4qHZ0e+bpS8ZszA;ZVmR4r@8lN!T*CJuAl5|I*dv)Nm9Zj-fH!ifSc(UMG zg`1_z&s)X($U0(IkKu@ zatOnNpo4lz_tGWrP5f4Lm6L6Ubkvmz=_!JbrZ5?$=mN`q$Q zHJ;_%_P<)|yWSO<6$VF{_lZ@$FSvP3^VFWjBHtynqgFOP{--vNt?W}|=wF`8|3tsf zk-g#4r6rd8=fl*to9&@XCY{Z@_rptgPC&oIhUu4lbdy!5I83{K^uR9%o*8Z+q|>?Df@`9Nz|u?JnleI->1jQ>E*lx9^Jwk(=e%q!OPkQVf9F296ie?s9Wcc^W|{BIbUl;x^X*c5E%SmF|1^5>)+TtC zoz#m3M*Wv$`gi6rhMEMm{ZhSE*!I~cUE$32LhsBaT;40ErdezVj|OFg34a*pz0}v_ zE}8Wfm@`#NTq8@X{iK&&5k+y+>m4Sil!d)gP z8glbfGSez?YcTo4)B)6>0J5PtKdq!Zu_%=xqokyu*h*hNGdD3k6^JvFv-R?e()XN~ zoeNYe0a6>1QJR}%W#y8eT$-DjS7K!q090DcV7UDMZhbU0o*+}KtUOXP(=$pK3@wcu zVhcloYQ&MufvO45Oex6#DY?8xkprki5=n_~W^QUpWkD(f*j4%kdFl3>!u-(8@eKj0 zVlXr}voticFg7w;UFuN@RKkyBPH<*bDuaQO)09PaZ-7!^NK&Ceo(yT3IjKOe>*wZ` zm!0(-^eU6(bOo_(j>*wBrVz0z`!yo#l$i#*~G{sIT1+f0d?pB V-8aX-6;ygNc)I$ztaD0e0svtzRdN6T literal 0 HcmV?d00001 diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico index 5c36dc20e31b8ffd2a72b056aa125267c1302432..ede5aee4601203130ceeac0208c3114c9d1c77de 100644 GIT binary patch literal 1150 zcmc(fu}T9$5QZnlBC(LGEE7xZMSTJvAXZTyz&a8TEVNKammq4e61#{^ti=ROp^ZL| zSR{VGw?mFVHUtFc_-^L@nc3UD+dH!yN0J!lRXbcXTQRdWKtf4%oEf>na?0=Z-<(bu z<{U0yCk&a#?*~o#9!Qg^Mw)umJIZiNEL(zC@LZp`2r|{UF8>X~pk3o-s_lW-Jrfr| zrW)z$QSUCi!zHYPYyr063A(TaIgqL53f`asJXL#yDok1fL2I3~1B3_AGts^7p#wFj z2afPs!wV>OT{Y^x1E13-aSPgT6VN(8;R6)Au9_xnz$}|K|5<&W|F8bo`}4WQe39g| r{S)i+Tx|b(P0Yu>pIG1Axe;GNfgeSd(s4S>8M|wEI_9fvmPNfk<+oAT literal 1150 zcmdUvzm9@H5XKh;H5NvjtEmWuFJeRC3)o&^M`hxpU`0dW3s93-n^=+10mj6TP*`aE z^WE-Th8UtIM|VqDW@oT7C5~g9&u1Ku$4W!O+Q#!&s};>3 n3NS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fF5lweBoc6VX;-`;;_Kaj^+ z;1OBOz`%C|gc+x5^GO2**-JcqUD;nTiizsVw_IyAV_;x)@^oJ^W zGv8jH#<8Vg?UaV3B~3{|&5c=qIt&@@m6lZ4!BX)!$>b!brq1$v9!^P4J+s^t z;{>Mkb8~DI5}qn0E-k2%(f#+%+dlE4jhp57RHx0@-2G>s=d3d~jcwoCSnn<_x@Vm3 z$f4LG-~=S{o2G4BW6C){s(XQ6V!p@|0%abW)f>l~k(+ZSx9>^xDPc6j@O>@&-(>iS;SF@Mv2 zQGIT?h1DYy`^vr>#)`Wwy)^^x$mDPqa@J-ea+g$ghxL@M`UiHhcr~gj_?<2{|6~M&aT{Z zHLzMGVao4U`>(8cy}fm7sOssMM_e~;0%ksDycRn3@42NtQQVQ|+g9aY=e=`&N%sOq ztJPk5pVY*SICq@!y7%mBMC1|iJ@VDJo|g#>2XiCYdI?3{kqz0Uw3HmDUBO$Z&QD6@jp_uIdt~^iU0psyOi#?C!6htn&Vyy&P%yNcdv3@xRMwrFLn3JFR0df5X*VtkbfN zfBy94{x;TWpT2~|a0%9$Dz9YJe$g+fVdT%r#ALQ{`tq0Enm128({jE3L(z;cJY~{( z%Y$>0H|(4K;?+~hxTgniNK}jWhy6X|JyZVY;w1Y=`uiRla~Ygbd?vIkSN-Pw44v(a z`@}0>r`b>DJfGL+%yanQ5nz5%t2m_jptooFsm1kYq?Ugx`w-jp?U{vyk=X7Vv65+5 zUi$5rJ5gjUYtN>m3+qm3@GsBMKOYhC`DXdz$&AZ0L$f0%uG$;-OE&Ad#F~@ucN$78 z`?q%P1=SMND!{{4AXGS}3r{>%7i#pJx_64}1k;=T9k!FIZb1%UL9In8*Fbvl~(D$#ar51KFNxu}g3@l}h{dVO=Q-&I461K<^*=7$>{Bo$e(w7Her`9;s_mL{PVZ6vxeuoGZ06jLRz6SD zFrIHAI!F0^%~R2t{(la;&wV2M{`&u=^KK`VHGm5+0jE2)f4JSmdMExk@-GEgz^azG zMwFx^mZVxG7o`Fz1|tI_GhG8iT_d9qBSR|_Q!67QZ36=<0|VEEyG&3t*W`v?>R3!7pPVOq&6g@ zG&jl0$|XO!G&eP`#L6lFsI-{DaQXk;`eQj!5ua(Rs+2T+M5k`mv{+|-iFf>Z{utMm)<((N~e`JtKP8v<0tU}$b;X=rF+ zY-F^$)T0upgdfSA;LNI21_LLjDU0mh0Hwl^q(Xx{8PYOyQh{FA&&?~z$xqi$ODxMw z&d)2>M+CXPky&D*sZpw>Ns6UOTC%Bufn`#PiDg={iIGWiB9PVt>d*taZ;pQ}s1j!I MboFyt=akR{06~Qv&;S4c literal 0 HcmV?d00001 diff --git a/priv/static/ms-icon-150x150.png b/priv/static/ms-icon-150x150.png new file mode 100644 index 0000000000000000000000000000000000000000..931444176cd0b023afb0fa2d8e6ce94d54b58262 GIT binary patch literal 1966 zcmeAS@N?(olHy`uVBq!ia0vp^(?FPm1x)VD1X3*Nj=qiz3>*8o|0J>k`3lJ%LB0$O zRcZ_j4J`}|zkoswFBlj~4Hy_+B``2p&0t^21szhV><)s=6#)@sJU!0PJh;uunK>+M|s9Fb6&HTK1`Yk$}6|9NKL%$t?@_Zke3 zNhJ4xh*xSJx_NzdpHfzM+~=B^FlXnMNq)^b(H2Miic*f`PuqFyy3Er>DwTo8XEdF( zc=cTSj3<0k+tM9px9G-(Ra~OaD*DfTQ(tG+oOa>3*))|^3%hgIWYj;(Jn<^)P@CPC z1%Ea*Et?Y-WMR5gG5catB-1q21iZwAwl$}c-sX5XhfSQ_`C20&3uM-;M?r_APQ@gd zwVnx9NRZ#J{_5_}&byf^^L$;eTTEWFlwIR&w3E?9>(`1$SyQ`Wp4-S9IoTQ^cfGejKA7`4)m9*-1dpd`HQ)M^Kb5$y++mMwDg`6v!=-DXYi~3d*E&Nbh?W5vgYX) z?wS`DD|e^vJQ{9uzvA@C4b!<-=DYl!X7v0Le)m3^g|>GQalb9#Sk zyjxVlI{RhUnzdVgT-=)TBxu=R!SGKaceg&j(K~&s+NQiF{U5zv-RqX0Uh~M*EM)E9 zndf(2T;%Tzs9d`2 z?p4>GJzDm)XR@e^(3*m&FQ=JxO?V-~ChBxb_s?PDgCQxgYI)C(yx;6<^}WgCZJE&K zxBFa`@drFucEO2V^6v{gZ$F8jwji7H!a;_^p_4`J4!v46iShi0cTKlXDgHM&_Ova} z^!hx7jrKu#4clj%IVMf(<=I^2c)U>E-{sWW=elXnj_MRoHLP38aaSg#CzFeN+HBLf zmEo(jjE{??-;|eBD=FVqIrnqvu|1NVKXrdgz4-Sw!9IOv(mJiIk{jL;qMHwxB+D$3 z<||OWSkT4&M)0y389$4X zRcBuEICEArZRXkJ^~-IZy0B{tPqU7A`Gtf-j(UvFlDmY}_)Z^D_Fz9Pbo`A_y!y^9 zoQAt=Un&q0EonEFDUZC;vh80Ac`N?=czoB)XVXIJ)^V=? zG5buxq@+b!Ts>Wf)PLALTrI;5Vo5HYppiCl@`kw+rT&(F)pPYTP+*sP7P4r@ap?}r z1Dsu)zb3ogkeiUooxiF3X_3DF%M>LL*(0|Vbj;;FBOLflN?9gTA?<(~-^TW- zMQ6A+&v~d}&*I>q-#<}2m9@p^gSh#T3;*k_-R&zsvV4v|Q}|J$a<<&B${A~Fie@Cu zdF^YdD+wvXdJOsb{xelRTeV&-I>8oL#;TUMMwFx^mZVxG7o`Fz1|tI_GhG8iT_d9q zBSR|_Q!67QZ36=<0|VEEyG&3t*W`v?>R3!7pPVOq&6g@G&jl0$|XO!G&eP`#L6lFsI-{DaQXk; z`eQj!5ua(Rs+2T+M5k`mv{+|-iF zf>Z{utMm)<((N~e`JtKP8v<0tU}$b;X=rF+Y-F^$)T0upgdfSA;LNI21_LLjDU0mh z0Hwl^q(Xx{8PYOyQh{FA&&?~z$xqi$ODxMw&d)2>M+CXPky&D*sZpw>Ns6UOTC%Bu ofn`#PiDg={iIGWiB9PVt>d*taZ;pQ}s2*nUboFyt=akR{04zWr#{d8T literal 0 HcmV?d00001 diff --git a/priv/static/ms-icon-310x310.png b/priv/static/ms-icon-310x310.png new file mode 100644 index 0000000000000000000000000000000000000000..029e2407efd140a5bd9861e9285884545b894729 GIT binary patch literal 4428 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 literal 0 HcmV?d00001 diff --git a/test/towerops/monitoring/check_test.exs b/test/towerops/monitoring/check_test.exs index 88786abf..8df77a5b 100644 --- a/test/towerops/monitoring/check_test.exs +++ b/test/towerops/monitoring/check_test.exs @@ -1,5 +1,6 @@ defmodule Towerops.Monitoring.CheckTest do - use Towerops.DataCase, async: true + # async: false to prevent deadlocks when testing foreign key constraints + use Towerops.DataCase, async: false import Towerops.AccountsFixtures diff --git a/test/towerops/snmp/processor_test.exs b/test/towerops/snmp/processor_test.exs new file mode 100644 index 00000000..307f14d9 --- /dev/null +++ b/test/towerops/snmp/processor_test.exs @@ -0,0 +1,214 @@ +defmodule Towerops.Snmp.ProcessorTest do + use Towerops.DataCase, async: true + + alias Towerops.Snmp.Processor + + describe "changeset/2" do + setup do + # Create a valid snmp_device_id for testing + snmp_device_id = Ecto.UUID.generate() + {:ok, snmp_device_id: snmp_device_id} + end + + test "valid changeset with required fields", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "hr_1", + processor_type: "hr_processor" + } + + changeset = Processor.changeset(%Processor{}, attrs) + + assert changeset.valid? + assert get_change(changeset, :processor_index) == "hr_1" + assert get_change(changeset, :processor_type) == "hr_processor" + end + + test "valid changeset with all fields", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "cisco_1", + description: "Cisco CPU Core 1", + processor_type: "cisco_cpu", + load_percent: 45.5, + last_checked_at: DateTime.utc_now(), + metadata: %{cores: 4} + } + + changeset = Processor.changeset(%Processor{}, attrs) + + assert changeset.valid? + assert get_change(changeset, :description) == "Cisco CPU Core 1" + assert get_change(changeset, :load_percent) == 45.5 + assert get_change(changeset, :metadata) == %{cores: 4} + end + + test "requires snmp_device_id" do + attrs = %{ + processor_index: "hr_1", + processor_type: "hr_processor" + } + + changeset = Processor.changeset(%Processor{}, attrs) + + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).snmp_device_id + end + + test "requires processor_index", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_type: "hr_processor" + } + + changeset = Processor.changeset(%Processor{}, attrs) + + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).processor_index + end + + test "requires processor_type", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "hr_1" + } + + changeset = Processor.changeset(%Processor{}, attrs) + + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).processor_type + end + + test "validates processor_type inclusion", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "invalid_1", + processor_type: "invalid_type" + } + + changeset = Processor.changeset(%Processor{}, attrs) + + refute changeset.valid? + assert "is invalid" in errors_on(changeset).processor_type + end + + test "accepts all valid processor types", %{snmp_device_id: snmp_device_id} do + for processor_type <- ~w(hr_processor cisco_cpu ucd_cpu) do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "test_1", + processor_type: processor_type + } + + changeset = Processor.changeset(%Processor{}, attrs) + assert changeset.valid?, "Expected #{processor_type} to be valid" + end + end + + test "validates load_percent is between 0 and 100", %{snmp_device_id: snmp_device_id} do + # Valid at 0 + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "hr_1", + processor_type: "hr_processor", + load_percent: 0.0 + } + + changeset = Processor.changeset(%Processor{}, attrs) + assert changeset.valid? + + # Valid at 100 + attrs = %{attrs | load_percent: 100.0} + changeset = Processor.changeset(%Processor{}, attrs) + assert changeset.valid? + + # Valid in middle + attrs = %{attrs | load_percent: 50.5} + changeset = Processor.changeset(%Processor{}, attrs) + assert changeset.valid? + + # Invalid above 100 + attrs = %{attrs | load_percent: 100.1} + changeset = Processor.changeset(%Processor{}, attrs) + refute changeset.valid? + assert "must be between 0 and 100" in errors_on(changeset).load_percent + + # Invalid below 0 + attrs = %{attrs | load_percent: -1.0} + changeset = Processor.changeset(%Processor{}, attrs) + refute changeset.valid? + assert "must be between 0 and 100" in errors_on(changeset).load_percent + end + + test "allows nil load_percent", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "hr_1", + processor_type: "hr_processor", + load_percent: nil + } + + changeset = Processor.changeset(%Processor{}, attrs) + assert changeset.valid? + end + + test "description can be nil", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "hr_1", + processor_type: "hr_processor", + description: nil + } + + changeset = Processor.changeset(%Processor{}, attrs) + assert changeset.valid? + end + + test "metadata defaults to empty map", %{snmp_device_id: snmp_device_id} do + attrs = %{ + snmp_device_id: snmp_device_id, + processor_index: "ucd_0", + processor_type: "ucd_cpu" + } + + changeset = Processor.changeset(%Processor{}, attrs) + assert changeset.valid? + + # Default comes from schema, not changeset + processor = %Processor{} + assert processor.metadata == %{} + end + end + + describe "schema" do + test "has correct fields" do + fields = Processor.__schema__(:fields) + assert :id in fields + assert :snmp_device_id in fields + assert :processor_index in fields + assert :description in fields + assert :processor_type in fields + assert :load_percent in fields + assert :last_checked_at in fields + assert :metadata in fields + assert :inserted_at in fields + assert :updated_at in fields + end + + test "belongs to snmp_device" do + assocs = Processor.__schema__(:associations) + assert :snmp_device in assocs + + assoc = Processor.__schema__(:association, :snmp_device) + assert assoc.queryable == Towerops.Snmp.Device + end + + test "uses binary_id for primary key" do + assert Processor.__schema__(:type, :id) == :binary_id + end + + test "uses binary_id for foreign keys" do + assert Processor.__schema__(:type, :snmp_device_id) == :binary_id + end + end +end diff --git a/test/towerops/snmp/profiles/base_test.exs b/test/towerops/snmp/profiles/base_test.exs index 257e93a5..eb08d98f 100644 --- a/test/towerops/snmp/profiles/base_test.exs +++ b/test/towerops/snmp/profiles/base_test.exs @@ -89,8 +89,9 @@ defmodule Towerops.Snmp.Profiles.BaseTest do ]} end) - # Mock interface data for each interface (8 OIDs per interface × 3 interfaces = 24 calls) - expect(SnmpMock, :get, 24, fn _, oid, _ -> + # Mock interface data for each interface (9 OIDs per interface × 3 interfaces = 27 calls) + # OIDs: ifDescr, ifType, ifSpeed, ifPhysAddress, ifAdminStatus, ifOperStatus, ifName, ifHighSpeed, ifAlias + expect(SnmpMock, :get, 27, fn _, oid, _ -> cond do String.ends_with?(oid, ".1") -> parts = String.split(oid, ".") @@ -104,6 +105,7 @@ defmodule Towerops.Snmp.Profiles.BaseTest do 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, "eth0"}} + second_to_last == "15" and String.contains?(oid, "31.1.1.1") -> {:ok, {:gauge32, 1000}} second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "WAN"}} true -> {:error, :no_such_object} end @@ -120,6 +122,7 @@ defmodule Towerops.Snmp.Profiles.BaseTest do second_to_last == "7" -> {:ok, {:integer, 1}} second_to_last == "8" -> {:ok, {:integer, 2}} second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "eth1"}} + second_to_last == "15" and String.contains?(oid, "31.1.1.1") -> {:ok, {:gauge32, 100}} second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "LAN"}} true -> {:error, :no_such_object} end @@ -136,6 +139,7 @@ defmodule Towerops.Snmp.Profiles.BaseTest do 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, "lo"}} + second_to_last == "15" and String.contains?(oid, "31.1.1.1") -> {:ok, {:gauge32, 10}} second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "Loopback"}} true -> {:error, :no_such_object} end @@ -884,6 +888,122 @@ defmodule Towerops.Snmp.Profiles.BaseTest do end end + describe "discover_ipv6_addresses/1" do + test "discovers IPv6 addresses from IPV6-MIB ipv6AddrTable" do + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + # ipAddressIfIndex - modern RFC 4293 table (empty, fallback to IPV6-MIB) + "1.3.6.1.2.1.4.34.1.3" -> + {:ok, []} + + # ipv6AddrPfxLength - indexed by ifIndex.ipv6Address + # Example: interface 2 with address 2001:db8::1 (16 octets) + "1.3.6.1.2.1.55.1.8.1.2" -> + {:ok, + [ + # ifIndex=2, IPv6=2001:0db8:0000:0000:0000:0000:0000:0001 + %{ + oid: "1.3.6.1.2.1.55.1.8.1.2.2.32.1.13.184.0.0.0.0.0.0.0.0.0.0.0.1", + value: {:integer, 64} + }, + # ifIndex=3, IPv6=fe80::1 + %{ + oid: "1.3.6.1.2.1.55.1.8.1.2.3.254.128.0.0.0.0.0.0.0.0.0.0.0.0.0.1", + value: {:integer, 64} + } + ]} + + _ -> + {:ok, []} + end + end) + + assert {:ok, ip_addresses} = Base.discover_ipv6_addresses(@client_opts) + + assert length(ip_addresses) == 2 + + ipv6_1 = Enum.find(ip_addresses, &(&1.if_index == 2)) + assert ipv6_1.ip_address == "2001:db8:0:0:0:0:0:1" + assert ipv6_1.prefix_length == 64 + assert ipv6_1.ip_type == "ipv6" + + ipv6_2 = Enum.find(ip_addresses, &(&1.if_index == 3)) + assert ipv6_2.ip_address == "fe80:0:0:0:0:0:0:1" + assert ipv6_2.prefix_length == 64 + end + + test "returns empty list when IPV6-MIB not supported" do + stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end) + + assert {:ok, ip_addresses} = Base.discover_ipv6_addresses(@client_opts) + assert ip_addresses == [] + end + + test "handles walk errors gracefully" do + stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end) + + assert {:ok, ip_addresses} = Base.discover_ipv6_addresses(@client_opts) + assert ip_addresses == [] + end + end + + describe "discover_all_ip_addresses/1" do + test "combines IPv4 and IPv6 addresses" do + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + # IPv4 from IP-MIB + "1.3.6.1.2.1.4.20.1.2" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.4.20.1.2.192.168.1.1", value: {:integer, 1}} + ]} + + "1.3.6.1.2.1.4.20.1.3" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.4.20.1.3.192.168.1.1", value: {:ip_address, {255, 255, 255, 0}}} + ]} + + # IPv6 from modern ipAddressTable (empty, fallback) + "1.3.6.1.2.1.4.34.1.3" -> + {:ok, []} + + # IPv6 from IPV6-MIB + "1.3.6.1.2.1.55.1.8.1.2" -> + {:ok, + [ + %{ + oid: "1.3.6.1.2.1.55.1.8.1.2.1.254.128.0.0.0.0.0.0.0.0.0.0.0.0.0.1", + value: {:integer, 64} + } + ]} + + _ -> + {:ok, []} + end + end) + + assert {:ok, all_addresses} = Base.discover_all_ip_addresses(@client_opts) + + assert length(all_addresses) == 2 + + ipv4 = Enum.find(all_addresses, &(&1.ip_type == "ipv4")) + assert ipv4.ip_address == "192.168.1.1" + assert ipv4.prefix_length == 24 + + ipv6 = Enum.find(all_addresses, &(&1.ip_type == "ipv6")) + assert ipv6.ip_address == "fe80:0:0:0:0:0:0:1" + assert ipv6.prefix_length == 64 + end + + test "returns empty list when no addresses found" do + stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end) + + assert {:ok, all_addresses} = Base.discover_all_ip_addresses(@client_opts) + assert all_addresses == [] + end + end + describe "discover_memory_pools/1" do test "discovers memory pools from HOST-RESOURCES-MIB" do stub(SnmpMock, :walk, fn _, oid, _ -> @@ -1326,4 +1446,153 @@ defmodule Towerops.Snmp.Profiles.BaseTest do end end end + + describe "discover_processors/1" do + test "discovers processors from HOST-RESOURCES-MIB" do + # Mock hrProcessorLoad walk + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.25.3.3.1.2.768", value: {:integer, 25}}, + %{oid: "1.3.6.1.2.1.25.3.3.1.2.769", value: {:integer, 45}} + ]} + end) + + # Mock hrDeviceDescr walk for processor descriptions + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.2.1.3", _ -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.25.3.2.1.3.768", value: {:octet_string, "Intel Core i7-9700 @ 3.00GHz"}}, + %{oid: "1.3.6.1.2.1.25.3.2.1.3.769", value: {:octet_string, "Intel Core i7-9700 @ 3.00GHz"}} + ]} + end) + + assert {:ok, processors} = Base.discover_processors(@client_opts) + + assert length(processors) == 2 + + cpu1 = Enum.find(processors, &(&1.processor_index == "hr_768")) + assert cpu1.description == "Intel Core i7-9700 @ 3.00GHz" + assert cpu1.processor_type == "hr_processor" + assert cpu1.load_percent == 25.0 + + cpu2 = Enum.find(processors, &(&1.processor_index == "hr_769")) + assert cpu2.description == "Intel Core i7-9700 @ 3.00GHz" + assert cpu2.processor_type == "hr_processor" + assert cpu2.load_percent == 45.0 + end + + test "falls back to CISCO-PROCESS-MIB when HOST-RESOURCES-MIB returns empty" do + # HR-MIB returns empty + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ -> + {:ok, []} + end) + + # Cisco CPU walk returns data + expect(SnmpMock, :walk, fn _, "1.3.6.1.4.1.9.9.109.1.1.1.1.5", _ -> + {:ok, + [ + %{oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.5.1", value: {:integer, 15}}, + %{oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.5.2", value: {:integer, 22}} + ]} + end) + + assert {:ok, processors} = Base.discover_processors(@client_opts) + + assert length(processors) == 2 + + cpu1 = Enum.find(processors, &(&1.processor_index == "cisco_1")) + assert cpu1.description == "Cisco CPU 1" + assert cpu1.processor_type == "cisco_cpu" + assert cpu1.load_percent == 15.0 + + cpu2 = Enum.find(processors, &(&1.processor_index == "cisco_2")) + assert cpu2.description == "Cisco CPU 2" + assert cpu2.processor_type == "cisco_cpu" + assert cpu2.load_percent == 22.0 + end + + test "falls back to UCD-SNMP-MIB when other MIBs return empty" do + # HR-MIB returns empty + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ -> + {:ok, []} + end) + + # Cisco CPU returns empty + expect(SnmpMock, :walk, fn _, "1.3.6.1.4.1.9.9.109.1.1.1.1.5", _ -> + {:ok, []} + end) + + # UCD-SNMP-MIB CPU stats + expect(SnmpMock, :get, 3, fn _, oid, _ -> + case oid do + "1.3.6.1.4.1.2021.11.9.0" -> {:ok, {:integer, 10}} + "1.3.6.1.4.1.2021.11.10.0" -> {:ok, {:integer, 5}} + "1.3.6.1.4.1.2021.11.11.0" -> {:ok, {:integer, 85}} + end + end) + + assert {:ok, processors} = Base.discover_processors(@client_opts) + + assert length(processors) == 1 + + cpu = hd(processors) + assert cpu.processor_index == "ucd_0" + assert cpu.description == "System CPU" + assert cpu.processor_type == "ucd_cpu" + assert cpu.load_percent == 15.0 + assert cpu.metadata.user_percent == 10 + assert cpu.metadata.system_percent == 5 + end + + test "returns empty list when no CPU MIBs are supported" do + # All walks return empty + stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end) + stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end) + + assert {:ok, processors} = Base.discover_processors(@client_opts) + assert processors == [] + end + + test "handles HOST-RESOURCES-MIB walk errors gracefully" do + # HR-MIB walk fails + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ -> + {:error, :timeout} + end) + + # Falls back to Cisco + expect(SnmpMock, :walk, fn _, "1.3.6.1.4.1.9.9.109.1.1.1.1.5", _ -> + {:ok, + [ + %{oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.5.1", value: {:integer, 30}} + ]} + end) + + assert {:ok, processors} = Base.discover_processors(@client_opts) + assert length(processors) == 1 + assert hd(processors).processor_type == "cisco_cpu" + end + + test "uses default description when hrDeviceDescr is missing" do + # HR processor load + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.25.3.3.1.2.768", value: {:integer, 50}} + ]} + end) + + # HR device description returns empty + expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.2.1.3", _ -> + {:ok, []} + end) + + assert {:ok, processors} = Base.discover_processors(@client_opts) + + assert length(processors) == 1 + cpu = hd(processors) + assert cpu.description == "CPU 768" + assert cpu.load_percent == 50.0 + end + end end diff --git a/test/towerops/snmp/sanitizer_test.exs b/test/towerops/snmp/sanitizer_test.exs new file mode 100644 index 00000000..ec1ef064 --- /dev/null +++ b/test/towerops/snmp/sanitizer_test.exs @@ -0,0 +1,137 @@ +defmodule Towerops.Snmp.SanitizerTest do + use ExUnit.Case, async: true + + alias Towerops.Snmp.Sanitizer + + describe "sanitize_string/1" do + test "returns nil for nil input" do + assert Sanitizer.sanitize_string(nil) == nil + end + + test "returns trimmed string for printable input" do + assert Sanitizer.sanitize_string(" Hello World ") == "Hello World" + end + + test "passes through normal strings" do + assert Sanitizer.sanitize_string("test string") == "test string" + end + + test "converts non-printable binary to hex string" do + # Raw binary bytes (e.g., MAC address) + assert Sanitizer.sanitize_string(<<255, 0, 128, 64>>) == "ff:00:80:40" + end + + test "converts erlang charlist to string" do + assert Sanitizer.sanitize_string(~c"hello") == "hello" + end + + test "converts integers to string" do + assert Sanitizer.sanitize_string(123) == "123" + end + + test "handles mixed printable/non-printable as hex" do + # String with null byte - not printable + assert Sanitizer.sanitize_string(<<"test", 0>>) == "74:65:73:74:00" + end + end + + describe "sanitize_ipv4/1" do + test "formats tuple as dotted decimal" do + assert Sanitizer.sanitize_ipv4({192, 168, 1, 1}) == "192.168.1.1" + end + + test "formats 4-byte binary as dotted decimal" do + assert Sanitizer.sanitize_ipv4(<<192, 168, 1, 1>>) == "192.168.1.1" + end + + test "passes through already-formatted string" do + assert Sanitizer.sanitize_ipv4("10.0.0.1") == "10.0.0.1" + end + + test "extracts first 4 bytes from longer binary" do + assert Sanitizer.sanitize_ipv4(<<192, 168, 1, 1, 0, 0>>) == "192.168.1.1" + end + + test "returns nil for invalid input" do + assert Sanitizer.sanitize_ipv4("not an ip") == nil + assert Sanitizer.sanitize_ipv4(nil) == nil + assert Sanitizer.sanitize_ipv4(123) == nil + end + + test "handles subnet mask with 0xff bytes" do + # This was the original bug - 0xff is not valid UTF-8 + assert Sanitizer.sanitize_ipv4(<<255, 255, 255, 0>>) == "255.255.255.0" + end + end + + describe "sanitize_ipv6/1" do + test "formats 16-byte binary as IPv6" do + # ::1 (localhost) + bytes = <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1>> + assert Sanitizer.sanitize_ipv6(bytes) == "0:0:0:0:0:0:0:1" + end + + test "passes through already-formatted string" do + assert Sanitizer.sanitize_ipv6("2001:db8::1") == "2001:db8::1" + end + + test "returns nil for invalid input" do + assert Sanitizer.sanitize_ipv6("not an ipv6") == nil + assert Sanitizer.sanitize_ipv6(nil) == nil + end + end + + describe "sanitize_mac/1" do + test "formats 6-byte binary as colon-separated hex" do + assert Sanitizer.sanitize_mac(<<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>) == "aa:bb:cc:dd:ee:ff" + end + + test "normalizes dash-separated to colon-separated" do + assert Sanitizer.sanitize_mac("AA-BB-CC-DD-EE-FF") == "aa:bb:cc:dd:ee:ff" + end + + test "passes through already-formatted lowercase" do + assert Sanitizer.sanitize_mac("aa:bb:cc:dd:ee:ff") == "aa:bb:cc:dd:ee:ff" + end + + test "lowercases uppercase input" do + assert Sanitizer.sanitize_mac("AA:BB:CC:DD:EE:FF") == "aa:bb:cc:dd:ee:ff" + end + + test "returns nil for invalid input" do + assert Sanitizer.sanitize_mac("not a mac") == nil + assert Sanitizer.sanitize_mac(nil) == nil + assert Sanitizer.sanitize_mac(<<1, 2, 3>>) == nil + end + end + + describe "sanitize_map/1" do + test "sanitizes all string values in map" do + input = %{ + name: " Test Name ", + value: 123, + binary: <<255, 0>> + } + + result = Sanitizer.sanitize_map(input) + + assert result.name == "Test Name" + assert result.value == 123 + assert result.binary == "ff:00" + end + + test "recursively sanitizes nested maps" do + input = %{ + outer: " outer ", + nested: %{ + inner: " inner " + } + } + + result = Sanitizer.sanitize_map(input) + + assert result.outer == "outer" + assert result.nested.inner == "inner" + end + end +end diff --git a/test/towerops/workers/discovery_worker_test.exs b/test/towerops/workers/discovery_worker_test.exs index 0a79e17e..63a7741b 100644 --- a/test/towerops/workers/discovery_worker_test.exs +++ b/test/towerops/workers/discovery_worker_test.exs @@ -36,16 +36,20 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do end test "successfully discovers a device", %{device: device} do - # Mock SNMP responses for discovery (categorize_device_speed + test_connection + system_info = 8 calls) - expect(SnmpMock, :get, 8, fn _target, oid, _opts -> + # Mock SNMP responses - use stub for flexibility as discovery calls vary + stub(SnmpMock, :get, fn _target, oid, _opts -> case oid do "1.3.6.1.2.1.1.1.0" -> {:ok, "Test Device"} "1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1, 4, 1, 9]} - # sysUpTime (also used by test_connection) "1.3.6.1.2.1.1.3.0" -> {:ok, 12_345} "1.3.6.1.2.1.1.4.0" -> {:ok, "admin@test.com"} "1.3.6.1.2.1.1.5.0" -> {:ok, "test-device"} "1.3.6.1.2.1.1.6.0" -> {:ok, "Test Location"} + # UCD-SNMP-MIB CPU stats (processor discovery fallback) + "1.3.6.1.4.1.2021.11.9.0" -> {:ok, 10} + "1.3.6.1.4.1.2021.11.10.0" -> {:ok, 5} + "1.3.6.1.4.1.2021.11.11.0" -> {:ok, 85} + _ -> {:error, :timeout} end end) @@ -82,8 +86,8 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do end test "successfully completes discovery with proper mocks", %{device: device} do - # Mock SNMP responses (categorize_device_speed + test_connection + system_info = 8 calls) - expect(SnmpMock, :get, 8, fn _target, oid, _opts -> + # Mock SNMP responses - use stub for flexibility as discovery calls vary + stub(SnmpMock, :get, fn _target, oid, _opts -> case oid do "1.3.6.1.2.1.1.1.0" -> {:ok, "Test"} "1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1]} @@ -91,6 +95,11 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do "1.3.6.1.2.1.1.4.0" -> {:ok, ""} "1.3.6.1.2.1.1.5.0" -> {:ok, "test"} "1.3.6.1.2.1.1.6.0" -> {:ok, ""} + # UCD-SNMP-MIB CPU stats (processor discovery fallback) + "1.3.6.1.4.1.2021.11.9.0" -> {:ok, 10} + "1.3.6.1.4.1.2021.11.10.0" -> {:ok, 5} + "1.3.6.1.4.1.2021.11.11.0" -> {:ok, 85} + _ -> {:error, :timeout} end end)