From 8f614cd2d52afe6301fc3220ec1f4ddc4095f199 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 30 Apr 2026 14:24:01 -0500 Subject: [PATCH] refactor(snmp): replace extracted sections with delegates Switches lib/towerops/snmp.ex over to defdelegate calls into the new submodules introduced in the prior commit. Drops the inline definitions and unused aliases. Final size: 450 lines (was 2742). --- lib/towerops/snmp.ex | 1833 ++---------------------------------------- 1 file changed, 62 insertions(+), 1771 deletions(-) diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index 443a90f2..88c89f45 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -9,30 +9,27 @@ defmodule Towerops.Snmp do alias Towerops.Devices.Device, as: DeviceSchema alias Towerops.Repo - alias Towerops.Snmp.ArpEntry + alias Towerops.Snmp.ArpEntries alias Towerops.Snmp.Client alias Towerops.Snmp.Device + alias Towerops.Snmp.DiscoveredDevices alias Towerops.Snmp.Discovery - alias Towerops.Snmp.EntityPhysical - alias Towerops.Snmp.EntityPhysicalReading - alias Towerops.Snmp.Interface + alias Towerops.Snmp.EntityPhysicals alias Towerops.Snmp.Interfaces alias Towerops.Snmp.IpAddresses - alias Towerops.Snmp.MacAddress + alias Towerops.Snmp.MacAddresses alias Towerops.Snmp.Mempools alias Towerops.Snmp.Monitoring - alias Towerops.Snmp.Neighbor alias Towerops.Snmp.Neighbors - alias Towerops.Snmp.PrinterSupply + alias Towerops.Snmp.PrinterSupplies alias Towerops.Snmp.Processors alias Towerops.Snmp.Queries alias Towerops.Snmp.Sensors alias Towerops.Snmp.StorageQueries - alias Towerops.Snmp.Transceiver - alias Towerops.Snmp.TransceiverReading + alias Towerops.Snmp.Topology + alias Towerops.Snmp.Transceivers alias Towerops.Snmp.Vlans - alias Towerops.Snmp.WirelessClient - alias Towerops.Snmp.WirelessClientReading + alias Towerops.Snmp.WirelessClients require Logger @@ -331,742 +328,9 @@ defmodule Towerops.Snmp do defdelegate delete_stale_neighbors(device_id, cutoff_datetime), to: Neighbors defdelegate delete_stale_and_upsert_neighbors(device_id, neighbors, cutoff), to: Neighbors - @doc """ - Lists all discovered devices across an organization that haven't been added yet. + ## Discovered device aggregation (implementation in `Towerops.Snmp.DiscoveredDevices`). - Aggregates data from LLDP/CDP neighbors, ARP entries, and MAC addresses. - Returns a list of discovered device structs with merged data. - - ## Returns - - List of maps with: - - `identifier`: %{type: :mac | :ip | :hostname, value: string} - - `hostname`: string | nil - - `ip_addresses`: [string] - - `mac_addresses`: [string] - - `device_type`: :router | :switch | :wireless | :server | :workstation | :unknown - - `capabilities`: [string] (from LLDP/CDP) - - `platform`: string | nil - - `discovered_by`: [%{device_id: uuid, device_name: string, interface: string}] - - `source_count`: integer - - `last_seen`: DateTime.t() - - `protocols_used`: [:lldp | :cdp | :arp | :mac] - """ - def list_discovered_devices_for_organization(organization_id) do - # Get all devices in organization - device_ids = query_organization_device_ids(organization_id) - - # Fetch all discovery data - neighbors = query_neighbors_for_devices(device_ids) - arp_entries = query_arp_for_devices(device_ids) - mac_addresses = query_mac_for_devices(device_ids) - - # Get existing devices to filter out - existing_ips = get_existing_device_ips(organization_id) - existing_macs = get_existing_interface_macs(organization_id) - - # Build identifier index by aggregating all sources - discovered_map = - %{} - |> merge_neighbors_into_map(neighbors) - |> merge_arp_into_map(arp_entries) - |> merge_mac_into_map(mac_addresses) - - # Filter out existing devices and build final structs - discovered_map - |> Enum.reject(fn {_key, entry} -> - device_already_exists?(entry, existing_ips, existing_macs) - end) - |> Enum.map(fn {_key, entry} -> build_discovered_device_struct(entry) end) - |> Enum.sort(fn a, b -> - # Sort by source_count descending, then by last_seen descending - cond do - a.source_count > b.source_count -> true - a.source_count < b.source_count -> false - true -> DateTime.after?(a.last_seen, b.last_seen) - end - end) - end - - # Query all device IDs in organization - defp query_organization_device_ids(organization_id) do - Repo.all( - from(d in DeviceSchema, join: s in assoc(d, :site), where: s.organization_id == ^organization_id, select: d.id) - ) - end - - # Batch query neighbors for all devices in org - defp query_neighbors_for_devices(device_ids) do - cutoff = DateTime.add(DateTime.utc_now(), -7, :day) - - Repo.all( - from(n in Neighbor, - join: d in DeviceSchema, - on: n.device_id == d.id, - left_join: i in Interface, - on: n.interface_id == i.id, - where: n.device_id in ^device_ids, - where: n.last_discovered_at > ^cutoff, - preload: [device: d, interface: i], - order_by: [desc: n.last_discovered_at] - ) - ) - end - - # Batch query ARP entries for all devices in org - defp query_arp_for_devices(device_ids) do - cutoff = DateTime.add(DateTime.utc_now(), -7, :day) - - Repo.all( - from(a in ArpEntry, - join: d in DeviceSchema, - on: a.device_id == d.id, - left_join: i in Interface, - on: a.interface_id == i.id, - where: a.device_id in ^device_ids, - where: a.last_seen_at > ^cutoff, - preload: [device: d, interface: i], - order_by: [desc: a.last_seen_at] - ) - ) - end - - # Batch query MAC addresses for all devices in org - defp query_mac_for_devices(device_ids) do - cutoff = DateTime.add(DateTime.utc_now(), -7, :day) - - Repo.all( - from(m in MacAddress, - join: d in DeviceSchema, - on: m.device_id == d.id, - left_join: i in Interface, - on: m.interface_id == i.id, - where: m.device_id in ^device_ids, - where: m.last_seen_at > ^cutoff, - preload: [device: d, interface: i], - order_by: [desc: m.last_seen_at] - ) - ) - end - - # Get existing device IPs in organization - defp get_existing_device_ips(organization_id) do - from(d in DeviceSchema, - join: s in assoc(d, :site), - where: s.organization_id == ^organization_id, - where: not is_nil(d.ip_address), - select: d.ip_address - ) - |> Repo.all() - |> MapSet.new() - end - - # Get existing interface MAC addresses in organization - defp get_existing_interface_macs(organization_id) do - from(i in Interface, - join: sd in Device, - on: i.snmp_device_id == sd.id, - join: d in DeviceSchema, - on: sd.device_id == d.id, - join: s in assoc(d, :site), - where: s.organization_id == ^organization_id, - where: not is_nil(i.if_phys_address), - select: i.if_phys_address - ) - |> Repo.all() - |> MapSet.new(&normalize_mac/1) - end - - # Normalize MAC address to "aa:bb:cc:dd:ee:ff" format - defp normalize_mac(nil), do: nil - - defp normalize_mac(mac) when is_binary(mac) do - mac - |> String.downcase() - |> String.replace(~r/[:-]/, "") - |> String.replace(~r/(.{2})/, "\\1:") - |> String.trim_trailing(":") - end - - # Merge LLDP/CDP neighbor data into the discovery map - defp merge_neighbors_into_map(map, neighbors) do - Enum.reduce(neighbors, map, fn neighbor, acc -> - identifiers = extract_neighbor_identifiers(neighbor) - # Use best available identifier as the key (MAC > IP > hostname) - {id_type, id_value} = choose_best_identifier_from_list(identifiers) - key = identifier_key(id_type, id_value) - - entry = - Map.get(acc, key, %{ - identifiers: [], - hostnames: [], - ips: [], - macs: [], - capabilities: [], - platforms: [], - discovered_by: [], - sources: %{neighbors: [], arp: [], mac: []} - }) - - updated_entry = %{ - entry - | identifiers: Enum.uniq(identifiers ++ entry.identifiers), - hostnames: add_if_present(entry.hostnames, neighbor.remote_system_name), - ips: add_if_present(entry.ips, neighbor.remote_address), - macs: add_if_present(entry.macs, normalize_mac(neighbor.remote_chassis_id)), - capabilities: Enum.uniq(entry.capabilities ++ (neighbor.remote_capabilities || [])), - platforms: add_if_present(entry.platforms, neighbor.remote_platform), - discovered_by: - Enum.uniq_by( - [ - %{ - device_id: neighbor.device.id, - device_name: neighbor.device.name, - interface: interface_name(neighbor.interface), - timestamp: neighbor.last_discovered_at - } - | entry.discovered_by - ], - & &1.device_id - ), - sources: %{entry.sources | neighbors: [neighbor | entry.sources.neighbors]} - } - - Map.put(acc, key, updated_entry) - end) - end - - # Merge ARP entry data into the discovery map - defp merge_arp_into_map(map, arp_entries) do - Enum.reduce(arp_entries, map, fn arp, acc -> - identifiers = extract_arp_identifiers(arp) - # Use best available identifier as the key (MAC > IP) - {id_type, id_value} = choose_best_identifier_from_list(identifiers) - key = identifier_key(id_type, id_value) - - entry = - Map.get(acc, key, %{ - identifiers: [], - hostnames: [], - ips: [], - macs: [], - capabilities: [], - platforms: [], - discovered_by: [], - sources: %{neighbors: [], arp: [], mac: []} - }) - - updated_entry = %{ - entry - | identifiers: Enum.uniq(identifiers ++ entry.identifiers), - ips: add_if_present(entry.ips, arp.ip_address), - macs: add_if_present(entry.macs, normalize_mac(arp.mac_address)), - discovered_by: - Enum.uniq_by( - [ - %{ - device_id: arp.device.id, - device_name: arp.device.name, - interface: interface_name(arp.interface), - timestamp: arp.last_seen_at - } - | entry.discovered_by - ], - & &1.device_id - ), - sources: %{entry.sources | arp: [arp | entry.sources.arp]} - } - - Map.put(acc, key, updated_entry) - end) - end - - # Merge MAC address table data into the discovery map - defp merge_mac_into_map(map, mac_addresses) do - Enum.reduce(mac_addresses, map, fn mac_entry, acc -> - mac_normalized = normalize_mac(mac_entry.mac_address) - if is_nil(mac_normalized), do: acc, else: do_merge_mac(acc, mac_entry, mac_normalized) - end) - end - - defp do_merge_mac(acc, mac_entry, mac_normalized) do - key = identifier_key(:mac, mac_normalized) - - entry = - Map.get(acc, key, %{ - identifiers: [], - hostnames: [], - ips: [], - macs: [], - capabilities: [], - platforms: [], - discovered_by: [], - sources: %{neighbors: [], arp: [], mac: []} - }) - - updated_entry = %{ - entry - | identifiers: Enum.uniq([{:mac, mac_normalized} | entry.identifiers]), - macs: Enum.uniq([mac_normalized | entry.macs]), - discovered_by: - Enum.uniq_by( - [ - %{ - device_id: mac_entry.device.id, - device_name: mac_entry.device.name, - interface: interface_name(mac_entry.interface), - timestamp: mac_entry.last_seen_at - } - | entry.discovered_by - ], - & &1.device_id - ), - sources: %{entry.sources | mac: [mac_entry | entry.sources.mac]} - } - - Map.put(acc, key, updated_entry) - end - - # Choose best identifier from a list of {type, value} tuples - # Priority: MAC > IP > hostname - defp choose_best_identifier_from_list(identifiers) do - Enum.find(identifiers, fn {type, _value} -> type == :mac end) || - Enum.find(identifiers, fn {type, _value} -> type == :ip end) || - Enum.find(identifiers, fn {type, _value} -> type == :hostname end) || - {:unknown, "unknown"} - end - - # Extract identifiers from neighbor record - defp extract_neighbor_identifiers(neighbor) do - Enum.reject( - [ - if(neighbor.remote_chassis_id && mac_address?(neighbor.remote_chassis_id), - do: {:mac, normalize_mac(neighbor.remote_chassis_id)} - ), - if(neighbor.remote_address, do: {:ip, neighbor.remote_address}), - if(neighbor.remote_system_name, do: {:hostname, neighbor.remote_system_name}) - ], - &is_nil/1 - ) - end - - # Check if a string looks like a MAC address - defp mac_address?(str) when is_binary(str) do - String.match?(str, ~r/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/) - end - - defp mac_address?(_), do: false - - # Extract identifiers from ARP entry - defp extract_arp_identifiers(arp) do - Enum.reject( - [if(arp.mac_address, do: {:mac, normalize_mac(arp.mac_address)}), if(arp.ip_address, do: {:ip, arp.ip_address})], - &is_nil/1 - ) - end - - # Create a consistent key for the identifier - defp identifier_key(type, value), do: "#{type}:#{value}" - - # Add value to list if not nil or empty - defp add_if_present(list, nil), do: list - defp add_if_present(list, ""), do: list - defp add_if_present(list, value), do: Enum.uniq([value | list]) - - # Get interface name or description - defp interface_name(nil), do: "unknown" - defp interface_name(interface), do: interface.if_name || interface.if_descr || "if#{interface.if_index}" - - # Check if device already exists in organization - defp device_already_exists?(entry, existing_ips, existing_macs) do - Enum.any?(entry.ips, &MapSet.member?(existing_ips, &1)) || - Enum.any?(entry.macs, &MapSet.member?(existing_macs, &1)) - end - - # Build final discovered device struct from aggregated entry - defp build_discovered_device_struct(entry) do - identifier = choose_best_identifier(entry) - device_type = infer_device_type(entry) - manufacturer = infer_manufacturer(entry) - - %{ - identifier: identifier, - hostname: List.first(entry.hostnames), - ip_addresses: Enum.uniq(entry.ips), - mac_addresses: Enum.uniq(entry.macs), - device_type: device_type, - manufacturer: manufacturer, - capabilities: Enum.uniq(entry.capabilities), - platform: List.first(entry.platforms), - discovered_by: Enum.sort_by(entry.discovered_by, & &1.timestamp, {:desc, DateTime}), - source_count: count_sources(entry), - last_seen: get_most_recent_timestamp(entry), - protocols_used: get_protocols_used(entry.sources) - } - end - - # Choose best identifier: prefer MAC > IP > hostname - defp choose_best_identifier(entry) do - cond do - entry.macs != [] -> - %{type: :mac, value: List.first(entry.macs)} - - entry.ips != [] -> - %{type: :ip, value: List.first(entry.ips)} - - entry.hostnames != [] -> - %{type: :hostname, value: List.first(entry.hostnames)} - - true -> - %{type: :unknown, value: "unknown"} - end - end - - # Infer device type from capabilities, platform, or system name - defp infer_device_type(entry) do - capabilities = Enum.map(entry.capabilities, &String.downcase/1) - platform = String.downcase(List.first(entry.platforms) || "") - hostname = String.downcase(List.first(entry.hostnames) || "") - - infer_from_capabilities(capabilities) || infer_from_platform(platform) || - infer_from_hostname(hostname) || :unknown - end - - defp infer_from_capabilities(capabilities) do - cond do - "router" in capabilities -> :router - "bridge" in capabilities or "switch" in capabilities -> :switch - "wlan-ap" in capabilities or "wlan" in capabilities -> :wireless - true -> nil - end - end - - defp infer_from_platform(platform) do - cond do - String.contains?(platform, "ios") -> :router - String.contains?(platform, "catalyst") -> :switch - String.contains?(platform, "linux") or String.contains?(platform, "windows") -> :server - true -> nil - end - end - - defp infer_from_hostname(hostname) do - cond do - hostname_suggests_router?(hostname) -> :router - hostname_suggests_switch?(hostname) -> :switch - hostname_suggests_wireless?(hostname) -> :wireless - hostname_suggests_server?(hostname) -> :server - hostname_suggests_workstation?(hostname) -> :workstation - true -> nil - end - end - - defp hostname_suggests_router?(hostname) do - String.contains?(hostname, "router") or String.contains?(hostname, "rtr") or - String.contains?(hostname, "gateway") or String.contains?(hostname, "gw") - end - - defp hostname_suggests_switch?(hostname) do - String.contains?(hostname, "switch") or String.contains?(hostname, "sw") - end - - defp hostname_suggests_wireless?(hostname) do - String.contains?(hostname, "ap") or String.contains?(hostname, "access") or - String.contains?(hostname, "wireless") or String.contains?(hostname, "wifi") or - String.contains?(hostname, "wap") - end - - defp hostname_suggests_server?(hostname) do - String.contains?(hostname, "server") or String.contains?(hostname, "srv") - end - - defp hostname_suggests_workstation?(hostname) do - String.contains?(hostname, "workstation") or String.contains?(hostname, "desktop") or - String.contains?(hostname, "laptop") or String.contains?(hostname, "pc") or - String.contains?(hostname, "ws") - end - - # Infer manufacturer from MAC OUI (first 3 bytes) - defp infer_manufacturer(entry) do - case entry.macs do - [] -> - nil - - [mac | _] -> - lookup_mac_oui(mac) - end - end - - # Lookup manufacturer by MAC OUI - defp lookup_mac_oui(mac) when is_binary(mac) do - # Extract first 3 bytes (OUI) from MAC address - oui = - mac - |> String.replace(":", "") - |> String.upcase() - |> String.slice(0, 6) - - Map.get(mac_oui_database(), oui) - end - - defp lookup_mac_oui(_), do: nil - - # Common MAC OUI to manufacturer mappings - # This is a subset of the IEEE OUI database focused on common network equipment vendors - defp mac_oui_database do - %{ - # Cisco - "000142" => "Cisco", - "000143" => "Cisco", - "00010D" => "Cisco", - "000164" => "Cisco", - "000196" => "Cisco", - "000197" => "Cisco", - "0001C7" => "Cisco", - "0001C9" => "Cisco", - "001818" => "Cisco", - "001B0C" => "Cisco", - "001B2A" => "Cisco", - "001D45" => "Cisco", - "001D70" => "Cisco", - "001E13" => "Cisco", - "001E4A" => "Cisco", - "001E7A" => "Cisco", - "001EBD" => "Cisco", - "001F6C" => "Cisco", - "001FCA" => "Cisco", - "0022BD" => "Cisco", - "002555" => "Cisco", - "0026CA" => "Cisco", - "002713" => "Cisco", - "00D097" => "Cisco", - "00E014" => "Cisco", - "04C5A4" => "Cisco", - "0C8527" => "Cisco", - "1C6A7A" => "Cisco", - "40F4EC" => "Cisco", - "5C50F7" => "Cisco", - "6CAB31" => "Cisco", - "6C41" => "Cisco", - "74A2E6" => "Cisco", - "D4A928" => "Cisco", - "E84F25" => "Cisco", - "F46E95" => "Cisco", - - # Ubiquiti - "00156D" => "Ubiquiti", - "001B2F" => "Ubiquiti", - "001DD7" => "Ubiquiti", - "0027BA" => "Ubiquiti", - "04189A" => "Ubiquiti", - "0418D6" => "Ubiquiti", - "247F20" => "Ubiquiti", - "248A07" => "Ubiquiti", - "44D9E7" => "Ubiquiti", - "74ACB9" => "Ubiquiti", - "788A20" => "Ubiquiti", - "80EA96" => "Ubiquiti", - "A42BB0" => "Ubiquiti", - "B4FBE4" => "Ubiquiti", - "DC9FDB" => "Ubiquiti", - "E063DA" => "Ubiquiti", - "F09FC2" => "Ubiquiti", - "F4E2C6" => "Ubiquiti", - "FCECDA" => "Ubiquiti", - - # MikroTik - "000456" => "MikroTik", - "001D2E" => "MikroTik", - "00272D" => "MikroTik", - "0C424C" => "MikroTik", - "2C3952" => "MikroTik", - "48A9D2" => "MikroTik", - "4C5E0C" => "MikroTik", - "6C3B6B" => "MikroTik", - "D49443" => "MikroTik", - "D4CA6D" => "MikroTik", - "E748B8" => "MikroTik", - - # TP-Link - "000E8F" => "TP-Link", - "001DD9" => "TP-Link", - "0C8268" => "TP-Link", - "1027F5" => "TP-Link", - "148824" => "TP-Link", - "1C3BF3" => "TP-Link", - "300108" => "TP-Link", - "3C468A" => "TP-Link", - "50C7BF" => "TP-Link", - "641C67" => "TP-Link", - "7C8BCA" => "TP-Link", - "98DAC4" => "TP-Link", - "A0F3C1" => "TP-Link", - "C46E1F" => "TP-Link", - "E8DE27" => "TP-Link", - "EC086B" => "TP-Link", - "F0B429" => "TP-Link", - - # Juniper - "001517" => "Juniper", - "001B1F" => "Juniper", - "002163" => "Juniper", - "002281" => "Juniper", - "0026DE" => "Juniper", - "0026DC" => "Juniper", - "002642" => "Juniper", - "009E1E" => "Juniper", - "285024" => "Juniper", - "28A24B" => "Juniper", - "3C8AB6" => "Juniper", - "5C5E" => "Juniper", - "842B2B" => "Juniper", - - # Aruba (HPE) - "001A1E" => "Aruba", - "00242C" => "Aruba", - "04BD88" => "Aruba", - "0C5496" => "Aruba", - "200E52" => "Aruba", - "24DE52" => "Aruba", - "40E3D6" => "Aruba", - "6C72E7" => "Aruba", - "94B4C4" => "Aruba", - "9C1C12" => "Aruba", - "B8D9CE" => "Aruba", - "D8C7C8" => "Aruba", - - # Dell - "000874" => "Dell", - "000BDB" => "Dell", - "000C7B" => "Dell", - "000D56" => "Dell", - "000E0C" => "Dell", - "000F1F" => "Dell", - "001111" => "Dell", - "00123F" => "Dell", - "0013C3" => "Dell", - "001372" => "Dell", - "001485" => "Dell", - "0015C5" => "Dell", - "0016CE" => "Dell", - "001731" => "Dell", - "001871" => "Dell", - "0019B9" => "Dell", - "001A4B" => "Dell", - "001C23" => "Dell", - "001D09" => "Dell", - "001E4F" => "Dell", - "0021F6" => "Dell", - "002219" => "Dell", - "002264" => "Dell", - "0023AE" => "Dell", - "002564" => "Dell", - "5C260A" => "Dell", - - # HP/HPE - "001279" => "HP", - "001438" => "HP", - "001560" => "HP", - "0016B9" => "HP", - "001708" => "HP", - "001B78" => "HP", - "001C2E" => "HP", - "001E0B" => "HP", - "001F29" => "HP", - "00237D" => "HP", - "002481" => "HP", - "00248D" => "HP", - "002655" => "HP", - "3863BB" => "HP", - - # Netgear - "00095B" => "Netgear", - "000FB5" => "Netgear", - "001E2A" => "Netgear", - "001F33" => "Netgear", - "00223F" => "Netgear", - "00260B" => "Netgear", - "04A151" => "Netgear", - "0846A0" => "Netgear", - "2C3033" => "Netgear", - "3448ED" => "Netgear", - "4C9EFF" => "Netgear", - "74DA38" => "Netgear", - "84C9B2" => "Netgear", - "A0040A" => "Netgear", - "C43DC7" => "Netgear", - - # Fortinet - "0009819" => "Fortinet", - "001910" => "Fortinet", - "00E04C" => "Fortinet", - "080027" => "Fortinet", - "0C1D36" => "Fortinet", - "246C05" => "Fortinet", - "708BCD" => "Fortinet", - "900B29" => "Fortinet", - - # Palo Alto - "009C33" => "Palo Alto", - "001B17" => "Palo Alto", - "5C5B35" => "Palo Alto", - "8C89A5" => "Palo Alto", - "D4F46F" => "Palo Alto" - } - end - - # Count distinct discovery sources - defp count_sources(entry) do - count = 0 - count = if entry.sources.neighbors == [], do: count, else: count + 1 - count = if entry.sources.arp == [], do: count, else: count + 1 - if entry.sources.mac == [], do: count, else: count + 1 - end - - # Get most recent timestamp from all sources - defp get_most_recent_timestamp(entry) do - timestamps = - Enum.reject( - Enum.map(entry.sources.neighbors, & &1.last_discovered_at) ++ - Enum.map(entry.sources.arp, & &1.last_seen_at) ++ Enum.map(entry.sources.mac, & &1.last_seen_at), - &is_nil/1 - ) - - case timestamps do - [] -> DateTime.utc_now() - _ -> Enum.max(timestamps, DateTime) - end - end - - # Get list of protocols used across all sources - defp get_protocols_used(sources) do - protocols = [] - - protocols = - case sources.neighbors do - [] -> - protocols - - neighbors -> - neighbor_protocols = - neighbors - |> Enum.map(& &1.protocol) - |> Enum.map(&safe_protocol_to_atom/1) - |> Enum.uniq() - - protocols ++ neighbor_protocols - end - - protocols = if sources.arp == [], do: protocols, else: [:arp | protocols] - protocols = if sources.mac == [], do: protocols, else: [:mac | protocols] - - Enum.uniq(protocols) - end - - # Safely convert protocol string to atom with whitelist to prevent atom exhaustion - defp safe_protocol_to_atom("lldp"), do: :lldp - defp safe_protocol_to_atom("cdp"), do: :cdp - defp safe_protocol_to_atom(_unknown), do: :unknown + defdelegate list_discovered_devices_for_organization(organization_id), to: DiscoveredDevices ## VLAN delegates (implementation in `Towerops.Snmp.Vlans`). @@ -1112,1048 +376,75 @@ defmodule Towerops.Snmp do defdelegate create_mempool_reading(attrs), to: Mempools defdelegate create_mempool_readings_batch(entries), to: Mempools - @doc """ - Lists all entity physical components for a given SNMP device. - """ - def list_entity_physical(snmp_device_id) do - EntityPhysical - |> where([e], e.snmp_device_id == ^snmp_device_id) - |> order_by([e], e.entity_index) - |> Repo.all() - end + ## Entity physical delegates (implementation in `Towerops.Snmp.EntityPhysicals`). - @doc """ - Gets a single entity physical component by id. - """ - def get_entity_physical(id) do - case Repo.get(EntityPhysical, id) do - nil -> {:error, :not_found} - entity -> {:ok, entity} - end - end + defdelegate list_entity_physical(snmp_device_id), to: EntityPhysicals + defdelegate get_entity_physical(id), to: EntityPhysicals + defdelegate get_entity_physical_by_class(snmp_device_id, entity_class), to: EntityPhysicals + defdelegate get_entity_physical_tree(snmp_device_id), to: EntityPhysicals + defdelegate update_entity_physical(entity, attrs), to: EntityPhysicals + defdelegate create_entity_physical_readings_batch(entries), to: EntityPhysicals - @doc """ - Gets entity physical components by class (chassis, module, port, etc.). - """ - def get_entity_physical_by_class(snmp_device_id, entity_class) do - EntityPhysical - |> where([e], e.snmp_device_id == ^snmp_device_id and e.entity_class == ^entity_class) - |> order_by([e], e.entity_index) - |> Repo.all() - end + ## Transceiver delegates (implementation in `Towerops.Snmp.Transceivers`). - @doc """ - Gets entity physical components as a hierarchical tree. + defdelegate list_transceivers(snmp_device_id), to: Transceivers + defdelegate get_transceiver(id), to: Transceivers + defdelegate get_transceivers_by_type(snmp_device_id, transceiver_type), to: Transceivers + defdelegate list_transceivers_with_dom(snmp_device_id), to: Transceivers + defdelegate get_transceiver_with_latest_reading(id), to: Transceivers + defdelegate update_transceiver(transceiver, attrs), to: Transceivers + defdelegate create_transceiver_readings_batch(entries), to: Transceivers - Returns root entities with children preloaded recursively. - """ - def get_entity_physical_tree(snmp_device_id) do - # Get all entities for this device - all_entities = - EntityPhysical - |> where([e], e.snmp_device_id == ^snmp_device_id) - |> order_by([e], e.entity_index) - |> Repo.all() + ## Printer supply delegates (implementation in `Towerops.Snmp.PrinterSupplies`). - # Build the tree structure - build_entity_tree(all_entities) - end + defdelegate list_printer_supplies(snmp_device_id), to: PrinterSupplies + defdelegate get_printer_supply(id), to: PrinterSupplies + defdelegate get_printer_supplies_by_type(snmp_device_id, supply_type), to: PrinterSupplies + defdelegate get_low_printer_supplies(snmp_device_id, threshold_percent), to: PrinterSupplies + defdelegate update_printer_supply(printer_supply, attrs), to: PrinterSupplies - # Build hierarchical tree from flat list of entities - defp build_entity_tree(entities) do - # Group entities by parent_id - children_by_parent = Enum.group_by(entities, & &1.parent_id) + ## ARP entry delegates (implementation in `Towerops.Snmp.ArpEntries`). - # Find root entities (parent_id is nil) - root_entities = Map.get(children_by_parent, nil, []) + defdelegate list_arp_entries(device_id), to: ArpEntries + defdelegate get_arp_entry(arp_entry_id), to: ArpEntries + defdelegate upsert_arp_entry(attrs), to: ArpEntries + defdelegate delete_stale_arp_entries(device_id, cutoff_datetime), to: ArpEntries - # Recursively attach children to each entity - Enum.map(root_entities, &attach_children(&1, children_by_parent)) - end + defdelegate delete_stale_and_upsert_arp_entries(device_id, arp_entries, interfaces, cutoff), + to: ArpEntries - defp attach_children(entity, children_by_parent) do - children = - children_by_parent - |> Map.get(entity.id, []) - |> Enum.map(&attach_children(&1, children_by_parent)) + defdelegate upsert_arp_entries(device_id, arp_entries, interfaces), to: ArpEntries - Map.put(entity, :children, children) - end + ## MAC address delegates (implementation in `Towerops.Snmp.MacAddresses`). - @doc """ - Updates an entity physical component with the given attributes. - """ - def update_entity_physical(entity, attrs) do - entity - |> EntityPhysical.changeset(attrs) - |> Repo.update() - end + defdelegate list_mac_addresses(device_id), to: MacAddresses + defdelegate upsert_mac_address(attrs), to: MacAddresses + defdelegate delete_stale_mac_addresses(device_id, cutoff_datetime), to: MacAddresses - # Transceiver queries + defdelegate delete_stale_and_upsert_mac_addresses(device_id, mac_entries, interfaces, cutoff), + to: MacAddresses - @doc """ - Lists all transceivers for a device. - """ - def list_transceivers(snmp_device_id) do - Transceiver - |> where([t], t.snmp_device_id == ^snmp_device_id) - |> order_by([t], t.port_index) - |> Repo.all() - end + defdelegate upsert_mac_addresses(device_id, mac_entries, interfaces), to: MacAddresses - @doc """ - Gets a single transceiver by id. - """ - def get_transceiver(id) do - case Repo.get(Transceiver, id) do - nil -> {:error, :not_found} - transceiver -> {:ok, transceiver} - end - end + ## Network topology delegate (implementation in `Towerops.Snmp.Topology`). - @doc """ - Gets transceivers by type (SFP, SFP+, QSFP, etc.). - """ - def get_transceivers_by_type(snmp_device_id, transceiver_type) do - Transceiver - |> where([t], t.snmp_device_id == ^snmp_device_id and t.transceiver_type == ^transceiver_type) - |> order_by([t], t.port_index) - |> Repo.all() - end + defdelegate get_network_topology(organization_id), to: Topology - @doc """ - Lists transceivers with DOM (Digital Optical Monitoring) support. - """ - def list_transceivers_with_dom(snmp_device_id) do - Transceiver - |> where([t], t.snmp_device_id == ^snmp_device_id and t.supports_dom == true) - |> order_by([t], t.port_index) - |> Repo.all() - end + ## Wireless client delegates (implementation in `Towerops.Snmp.WirelessClients`). - @doc """ - Gets a transceiver with its latest DOM reading. + defdelegate list_wireless_clients(device_id), to: WirelessClients + defdelegate get_wireless_clients_by_mac(organization_id, mac_addresses), to: WirelessClients + defdelegate upsert_wireless_client(attrs), to: WirelessClients + defdelegate upsert_wireless_clients(device_id, organization_id, clients), to: WirelessClients + defdelegate delete_stale_wireless_clients(device_id, cutoff_datetime), to: WirelessClients - Returns the transceiver with a `:latest_reading` field containing the most recent - TransceiverReading, or nil if no readings exist. - """ - def get_transceiver_with_latest_reading(id) do - case Repo.get(Transceiver, id) do - nil -> - {:error, :not_found} + defdelegate delete_stale_and_upsert_wireless_clients(device_id, organization_id, clients, cutoff), + to: WirelessClients - transceiver -> - latest_reading = - TransceiverReading - |> where([r], r.transceiver_id == ^id) - |> order_by([r], desc: r.measured_at) - |> limit(1) - |> Repo.one() - - {:ok, Map.put(transceiver, :latest_reading, latest_reading)} - end - end - - @doc """ - Updates a transceiver with the given attributes. - """ - def update_transceiver(transceiver, attrs) do - transceiver - |> Transceiver.changeset(attrs) - |> Repo.update() - end - - ## Printer Supplies - - @doc """ - Lists all printer supplies for a device. - """ - def list_printer_supplies(snmp_device_id) do - PrinterSupply - |> where([s], s.snmp_device_id == ^snmp_device_id) - |> order_by([s], s.supply_index) - |> Repo.all() - end - - @doc """ - Gets a single printer supply by id. - Returns nil if not found. - """ - def get_printer_supply(id) do - Repo.get(PrinterSupply, id) - end - - @doc """ - Gets printer supplies by type (toner, ink, drum, etc.). - """ - def get_printer_supplies_by_type(snmp_device_id, supply_type) do - PrinterSupply - |> where([s], s.snmp_device_id == ^snmp_device_id and s.supply_type == ^supply_type) - |> order_by([s], s.supply_index) - |> Repo.all() - end - - @doc """ - Gets printer supplies below a threshold percentage. - - Returns supplies where (current_level / max_capacity * 100) < threshold. - Excludes supplies without capacity data. - """ - def get_low_printer_supplies(snmp_device_id, threshold_percent) do - PrinterSupply - |> where([s], s.snmp_device_id == ^snmp_device_id) - |> where([s], not is_nil(s.max_capacity) and not is_nil(s.current_level)) - |> where([s], s.max_capacity > 0) - |> Repo.all() - |> Enum.filter(fn supply -> - percent = supply.current_level * 100 / supply.max_capacity - percent < threshold_percent - end) - end - - @doc """ - Updates a printer supply with the given attributes. - """ - def update_printer_supply(printer_supply, attrs) do - printer_supply - |> PrinterSupply.changeset(attrs) - |> Repo.update() - end - - @doc """ - Creates multiple transceiver DOM readings in a single batch insert. - - ## Parameters - - entries: List of maps with keys: transceiver_id, rx_power_dbm, tx_power_dbm, - bias_current_ma, temperature_celsius, voltage_v, measured_at - - ## Returns - - {count, nil} where count is the number of inserted readings - """ - def create_transceiver_readings_batch([]), do: {0, nil} - - def create_transceiver_readings_batch(entries) when is_list(entries) do - now = Towerops.Time.now() - - rows = - Enum.map(entries, fn entry -> - entry - |> Map.put(:id, Ecto.UUID.generate()) - |> Map.put(:inserted_at, now) - |> Map.put(:updated_at, now) - end) - - Repo.insert_all(TransceiverReading, rows) - end - - @doc """ - Batch inserts entity physical status readings. - - ## Parameters - - entries: List of maps with entity_physical_id, operational_status, admin_status, measured_at - - ## Returns - - {count, nil} where count is number of rows inserted - """ - def create_entity_physical_readings_batch([]), do: {0, nil} - - def create_entity_physical_readings_batch(entries) when is_list(entries) do - now = Towerops.Time.now() - - rows = - Enum.map(entries, fn entry -> - entry - |> Map.put(:id, Ecto.UUID.generate()) - |> Map.put(:inserted_at, now) - |> Map.put(:updated_at, now) - end) - - Repo.insert_all(EntityPhysicalReading, rows) - end - - # ARP Entry queries - - @doc """ - Lists all ARP entries for a device. - """ - def list_arp_entries(device_id) do - ArpEntry - |> where([a], a.device_id == ^device_id) - |> order_by([a], a.ip_address) - |> preload(:interface) - |> Repo.all() - end - - @doc """ - Gets a specific ARP entry. - """ - def get_arp_entry(arp_entry_id) do - Repo.get(ArpEntry, arp_entry_id) - end - - @doc """ - Creates or updates an ARP entry. - - Uses atomic PostgreSQL ON CONFLICT to prevent race conditions when multiple - pollers try to upsert the same ARP entry simultaneously. - """ - def upsert_arp_entry(attrs) do - %ArpEntry{} - |> ArpEntry.changeset(attrs) - |> Repo.insert( - on_conflict: {:replace_all_except, [:id, :inserted_at]}, - conflict_target: [:device_id, :ip_address, :mac_address], - returning: true - ) - end - - @doc """ - Deletes stale ARP entries that haven't been seen since the given datetime. - """ - def delete_stale_arp_entries(device_id, cutoff_datetime) do - ArpEntry - |> where([a], a.device_id == ^device_id) - |> where([a], a.last_seen_at < ^cutoff_datetime) - |> Repo.delete_all() - end - - @doc """ - Atomically deletes stale ARP entries and upserts new ones in a single transaction. - Prevents race conditions where concurrent delete/upsert could remove fresh data. - """ - def delete_stale_and_upsert_arp_entries(device_id, arp_entries, interfaces, cutoff) do - Repo.transaction(fn -> - delete_stale_arp_entries(device_id, cutoff) - upsert_arp_entries(device_id, arp_entries, interfaces) - end) - end - - # Helper to format changeset errors for logging - defp format_changeset_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end) - end - - # Helper to collect error samples (first N errors for debugging) - defp maybe_add_error_sample(error_count, samples, attrs, changeset, max_samples \\ 3) do - if error_count < max_samples do - error_details = %{ - ip: Map.get(attrs, :ip_address), - mac: Map.get(attrs, :mac_address), - errors: format_changeset_errors(changeset) - } - - [error_details | samples] - else - samples - end - end - - # Helper to format error samples into log message text - defp format_error_samples([]), do: "" - - defp format_error_samples(samples) do - formatted_samples = - Enum.map_join(samples, "\n", fn sample -> - " - IP: #{sample.ip}, MAC: #{sample.mac}, errors: #{inspect(sample.errors)}" - end) - - "\nSample errors:\n" <> formatted_samples - end - - @doc """ - Bulk upserts ARP entries for a device. - Returns `{success_count, error_count}` tuple. - """ - def upsert_arp_entries(device_id, arp_entries, interfaces) do - interface_map = Map.new(interfaces, fn i -> {i.if_index, i.id} end) - - arp_entries - |> Enum.reduce({0, 0, []}, fn entry, {s, e, error_samples} -> - interface_id = Map.get(interface_map, entry.if_index) - - attrs = - entry - |> Map.put(:device_id, device_id) - |> Map.put(:interface_id, interface_id) - - case upsert_arp_entry(attrs) do - {:ok, _} -> - {s + 1, e, error_samples} - - {:error, changeset} -> - # Sample first 3 errors for debugging - sample = maybe_add_error_sample(e, error_samples, attrs, changeset) - {s, e + 1, sample} - end - end) - |> tap(fn {_s, errors, error_samples} -> - if errors > 0 do - samples_text = error_samples |> Enum.reverse() |> format_error_samples() - - Logger.warning( - "#{errors} ARP upsert failures for device #{device_id}#{samples_text}", - device_id: device_id, - error_count: errors - ) - end - end) - |> then(fn {s, e, _samples} -> {s, e} end) - end - - # MAC Address queries - - @doc """ - Lists all MAC address entries for a device. - """ - def list_mac_addresses(device_id) do - MacAddress - |> where([m], m.device_id == ^device_id) - |> order_by([m], [m.vlan_id, m.mac_address]) - |> preload(:interface) - |> Repo.all() - end - - @doc """ - Creates or updates a MAC address entry. - - Uses atomic PostgreSQL ON CONFLICT to prevent race conditions when multiple - pollers try to upsert the same MAC address simultaneously. The unique constraint - handles duplicate detection automatically, eliminating the need for manual cleanup. - """ - def upsert_mac_address(attrs) do - %MacAddress{} - |> MacAddress.changeset(attrs) - |> Repo.insert( - on_conflict: {:replace_all_except, [:id, :inserted_at]}, - conflict_target: [:device_id, :mac_address, :vlan_id], - returning: true - ) - end - - @doc """ - Deletes stale MAC address entries that haven't been seen since the given datetime. - """ - def delete_stale_mac_addresses(device_id, cutoff_datetime) do - MacAddress - |> where([m], m.device_id == ^device_id) - |> where([m], m.last_seen_at < ^cutoff_datetime) - |> Repo.delete_all() - end - - @doc """ - Atomically deletes stale MAC addresses and upserts new ones in a single transaction. - Prevents race conditions where concurrent delete/upsert could remove fresh data. - """ - def delete_stale_and_upsert_mac_addresses(device_id, mac_entries, interfaces, cutoff) do - Repo.transaction(fn -> - delete_stale_mac_addresses(device_id, cutoff) - upsert_mac_addresses(device_id, mac_entries, interfaces) - end) - end - - @doc """ - Bulk upserts MAC address entries for a device. - Returns `{success_count, error_count}` tuple. - """ - def upsert_mac_addresses(device_id, mac_entries, interfaces) do - # Build a map from bridge port index to interface ID - # Note: port_index from BRIDGE-MIB is not the same as if_index - # For now, we'll use a simple mapping if they're available - interface_map = Map.new(interfaces, fn i -> {i.if_index, i.id} end) - - mac_entries - |> Enum.reduce({0, 0}, fn entry, {s, e} -> - interface_id = Map.get(interface_map, entry.port_index) - - attrs = - entry - |> Map.put(:device_id, device_id) - |> Map.put(:interface_id, interface_id) - - case upsert_mac_address(attrs) do - {:ok, _} -> {s + 1, e} - {:error, _} -> {s, e + 1} - end - end) - |> tap(fn {_s, errors} -> - if errors > 0 do - Logger.warning("#{errors} MAC upsert failures for device #{device_id}", - device_id: device_id, - error_count: errors - ) - end - end) - end - - # Network Topology Functions - - @doc """ - Builds a network topology map for an organization showing devices and their connections. - - Returns a graph structure with: - - nodes: All devices (both added and discovered) - - edges: Physical connections between interfaces - - subnets: Logical groupings of devices on same networks - """ - def get_network_topology(organization_id) do - # Get all devices with their neighbors and interfaces - devices = get_devices_with_topology(organization_id) - - # Get discovered devices not yet added - discovered = list_discovered_devices_for_organization(organization_id) - - # Build topology structure - nodes = build_topology_nodes(devices, discovered) - edges = build_topology_edges(devices, nodes) - subnets = build_subnet_groups(devices) - - %{ - nodes: nodes, - edges: edges, - subnets: subnets, - stats: %{ - total_devices: length(nodes), - added_devices: Enum.count(nodes, &(!&1.discovered)), - discovered_devices: Enum.count(nodes, & &1.discovered), - total_links: length(edges), - total_subnets: length(subnets) - }, - last_updated: DateTime.utc_now() - } - end - - # Get devices with all topology-related data preloaded - defp get_devices_with_topology(organization_id) do - Repo.all( - from(d in DeviceSchema, - join: s in assoc(d, :site), - where: s.organization_id == ^organization_id, - left_join: sd in Device, - on: d.id == sd.device_id, - left_join: i in Interface, - on: sd.id == i.snmp_device_id, - left_join: n in Neighbor, - on: i.id == n.interface_id, - left_join: ip in IpAddress, - on: i.id == ip.snmp_interface_id, - preload: [ - site: s, - snmp_device: {sd, [interfaces: {i, [neighbors: n, ip_addresses: ip]}]} - ] - ) - ) - end - - # Build node structures for the graph - defp build_topology_nodes(devices, discovered) do - # Nodes for added devices - device_nodes = - Enum.map(devices, fn device -> - %{ - id: device.id, - label: device.name, - type: device_type_atom(device), - status: device.status, - site_id: device.site_id, - site_name: device.site.name, - ip_address: device.ip_address, - discovered: false, - snmp_enabled: device.snmp_enabled, - monitoring_enabled: device.monitoring_enabled - } - end) - - # Nodes for discovered but not added devices - discovered_nodes = - Enum.map(discovered, fn disc -> - %{ - id: "discovered_#{disc.identifier.type}_#{disc.identifier.value}", - label: disc.hostname || disc.identifier.value, - type: disc.device_type, - status: :unknown, - site_id: nil, - site_name: nil, - ip_address: List.first(disc.ip_addresses), - discovered: true, - manufacturer: disc.manufacturer - } - end) - - device_nodes ++ discovered_nodes - end - - # Build edge structures for the graph (physical connections) - defp build_topology_edges(devices, nodes) do - # Build lookup maps for matching neighbors to nodes - node_lookup = build_node_lookup(devices, nodes) - - devices - |> Enum.flat_map(&build_device_edges(&1, node_lookup)) - |> Enum.uniq_by(& &1.id) - end - - # Build lookup maps to match neighbors to nodes using multiple strategies - defp build_node_lookup(devices, nodes) do - # Create maps for matching by different attributes - by_id = Map.new(nodes, &{&1.id, &1}) - - by_name = - nodes - |> Enum.filter(&(&1.label != nil)) - |> Map.new(&{String.downcase(&1.label), &1.id}) - - by_ip = Map.new(nodes, fn node -> {node.ip_address, node.id} end) - - # Build MAC address lookup from SNMP devices - by_mac = - devices - |> Enum.flat_map(fn device -> - case device.snmp_device do - nil -> - [] - - snmp_device -> - snmp_device.interfaces - |> Enum.filter(&(&1.if_phys_address != nil)) - |> Enum.map(&{&1.if_phys_address, device.id}) - end - end) - |> Map.new() - - %{ - by_id: by_id, - by_name: by_name, - by_ip: by_ip, - by_mac: by_mac, - node_ids: MapSet.new(Map.keys(by_id)) - } - end - - defp build_device_edges(device, node_lookup) do - case device.snmp_device do - nil -> [] - snmp_device -> Enum.flat_map(snmp_device.interfaces, &build_interface_edges(device, &1, node_lookup)) - end - end - - defp build_interface_edges(device, interface, node_lookup) do - interface.neighbors - |> Enum.map(fn neighbor -> - # Try to match neighbor to existing node using multiple strategies - target_id = find_target_node_id(neighbor, node_lookup) - - # Get IP addresses for source interface - source_ips = format_interface_ips(interface.ip_addresses) - - # Create bidirectional edge ID (alphabetically sorted to deduplicate) - edge_parts = Enum.sort([device.id, target_id || "unknown"]) - edge_id = Enum.join(edge_parts, "_") - - %{ - id: edge_id, - source: device.id, - target: target_id, - source_interface: interface.if_name || interface.if_descr, - target_interface: neighbor.remote_port_id, - source_interface_id: interface.id, - source_ips: source_ips, - target_ip: neighbor.remote_address, - protocol: neighbor.protocol, - last_discovered: neighbor.last_discovered_at - } - end) - |> Enum.filter(&(&1.target != nil)) - end - - # Format IP addresses with CIDR notation (IPv4 only) - defp format_interface_ips(ip_addresses) do - ip_addresses - |> Enum.filter(&ipv4?(&1.ip_address)) - |> Enum.map(fn ip -> - subnet = if ip.subnet_mask, do: "/#{calculate_prefix_length(ip.subnet_mask)}", else: "" - "#{ip.ip_address}#{subnet}" - end) - end - - # Check if an IP address is IPv4 - defp ipv4?(ip_address) when is_binary(ip_address) do - case :inet.parse_address(String.to_charlist(ip_address)) do - {:ok, {_, _, _, _}} -> true - _ -> false - end - end - - defp ipv4?(_), do: false - - # Calculate prefix length from subnet mask - defp calculate_prefix_length(nil), do: "" - - defp calculate_prefix_length(subnet_mask) do - subnet_mask - |> String.split(".") - |> Enum.map(&String.to_integer/1) - |> Enum.map(&Integer.to_string(&1, 2)) - |> Enum.map_join(&String.pad_leading(&1, 8, "0")) - |> String.graphemes() - |> Enum.count(&(&1 == "1")) - end - - # Find the node ID that matches the neighbor using multiple strategies - defp find_target_node_id(neighbor, node_lookup) do - # Strategy 1: Match by system name to device name - name_match = - if neighbor.remote_system_name do - Map.get(node_lookup.by_name, String.downcase(neighbor.remote_system_name)) - end - - # Strategy 2: Match by IP address - ip_match = - if neighbor.remote_address do - Map.get(node_lookup.by_ip, neighbor.remote_address) - end - - # Strategy 3: Match by chassis ID (MAC address) - mac_match = - if neighbor.remote_chassis_id do - Map.get(node_lookup.by_mac, neighbor.remote_chassis_id) - end - - # Strategy 4: Check if it's a discovered node - discovered_match = - if neighbor.remote_chassis_id do - discovered_id = "discovered_mac_#{neighbor.remote_chassis_id}" - if MapSet.member?(node_lookup.node_ids, discovered_id), do: discovered_id - end - - # Return first successful match - name_match || ip_match || mac_match || discovered_match - end - - # Build subnet groupings - defp build_subnet_groups(devices) do - devices - |> Enum.flat_map(fn device -> - case device.snmp_device do - nil -> - [] - - snmp_device -> - Enum.flat_map(snmp_device.interfaces, fn interface -> - Enum.map(interface.ip_addresses, fn ip -> - {device.id, ip.ip_address, ip.subnet_mask} - end) - end) - end - end) - |> Enum.group_by(fn {_device_id, ip, mask} -> - # Calculate CIDR notation - case mask do - nil -> nil - _ -> calculate_cidr(ip, mask) - end - end) - |> Enum.reject(fn {cidr, _} -> is_nil(cidr) end) - |> Enum.map(fn {cidr, entries} -> - device_ids = entries |> Enum.map(fn {device_id, _, _} -> device_id end) |> Enum.uniq() - - %{ - cidr: cidr, - device_ids: device_ids, - device_count: length(device_ids) - } - end) - |> Enum.sort_by(& &1.device_count, :desc) - end - - # Convert device type to atom with whitelist to prevent atom exhaustion - defp device_type_atom(device) when is_atom(device), do: device - defp device_type_atom("router"), do: :router - defp device_type_atom("switch"), do: :switch - defp device_type_atom("wireless"), do: :wireless - defp device_type_atom("server"), do: :server - defp device_type_atom("workstation"), do: :workstation - defp device_type_atom("firewall"), do: :firewall - defp device_type_atom("printer"), do: :printer - defp device_type_atom("phone"), do: :phone - defp device_type_atom(_), do: :unknown - - # Calculate CIDR notation from IP and mask - defp calculate_cidr(ip, mask) do - case {parse_ip(ip), parse_ip(mask)} do - {{:ok, _ip_tuple}, {:ok, mask_tuple}} -> - prefix_length = count_mask_bits(mask_tuple) - network = calculate_network(ip, mask) - "#{network}/#{prefix_length}" - - _ -> - nil - end - end - - defp parse_ip(ip) when is_binary(ip) do - case :inet.parse_address(String.to_charlist(ip)) do - {:ok, tuple} -> {:ok, tuple} - {:error, _} -> :error - end - end - - defp parse_ip(_), do: :error - - defp count_mask_bits({a, b, c, d}) do - [a, b, c, d] - |> Enum.map(&Integer.to_string(&1, 2)) - |> Enum.map_join(&String.pad_leading(&1, 8, "0")) - |> String.graphemes() - |> Enum.count(&(&1 == "1")) - end - - defp calculate_network(ip, mask) do - {:ok, ip_tuple} = parse_ip(ip) - {:ok, mask_tuple} = parse_ip(mask) - - {a1, b1, c1, d1} = ip_tuple - {a2, b2, c2, d2} = mask_tuple - - a = Bitwise.band(a1, a2) - b = Bitwise.band(b1, b2) - c = Bitwise.band(c1, c2) - d = Bitwise.band(d1, d2) - - "#{a}.#{b}.#{c}.#{d}" - end - - # Wireless Client queries - - @doc """ - Lists all wireless clients for a device. - """ - def list_wireless_clients(device_id) do - WirelessClient - |> where([w], w.device_id == ^device_id) - |> order_by([w], w.mac_address) - |> Repo.all() - end - - @doc """ - Gets wireless clients by MAC addresses within an organization. - """ - def get_wireless_clients_by_mac(organization_id, mac_addresses) do - WirelessClient - |> where([w], w.organization_id == ^organization_id) - |> where([w], w.mac_address in ^mac_addresses) - |> Repo.all() - end - - @doc """ - Creates or updates a wireless client entry. - """ - def upsert_wireless_client(attrs) do - %WirelessClient{} - |> WirelessClient.changeset(attrs) - |> Repo.insert( - on_conflict: {:replace_all_except, [:id, :inserted_at]}, - conflict_target: [:device_id, :mac_address], - returning: true - ) - end - - @doc """ - Upserts a list of wireless clients for a device. - Returns `{success_count, error_count}`. - """ - def upsert_wireless_clients(device_id, organization_id, clients) do - Enum.reduce(clients, {0, 0}, fn client, {s, e} -> - attrs = - client - |> Map.put(:device_id, device_id) - |> Map.put(:organization_id, organization_id) - - case upsert_wireless_client(attrs) do - {:ok, _} -> {s + 1, e} - {:error, _} -> {s, e + 1} - end - end) - end - - @doc """ - Deletes stale wireless clients that haven't been seen since the given datetime. - """ - def delete_stale_wireless_clients(device_id, cutoff_datetime) do - WirelessClient - |> where([w], w.device_id == ^device_id) - |> where([w], w.last_seen_at < ^cutoff_datetime) - |> Repo.delete_all() - end - - @doc """ - Atomically deletes stale wireless clients and upserts new ones. - """ - def delete_stale_and_upsert_wireless_clients(device_id, organization_id, clients, cutoff) do - Repo.transaction(fn -> - delete_stale_wireless_clients(device_id, cutoff) - upsert_wireless_clients(device_id, organization_id, clients) - end) - end - - @doc """ - Batch inserts multiple wireless client readings using `Repo.insert_all/3`. - - Accepts a list of attribute maps containing wireless client metrics. - Generates UUIDs and timestamps automatically. Returns `{count, nil}`. - - ## Examples - - iex> create_wireless_client_readings_batch([ - ...> %{device_id: id, wireless_client_id: wc_id, organization_id: org_id, - ...> mac_address: "aa:bb:cc:dd:ee:ff", signal_strength: -65, - ...> snr: 30, checked_at: ~U[2024-01-01 00:00:00Z]} - ...> ]) - {1, nil} - """ - @spec create_wireless_client_readings_batch([map()]) :: {non_neg_integer(), nil} - def create_wireless_client_readings_batch([]), do: {0, nil} - - def create_wireless_client_readings_batch(entries) when is_list(entries) do - now = Towerops.Time.now() - - rows = - Enum.map(entries, fn entry -> - entry - |> Map.put(:id, Ecto.UUID.generate()) - |> Map.put(:inserted_at, now) - end) - - Repo.insert_all(WirelessClientReading, rows) - end - - @doc """ - Lists wireless clients with weak signal strength for an organization. - - Returns clients with signal strength below the threshold (default -75 dBm). - - ## Examples - - iex> list_weak_signal_clients(org_id) - [%WirelessClient{signal_strength: -80, ...}, ...] - - iex> list_weak_signal_clients(org_id, -85) - [%WirelessClient{signal_strength: -90, ...}, ...] - """ - @spec list_weak_signal_clients(Ecto.UUID.t(), integer()) :: [WirelessClient.t()] - def list_weak_signal_clients(organization_id, threshold_dbm \\ -75) do - Repo.all( - from(c in WirelessClient, - join: d in assoc(c, :device), - where: c.organization_id == ^organization_id, - where: not is_nil(c.signal_strength), - where: c.signal_strength < ^threshold_dbm, - preload: [device: d], - order_by: [asc: c.signal_strength] - ) - ) - end - - @doc """ - Lists wireless clients with low SNR for an organization. - - Returns clients with SNR below the threshold (default 15 dB). - - ## Examples - - iex> list_low_snr_clients(org_id) - [%WirelessClient{snr: 10, ...}, ...] - - iex> list_low_snr_clients(org_id, 10) - [%WirelessClient{snr: 8, ...}, ...] - """ - @spec list_low_snr_clients(Ecto.UUID.t(), integer()) :: [WirelessClient.t()] - def list_low_snr_clients(organization_id, threshold_db \\ 15) do - Repo.all( - from(c in WirelessClient, - join: d in assoc(c, :device), - where: c.organization_id == ^organization_id, - where: not is_nil(c.snr), - where: c.snr < ^threshold_db, - preload: [device: d], - order_by: [asc: c.snr] - ) - ) - end - - @doc """ - Gets wireless client count per device for an organization. - - Returns a map of device_id => client_count. - - ## Examples - - iex> get_wireless_client_count_by_device(org_id) - %{device_id_1 => 25, device_id_2 => 50} - """ - @spec get_wireless_client_count_by_device(Ecto.UUID.t()) :: %{Ecto.UUID.t() => integer()} - def get_wireless_client_count_by_device(organization_id) do - from(c in WirelessClient, - where: c.organization_id == ^organization_id, - group_by: c.device_id, - select: {c.device_id, count(c.id)} - ) - |> Repo.all() - |> Map.new() - end - - @doc """ - Lists devices (access points) with client count exceeding threshold. - - Returns devices with their client counts. - - ## Examples - - iex> list_overloaded_aps(org_id) - [{%Device{name: "AP-1"}, 55}, ...] - - iex> list_overloaded_aps(org_id, 75) - [{%Device{name: "AP-2"}, 80}, ...] - """ - @spec list_overloaded_aps(Ecto.UUID.t(), integer()) :: [{DeviceSchema.t(), integer()}] - def list_overloaded_aps(organization_id, threshold \\ 50) do - Repo.all( - from(c in WirelessClient, - join: d in DeviceSchema, - on: c.device_id == d.id, - where: c.organization_id == ^organization_id, - group_by: [c.device_id, d.id], - having: count(c.id) > ^threshold, - select: {d, count(c.id)}, - order_by: [desc: count(c.id)] - ) - ) - end - - @doc """ - Gets the last seen timestamp for a wireless client by MAC address. - - Returns the most recent last_seen_at timestamp for the given MAC across all devices. - - ## Examples - - iex> get_wireless_client_last_seen(org_id, "AA:BB:CC:DD:EE:FF") - ~U[2024-01-15 10:30:00Z] - - iex> get_wireless_client_last_seen(org_id, "unknown-mac") - nil - """ - @spec get_wireless_client_last_seen(Ecto.UUID.t(), String.t()) :: DateTime.t() | nil - def get_wireless_client_last_seen(organization_id, mac_address) do - mac_lower = String.downcase(mac_address) - - Repo.one( - from(c in WirelessClient, - where: c.organization_id == ^organization_id, - where: fragment("LOWER(?)", c.mac_address) == ^mac_lower, - select: c.last_seen_at, - order_by: [desc: c.last_seen_at], - limit: 1 - ) - ) - end + defdelegate create_wireless_client_readings_batch(entries), to: WirelessClients + defdelegate list_weak_signal_clients(organization_id, threshold_dbm \\ -75), to: WirelessClients + defdelegate list_low_snr_clients(organization_id, threshold_db \\ 15), to: WirelessClients + defdelegate get_wireless_client_count_by_device(organization_id), to: WirelessClients + defdelegate list_overloaded_aps(organization_id, threshold \\ 50), to: WirelessClients + defdelegate get_wireless_client_last_seen(organization_id, mac_address), to: WirelessClients end