towerops/lib/towerops/snmp.ex
Graham McIntire d7741cdc0e
feat: implement unified checks system (Phase 1-4)
This commit implements the unified checks architecture that consolidates
SNMP monitoring with HTTP/TCP/DNS checks under a single "check" abstraction,
enabling consistent graphing, alerting, and management across all check types.

Database Changes:
- Add source_type and source_id to checks table for tracking auto-discovered
  vs manually created checks
- Add value field to check_results for storing numeric sensor readings
- Maintain backward compatibility with existing check_results data

New SNMP Executors:
- SnmpSensorExecutor: Poll sensor OIDs and return formatted values with
  status determination (OK/WARNING/CRITICAL based on limits)
- SnmpInterfaceExecutor: Poll interface stats (bandwidth, packets, errors)
- SnmpProcessorExecutor: Poll CPU/processor usage
- SnmpStorageExecutor: Poll disk/memory usage with percentage calculations

Check Execution Worker:
- CheckExecutorWorker: Unified Oban worker that dispatches to appropriate
  executor based on check_type (snmp_sensor, snmp_interface, http, tcp, etc.)
- Self-schedules next execution with distributed polling offsets
- Records results in check_results TimescaleDB hypertable
- Updates check state (OK/WARNING/CRITICAL/UNKNOWN)

Discovery Integration:
- Auto-creates checks during SNMP discovery for sensors, interfaces,
  processors, and storage
- Links checks to source entities via source_id for data lookup
- Enables/disables checks based on discovery results

UI Enhancements:
- Checks tab on device detail page with grouped display
- FormComponent for adding manual HTTP/TCP/DNS checks
- Empty state with "Run Discovery" prompt
- Check status badges and last checked times

Graphing:
- Update GraphLive to accept check_id parameter
- Query check_results table for time-series data
- Support all check types (SNMP, HTTP response times, etc.)

Testing:
- Comprehensive test suite for SnmpSensorExecutor (5 tests)
- Test suite for CheckExecutorWorker (7 tests)
- Test coverage for discovery check creation (6 tests)
- Remove deprecated monitoring_test.exs testing old API

Bug Fixes:
- Fix SNMP executors reading credentials from correct Device schema fields
  (device.snmp_version instead of device.snmp_device.version)
- Update agent channel test to query MonitoringCheck table directly

Code Quality:
- Extract add_snmp_credentials helper to reduce cyclomatic complexity
- Use map-based lookups for sensor formatting and check type grouping
- Apply pattern matching in dispatcher to reduce complexity
- All credo checks passing with no issues

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 16:58:40 -06:00

2281 lines
64 KiB
Elixir

defmodule Towerops.Snmp do
@moduledoc """
The SNMP context.
Provides the public API for SNMP discovery and monitoring functionality.
"""
import Ecto.Query
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Repo
alias Towerops.Snmp.ArpEntry
alias Towerops.Snmp.Client
alias Towerops.Snmp.Device
alias Towerops.Snmp.Discovery
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
alias Towerops.Snmp.IpAddress
alias Towerops.Snmp.MacAddress
alias Towerops.Snmp.Neighbor
alias Towerops.Snmp.Processor
alias Towerops.Snmp.ProcessorReading
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.SensorReading
alias Towerops.Snmp.StateSensor
alias Towerops.Snmp.Storage
alias Towerops.Snmp.StorageReading
alias Towerops.Snmp.Vlan
require Logger
@doc """
Tests SNMP connectivity to a device.
## Examples
iex> test_connection("192.168.1.1", "public", "2c")
{:ok, "Connection successful"}
iex> test_connection("192.168.1.99", "wrong", "2c")
{:error, :timeout}
"""
@spec test_connection(String.t(), String.t(), String.t(), integer()) :: {:ok, String.t()} | {:error, term()}
def test_connection(ip, community, version, port \\ 161) do
Client.test_connection(
ip: ip,
community: community,
version: version,
port: port
)
end
@doc """
Runs SNMP discovery for a piece of device.
Discovers:
- Device information (manufacturer, model, firmware)
- Network interfaces
- Sensors (temperature, power, fans, etc.)
Updates the database with discovered data.
## Examples
iex> discover_device(device)
{:ok, %Device{}}
"""
@spec discover_device(DeviceSchema.t()) :: {:ok, Device.t()} | {:error, term()}
def discover_device(%DeviceSchema{} = device) do
Discovery.discover_device(device)
end
@doc """
Runs SNMP discovery for all SNMP-enabled devices in an organization.
Returns a summary of successful and failed discoveries.
## Examples
iex> discover_all_for_org(org_id)
{:ok, %{success: 10, failed: 2, errors: [:timeout, :no_response]}}
"""
@spec discover_all_for_org(String.t()) :: {:ok, map()}
def discover_all_for_org(org_id) do
Discovery.discover_all(org_id)
end
@doc """
Creates monitoring checks from discovered SNMP entities.
After discovery completes, this function creates Check records for:
- Each sensor (temperature, voltage, power, etc.)
- Each interface (operational status monitoring)
- Each processor (CPU load monitoring)
- Each storage volume (disk usage monitoring)
Checks are automatically enabled and scheduled for execution.
## Examples
iex> create_checks_from_discovery(device, snmp_device)
{:ok, %{sensors: 45, interfaces: 12, processors: 2, storage: 5}}
"""
def create_checks_from_discovery(%DeviceSchema{} = device, %Device{} = snmp_device) do
alias Towerops.Monitoring
# Preload all associations
snmp_device = Repo.preload(snmp_device, [:sensors, :interfaces, :processors, :storage])
results = %{
sensors: 0,
interfaces: 0,
processors: 0,
storage: 0,
errors: []
}
# Create checks for sensors
results =
Enum.reduce(snmp_device.sensors, results, fn sensor, acc ->
case create_sensor_check(device, sensor) do
{:ok, _check} -> Map.update!(acc, :sensors, &(&1 + 1))
{:error, reason} -> Map.update!(acc, :errors, &[{:sensor, sensor.id, reason} | &1])
end
end)
# Create checks for interfaces
results =
Enum.reduce(snmp_device.interfaces, results, fn interface, acc ->
case create_interface_check(device, interface) do
{:ok, _check} -> Map.update!(acc, :interfaces, &(&1 + 1))
{:error, reason} -> Map.update!(acc, :errors, &[{:interface, interface.id, reason} | &1])
end
end)
# Create checks for processors
results =
Enum.reduce(snmp_device.processors, results, fn processor, acc ->
case create_processor_check(device, processor) do
{:ok, _check} -> Map.update!(acc, :processors, &(&1 + 1))
{:error, reason} -> Map.update!(acc, :errors, &[{:processor, processor.id, reason} | &1])
end
end)
# Create checks for storage
results =
Enum.reduce(snmp_device.storage, results, fn storage, acc ->
case create_storage_check(device, storage) do
{:ok, _check} -> Map.update!(acc, :storage, &(&1 + 1))
{:error, reason} -> Map.update!(acc, :errors, &[{:storage, storage.id, reason} | &1])
end
end)
Logger.info(
"Created checks for device #{device.id}: #{results.sensors} sensors, #{results.interfaces} interfaces, #{results.processors} processors, #{results.storage} storage"
)
{:ok, results}
end
defp create_sensor_check(device, sensor) do
alias Towerops.Monitoring
attrs = %{
organization_id: device.organization_id,
device_id: device.id,
name: sensor.sensor_descr,
check_type: "snmp_sensor",
source_type: "auto_discovery",
source_id: sensor.id,
interval_seconds: 60,
enabled: sensor.monitored,
config: %{
"sensor_type" => sensor.sensor_type,
"sensor_oid" => sensor.sensor_oid,
"sensor_unit" => sensor.sensor_unit
}
}
case Monitoring.create_check(attrs) do
{:ok, check} ->
# Schedule first execution
Monitoring.schedule_check(check)
{:ok, check}
error ->
error
end
end
defp create_interface_check(device, interface) do
alias Towerops.Monitoring
name = interface.if_alias || interface.if_name || interface.if_descr || "Interface #{interface.if_index}"
attrs = %{
organization_id: device.organization_id,
device_id: device.id,
name: "#{name} Status",
check_type: "snmp_interface",
source_type: "auto_discovery",
source_id: interface.id,
interval_seconds: 60,
enabled: interface.monitored,
config: %{
"if_index" => interface.if_index,
"if_descr" => interface.if_descr
}
}
case Monitoring.create_check(attrs) do
{:ok, check} ->
Monitoring.schedule_check(check)
{:ok, check}
error ->
error
end
end
defp create_processor_check(device, processor) do
alias Towerops.Monitoring
attrs = %{
organization_id: device.organization_id,
device_id: device.id,
name: processor.description || "Processor #{processor.processor_index}",
check_type: "snmp_processor",
source_type: "auto_discovery",
source_id: processor.id,
interval_seconds: 60,
enabled: true,
config: %{
"processor_type" => processor.processor_type,
"processor_index" => processor.processor_index
}
}
case Monitoring.create_check(attrs) do
{:ok, check} ->
Monitoring.schedule_check(check)
{:ok, check}
error ->
error
end
end
defp create_storage_check(device, storage) do
alias Towerops.Monitoring
name = storage.description || storage.device_name || "Storage #{storage.storage_index}"
attrs = %{
organization_id: device.organization_id,
device_id: device.id,
name: "#{name} Usage",
check_type: "snmp_storage",
source_type: "auto_discovery",
source_id: storage.id,
interval_seconds: 300,
enabled: true,
config: %{
"storage_type" => storage.storage_type,
"storage_index" => storage.storage_index
}
}
case Monitoring.create_check(attrs) do
{:ok, check} ->
Monitoring.schedule_check(check)
{:ok, check}
error ->
error
end
end
# Device queries
@doc """
Gets the SNMP device for a piece of device.
## Examples
iex> get_device(device_id)
%Device{}
iex> get_device(nonexistent_id)
nil
"""
@spec get_device(String.t()) :: Device.t() | nil
def get_device(device_id) do
Repo.get_by(Device, device_id: device_id)
end
@doc """
Gets the SNMP device for a device with preloaded associations.
"""
def get_device_with_associations(device_id) do
Device
|> where([d], d.device_id == ^device_id)
|> preload([:interfaces, :sensors, :state_sensors, :processors, :storage])
|> Repo.one()
end
# Interface queries
@doc """
Lists all interfaces for a device.
"""
def list_interfaces(device_id) do
Interface
|> where([i], i.snmp_device_id == ^device_id)
|> order_by([i], i.if_index)
|> Repo.all()
end
@doc """
Lists only monitored interfaces for a device.
"""
def list_monitored_interfaces(device_id) do
Interface
|> where([i], i.snmp_device_id == ^device_id and i.monitored == true)
|> order_by([i], i.if_index)
|> Repo.all()
end
@doc """
Gets a specific interface.
"""
def get_interface(interface_id) do
Repo.get(Interface, interface_id)
end
@doc """
Updates an interface.
"""
def update_interface(%Interface{} = interface, attrs) do
interface
|> Interface.changeset(attrs)
|> Repo.update()
end
# Sensor queries
@doc """
Lists all sensors for a device.
"""
def list_sensors(device_id) do
Sensor
|> where([s], s.snmp_device_id == ^device_id)
|> order_by([s], [s.sensor_type, s.sensor_index])
|> Repo.all()
end
@doc """
Lists only monitored sensors for a device.
"""
def list_monitored_sensors(device_id) do
Sensor
|> where([s], s.snmp_device_id == ^device_id and s.monitored == true)
|> order_by([s], [s.sensor_type, s.sensor_index])
|> Repo.all()
end
@doc """
Lists sensors grouped by type.
"""
def list_sensors_by_type(device_id) do
sensors =
Sensor
|> where([s], s.snmp_device_id == ^device_id)
|> order_by([s], [s.sensor_type, s.sensor_index])
|> Repo.all()
Enum.group_by(sensors, & &1.sensor_type)
end
@doc """
Gets a specific sensor.
"""
def get_sensor(sensor_id) do
Repo.get(Sensor, sensor_id)
end
@doc """
Updates a sensor.
"""
def update_sensor(%Sensor{} = sensor, attrs) do
sensor
|> Sensor.changeset(attrs)
|> Repo.update()
end
# State sensor queries
@doc """
Updates a state sensor.
"""
def update_state_sensor(%StateSensor{} = state_sensor, attrs) do
state_sensor
|> StateSensor.changeset(attrs)
|> Repo.update()
end
@doc """
Lists all state sensors for a device.
"""
def list_state_sensors(device_id) do
StateSensor
|> where([s], s.snmp_device_id == ^device_id)
|> order_by([s], s.sensor_index)
|> Repo.all()
end
# Sensor readings queries
@doc """
Gets recent sensor readings for a sensor.
## Options
- `:limit` - Maximum number of readings to return (default: 100)
- `:since` - Only return readings after this datetime
"""
def get_sensor_readings(sensor_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
since = Keyword.get(opts, :since)
query =
SensorReading
|> where([r], r.sensor_id == ^sensor_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 """
Gets the latest sensor reading for a sensor.
"""
def get_latest_sensor_reading(sensor_id) do
SensorReading
|> where([r], r.sensor_id == ^sensor_id)
|> order_by([r], desc: r.checked_at)
|> limit(1)
|> Repo.one()
end
@doc """
Gets the latest sensor readings for multiple sensors in a single query.
Returns a map of %{sensor_id => reading} for efficient batch loading.
Sensors without readings will not be present in the map.
## Examples
iex> get_latest_sensor_readings_batch([sensor_id1, sensor_id2])
%{sensor_id1 => %SensorReading{}, sensor_id2 => %SensorReading{}}
"""
def get_latest_sensor_readings_batch(sensor_ids) when is_list(sensor_ids) do
if Enum.empty?(sensor_ids) do
%{}
else
# Use DISTINCT ON to get only the latest reading per sensor
query =
from(r in SensorReading,
where: r.sensor_id in ^sensor_ids,
distinct: r.sensor_id,
order_by: [asc: r.sensor_id, desc: r.checked_at]
)
query
|> Repo.all()
|> Map.new(fn reading -> {reading.sensor_id, reading} end)
end
end
# Interface stats queries
@doc """
Gets recent interface statistics for an interface.
## Options
- `:limit` - Maximum number of stats to return (default: 100)
- `:since` - Only return stats after this datetime
"""
def get_interface_stats(interface_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
since = Keyword.get(opts, :since)
query =
InterfaceStat
|> where([s], s.interface_id == ^interface_id)
|> order_by([s], desc: s.checked_at)
|> limit(^limit)
query =
if since do
where(query, [s], s.checked_at >= ^since)
else
query
end
Repo.all(query)
end
@doc """
Gets the latest interface stat for an interface.
"""
def get_latest_interface_stat(interface_id) do
InterfaceStat
|> where([s], s.interface_id == ^interface_id)
|> order_by([s], desc: s.checked_at)
|> limit(1)
|> Repo.one()
end
@doc """
Gets the latest interface stats for multiple interfaces in a single query.
Returns a map of %{interface_id => stat} for efficient batch loading.
Interfaces without stats will not be present in the map.
## Examples
iex> get_latest_interface_stats_batch([if_id1, if_id2])
%{if_id1 => %InterfaceStat{}, if_id2 => %InterfaceStat{}}
"""
def get_latest_interface_stats_batch(interface_ids) when is_list(interface_ids) do
if Enum.empty?(interface_ids) do
%{}
else
# Use DISTINCT ON to get only the latest stat per interface
query =
from(s in InterfaceStat,
where: s.interface_id in ^interface_ids,
distinct: s.interface_id,
order_by: [asc: s.interface_id, desc: s.checked_at]
)
query
|> Repo.all()
|> Map.new(fn stat -> {stat.interface_id, stat} end)
end
end
@doc """
Records a new sensor reading.
"""
def create_sensor_reading(attrs) do
%SensorReading{}
|> SensorReading.changeset(attrs)
|> Repo.insert()
end
@doc """
Batch inserts multiple sensor readings using `Repo.insert_all/3`.
Accepts a list of attribute maps with the same keys as `create_sensor_reading/1`.
Generates UUIDs and timestamps automatically. Returns `{count, nil}`.
"""
@spec create_sensor_readings_batch([map()]) :: {non_neg_integer(), nil}
def create_sensor_readings_batch([]), do: {0, nil}
def create_sensor_readings_batch(entries) when is_list(entries) do
now = DateTime.truncate(DateTime.utc_now(), :second)
rows =
Enum.map(entries, fn entry ->
entry
|> Map.put(:id, Ecto.UUID.generate())
|> Map.put(:inserted_at, now)
end)
Repo.insert_all(SensorReading, rows)
end
@doc """
Records a new interface stat.
"""
def create_interface_stat(attrs) do
%InterfaceStat{}
|> InterfaceStat.changeset(attrs)
|> Repo.insert()
end
@doc """
Batch inserts multiple interface stats using `Repo.insert_all/3`.
Accepts a list of attribute maps with the same keys as `create_interface_stat/1`.
Generates UUIDs and timestamps automatically. Returns `{count, nil}`.
"""
@spec create_interface_stats_batch([map()]) :: {non_neg_integer(), nil}
def create_interface_stats_batch([]), do: {0, nil}
def create_interface_stats_batch(entries) when is_list(entries) do
now = DateTime.truncate(DateTime.utc_now(), :second)
rows =
Enum.map(entries, fn entry ->
entry
|> Map.put(:id, Ecto.UUID.generate())
|> Map.put(:inserted_at, now)
end)
Repo.insert_all(InterfaceStat, rows)
end
# Neighbor queries
@doc """
Lists all neighbors for a piece of device.
"""
def list_neighbors(device_id) do
Neighbor
|> where([n], n.device_id == ^device_id)
|> preload(:interface)
|> order_by([n], [n.protocol, n.remote_system_name])
|> Repo.all()
end
@doc """
Lists all neighbors for device with enriched data linking to known device.
Adds a `matched_equipment` field to each neighbor if we can identify the remote device.
"""
def list_neighbors_with_equipment(device_id) do
neighbors = list_neighbors(device_id)
# device to search within the same org
device = Towerops.Devices.get_device!(device_id)
Enum.map(neighbors, fn neighbor ->
matched_device = find_matching_equipment(neighbor, device.organization_id)
Map.put(neighbor, :matched_device, matched_device)
end)
end
# device in the organization
defp find_matching_equipment(neighbor, organization_id) do
# Try matching strategies in order: chassis ID, port ID, system name
try_match_by_chassis(neighbor, organization_id) ||
try_match_by_port(neighbor, organization_id) ||
try_match_by_name(neighbor, organization_id)
end
defp try_match_by_chassis(neighbor, organization_id) do
if neighbor.remote_chassis_id && mac_address?(neighbor.remote_chassis_id) do
find_device_by_mac(neighbor.remote_chassis_id, organization_id)
end
end
defp try_match_by_port(neighbor, organization_id) do
if neighbor.remote_port_id && mac_address?(neighbor.remote_port_id) do
find_device_by_mac(neighbor.remote_port_id, organization_id)
end
end
defp try_match_by_name(neighbor, organization_id) do
if neighbor.remote_system_name do
find_device_by_name(neighbor.remote_system_name, organization_id)
end
end
# Check if a string looks like a MAC address
defp mac_address?(str) when is_binary(str) do
# Match patterns like "AA:BB:CC:DD:EE:FF" or "aa-bb-cc-dd-ee-ff"
String.match?(str, ~r/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/)
end
defp mac_address?(_), do: false
defp find_device_by_mac(mac_address, organization_id) do
# MAC address might be in format "aa:bb:cc:dd:ee:ff", "aa-bb-cc-dd-ee-ff", or "aabbccddeeff"
# Normalize to just the hex digits for comparison
normalized_mac = mac_address |> String.downcase() |> String.replace(~r/[:-]/, "")
Repo.one(
from(i in Interface,
join: d in Device,
on: i.snmp_device_id == d.id,
join: e in DeviceSchema,
on: d.device_id == e.id,
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id,
where:
fragment("REPLACE(REPLACE(LOWER(?), ':', ''), '-', '')", i.if_phys_address) ==
^normalized_mac,
select: e,
limit: 1
)
)
end
defp find_device_by_name(system_name, organization_id) do
# Try exact match first, then partial match
normalized_name = String.downcase(system_name)
from(e in DeviceSchema,
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id,
where: fragment("LOWER(?)", e.name) == ^normalized_name,
select: e,
limit: 1
)
|> Repo.one()
|> case do
nil ->
# Try partial match as fallback
Repo.one(
from(e in DeviceSchema,
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id,
where: fragment("LOWER(?) LIKE ?", e.name, ^"%#{normalized_name}%"),
select: e,
limit: 1
)
)
device ->
device
end
end
@doc """
Gets a specific neighbor.
"""
def get_neighbor(neighbor_id) do
Repo.get(Neighbor, neighbor_id)
end
@doc """
Creates or updates a neighbor record.
Uses atomic PostgreSQL ON CONFLICT to prevent race conditions when multiple
pollers try to upsert the same neighbor simultaneously.
"""
def upsert_neighbor(attrs) do
%Neighbor{}
|> Neighbor.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :inserted_at]},
conflict_target: [:interface_id, :remote_chassis_id, :protocol],
returning: true
)
end
@doc """
Deletes stale neighbors that haven't been seen since the given datetime.
"""
def delete_stale_neighbors(device_id, cutoff_datetime) do
Neighbor
|> where([n], n.device_id == ^device_id)
|> where([n], n.last_discovered_at < ^cutoff_datetime)
|> Repo.delete_all()
end
@doc """
Atomically deletes stale neighbors and upserts new ones in a single transaction.
Prevents race conditions where concurrent delete/upsert could remove fresh data.
"""
def delete_stale_and_upsert_neighbors(device_id, neighbors, cutoff) do
Repo.transaction(fn ->
delete_stale_neighbors(device_id, cutoff)
Enum.each(neighbors, fn neighbor_data ->
upsert_neighbor(neighbor_data)
end)
end)
end
@doc """
Lists all discovered devices across an organization that haven't been added yet.
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
# 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
# VLAN queries
@doc """
Lists all VLANs for a device.
"""
def list_vlans(snmp_device_id) do
Vlan
|> where([v], v.snmp_device_id == ^snmp_device_id)
|> order_by([v], v.vlan_id)
|> Repo.all()
end
@doc """
Gets a specific VLAN.
"""
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
@doc """
Batch inserts multiple processor readings using `Repo.insert_all/3`.
Accepts a list of attribute maps with the same keys as `create_processor_reading/1`.
Generates UUIDs and timestamps automatically. Returns `{count, nil}`.
"""
@spec create_processor_readings_batch([map()]) :: {non_neg_integer(), nil}
def create_processor_readings_batch([]), do: {0, nil}
def create_processor_readings_batch(entries) when is_list(entries) do
now = DateTime.truncate(DateTime.utc_now(), :second)
rows =
Enum.map(entries, fn entry ->
entry
|> Map.put(:id, Ecto.UUID.generate())
|> Map.put(:inserted_at, now)
end)
Repo.insert_all(ProcessorReading, rows)
end
# Storage queries
@doc """
Lists all storage entries for a device.
"""
def list_storage(snmp_device_id) do
Storage
|> where([s], s.snmp_device_id == ^snmp_device_id)
|> order_by([s], s.storage_index)
|> Repo.all()
end
@doc """
Gets a specific storage entry.
"""
def get_storage(storage_id) do
Repo.get(Storage, storage_id)
end
@doc """
Updates a storage entry.
"""
def update_storage(storage, attrs) do
storage
|> Storage.changeset(attrs)
|> Repo.update()
end
# Storage reading queries
@doc """
Gets recent storage readings for a storage entry.
## Options
- `:limit` - Maximum number of readings to return (default: 100)
- `:since` - Only return readings after this datetime
"""
def get_storage_readings(storage_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
since = Keyword.get(opts, :since)
query =
StorageReading
|> where([r], r.storage_id == ^storage_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 """
Gets the latest storage reading for a storage entry.
"""
def get_latest_storage_reading(storage_id) do
StorageReading
|> where([r], r.storage_id == ^storage_id)
|> order_by([r], desc: r.checked_at)
|> limit(1)
|> Repo.one()
end
@doc """
Gets the latest storage readings for multiple storage entries in a single query.
Returns a map of %{storage_id => reading} for efficient batch loading.
Storage entries without readings will not be present in the map.
## Examples
iex> get_latest_storage_readings_batch([storage_id1, storage_id2])
%{storage_id1 => %StorageReading{}, storage_id2 => %StorageReading{}}
"""
def get_latest_storage_readings_batch(storage_ids) when is_list(storage_ids) do
if Enum.empty?(storage_ids) do
%{}
else
# Use DISTINCT ON to get only the latest reading per storage
query =
from(r in StorageReading,
where: r.storage_id in ^storage_ids,
distinct: r.storage_id,
order_by: [asc: r.storage_id, desc: r.checked_at]
)
query
|> Repo.all()
|> Map.new(fn reading -> {reading.storage_id, reading} end)
end
end
@doc """
Records a new storage reading.
"""
def create_storage_reading(attrs) do
%StorageReading{}
|> StorageReading.changeset(attrs)
|> Repo.insert()
end
@doc """
Batch inserts multiple storage readings using `Repo.insert_all/3`.
Accepts a list of attribute maps with the same keys as `create_storage_reading/1`.
Generates UUIDs and timestamps automatically. Returns `{count, nil}`.
"""
@spec create_storage_readings_batch([map()]) :: {non_neg_integer(), nil}
def create_storage_readings_batch([]), do: {0, nil}
def create_storage_readings_batch(entries) when is_list(entries) do
now = DateTime.truncate(DateTime.utc_now(), :second)
rows =
Enum.map(entries, fn entry ->
entry
|> Map.put(:id, Ecto.UUID.generate())
|> Map.put(:inserted_at, now)
end)
Repo.insert_all(StorageReading, 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
@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} ->
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, _} -> {s, e + 1}
end
end)
|> tap(fn {_s, errors} ->
if errors > 0 do
Logger.warning("#{errors} ARP upsert failures for device #{device_id}",
device_id: device_id,
error_count: errors
)
end
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
end