towerops/lib/towerops_web/live/device_live/helpers/data_loaders.ex
Graham McIntire 3ca0834ef0 tests: raise coverage to 70% via helper promotion + new unit/property tests
Promoted pure presentation and utility helpers from `defp` to `def @doc false`
across ~20 LiveViews, Oban workers, and sync modules so they're reachable from
unit tests. Refactored several `cond` blocks into idiomatic function heads with
guards. Added ~250 new test cases in new files under test/towerops and
test/towerops_web, including DB-backed tests for CnMaestro.Sync and
AlertNotificationWorker, and removed dead LiveView tab components and
CapacityLive (no callers anywhere in lib/test).

Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party
code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types,
Phoenix HTML modules, Inspect protocol impls) from coverage calculations —
these are not our project code.

Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
2026-04-24 09:49:06 -05:00

510 lines
15 KiB
Elixir

defmodule ToweropsWeb.DeviceLive.Helpers.DataLoaders do
@moduledoc """
Data loading functions for DeviceLive.Show.
Pure functions that fetch data from the database without side effects.
All functions return data structures (maps, lists, structs) that can be
assigned to socket assigns.
"""
alias Towerops.Agents
alias Towerops.Devices
alias Towerops.Devices.Firmware
alias Towerops.Devices.MikrotikBackups
alias Towerops.Gaiia
alias Towerops.Monitoring
alias Towerops.Snmp
@doc """
Loads agent information for display.
Returns a map with agent details including type (cloud/agent), name,
connection status, and last seen timestamp.
"""
@spec load_agent_info(binary() | nil, atom()) :: map()
def load_agent_info(nil, :none) do
%{
agent_token_id: nil,
type: :cloud,
name: "Cloud Polling",
source: nil,
last_seen_at: nil,
is_offline: false
}
end
def load_agent_info(agent_token_id, source) do
agent_token = Agents.get_agent_token!(agent_token_id)
is_offline = agent_offline?(agent_token)
%{
agent_token_id: agent_token_id,
type: if(agent_token.is_cloud_poller, do: :cloud, else: :agent),
name: agent_token.name,
source: source,
last_seen_at: agent_token.last_seen_at,
is_offline: is_offline
}
rescue
Ecto.NoResultsError ->
# Agent token no longer exists, fall back to cloud polling display
%{
agent_token_id: nil,
type: :cloud,
name: "Cloud Polling (agent not found)",
source: nil,
last_seen_at: nil,
is_offline: false
}
end
@doc """
True if an agent should be considered offline.
Cloud pollers are never offline. Agents without a `last_seen_at` are always
offline. Otherwise the cutoff is 5 minutes from `DateTime.utc_now/0`.
"""
@spec agent_offline?(map()) :: boolean()
def agent_offline?(%{is_cloud_poller: true}), do: false
def agent_offline?(%{last_seen_at: nil}), do: true
def agent_offline?(%{last_seen_at: last_seen_at}) do
five_minutes_ago = DateTime.add(DateTime.utc_now(), -5, :minute)
DateTime.before?(last_seen_at, five_minutes_ago)
end
@doc """
Loads SNMP device data with associations (interfaces, sensors).
Returns a map with `:device`, `:interfaces`, and `:sensors` keys.
Enriches sensors and interfaces with their latest readings/stats.
"""
@spec load_snmp_data(binary()) :: %{
device: Snmp.Device.t() | nil,
interfaces: list(),
sensors: list()
}
def load_snmp_data(device_id) do
case Snmp.get_device_with_associations(device_id) do
nil ->
%{device: nil, interfaces: [], sensors: []}
device ->
# Batch load latest readings for all sensors in a single query
sensor_ids = Enum.map(device.sensors, & &1.id)
latest_readings_map = Snmp.get_latest_sensor_readings_batch(sensor_ids)
sensors_with_readings =
Enum.map(device.sensors, fn sensor ->
latest_reading = Map.get(latest_readings_map, sensor.id)
Map.put(sensor, :latest_reading, latest_reading)
end)
# Batch load latest stats for all interfaces in a single query
interface_ids = Enum.map(device.interfaces, & &1.id)
latest_stats_map = Snmp.get_latest_interface_stats_batch(interface_ids)
interfaces_with_stats =
Enum.map(device.interfaces, fn interface ->
latest_stat = Map.get(latest_stats_map, interface.id)
Map.put(interface, :latest_stat, latest_stat)
end)
%{
device: device,
interfaces: interfaces_with_stats,
sensors: sensors_with_readings
}
end
end
@doc """
Loads VLAN information for an SNMP device.
"""
@spec load_vlans(Snmp.Device.t() | nil) :: list()
def load_vlans(nil), do: []
def load_vlans(snmp_device), do: Snmp.list_vlans(snmp_device.id)
@doc """
Loads IP addresses for an SNMP device.
"""
@spec load_ip_addresses(Snmp.Device.t() | nil) :: list()
def load_ip_addresses(nil), do: []
def load_ip_addresses(snmp_device), do: Snmp.list_ip_addresses(snmp_device.id)
@doc """
Loads processor information for an SNMP device.
"""
@spec load_processors(Snmp.Device.t() | nil) :: list()
def load_processors(nil), do: []
def load_processors(snmp_device), do: Snmp.list_processors(snmp_device.id)
@doc """
Loads storage information for an SNMP device.
"""
@spec load_storage(Snmp.Device.t() | nil) :: list()
def load_storage(nil), do: []
def load_storage(snmp_device), do: Snmp.list_storage(snmp_device.id)
@doc """
Loads recent configuration changes for a MikroTik device.
Returns a list of config changes from the last 30 days (max 5).
Returns empty list if device doesn't have MikroTik API enabled.
"""
@spec load_recent_config_changes(Devices.Device.t()) :: list()
def load_recent_config_changes(device) do
if device.mikrotik_enabled do
since = DateTime.add(DateTime.utc_now(), -30 * 24 * 3600, :second)
Towerops.ConfigChanges.list_device_changes(device.id, after: since, limit: 5)
else
[]
end
end
@doc """
Gets available firmware information for an SNMP device.
Returns firmware release information if available, nil otherwise.
Currently only supports MikroTik RouterOS.
"""
@spec get_available_firmware(Snmp.Device.t() | nil) :: map() | nil
def get_available_firmware(nil), do: nil
def get_available_firmware(snmp_device) do
vendor = determine_vendor(snmp_device.manufacturer)
product_line = determine_product_line(snmp_device.manufacturer)
# Only check firmware if both vendor and product_line are known
# Currently only MikroTik/RouterOS is supported
if vendor && product_line do
Firmware.get_latest_firmware_release(vendor, product_line)
end
end
@doc """
Determines a canonical vendor string from a manufacturer string.
Recognizes MikroTik, Cisco, and Ubiquiti. Returns `nil` for others.
"""
@spec determine_vendor(String.t() | nil | term()) :: String.t() | nil
def determine_vendor(manufacturer) when is_binary(manufacturer) do
manufacturer |> String.downcase() |> match_vendor()
end
def determine_vendor(_), do: nil
defp match_vendor(lower) do
cond do
String.contains?(lower, "mikrotik") -> "mikrotik"
String.contains?(lower, "cisco") -> "cisco"
String.contains?(lower, "ubiquiti") -> "ubiquiti"
true -> nil
end
end
@doc """
Determines a product-line slug from a manufacturer string.
Only MikroTik is recognized (→ `"routeros"`); other vendors return `nil`.
"""
@spec determine_product_line(String.t() | nil | term()) :: String.t() | nil
def determine_product_line(manufacturer) when is_binary(manufacturer) do
if manufacturer |> String.downcase() |> String.contains?("mikrotik"), do: "routeros"
end
def determine_product_line(_), do: nil
@doc """
Loads neighbors (LLDP/CDP) with equipment associations.
"""
@spec load_neighbors(binary()) :: list()
def load_neighbors(device_id) do
Snmp.list_neighbors_with_equipment(device_id)
end
@doc """
Loads ARP entries for a device.
"""
@spec load_arp_entries(binary()) :: list()
def load_arp_entries(device_id) do
Snmp.list_arp_entries(device_id)
end
@doc """
Loads MAC address table for a device.
"""
@spec load_mac_addresses(binary()) :: list()
def load_mac_addresses(device_id) do
Snmp.list_mac_addresses(device_id)
end
@doc """
Loads device events (logs).
"""
@spec load_device_events(binary(), pos_integer()) :: list()
def load_device_events(device_id, limit \\ 100) do
Devices.list_devices_events(device_id, limit)
end
@doc """
Loads MikroTik backups for a device.
Returns empty list if device doesn't have MikroTik API enabled.
"""
@spec load_mikrotik_backups(Devices.Device.t(), pos_integer()) :: list()
def load_mikrotik_backups(device, limit \\ 50) do
if device.mikrotik_enabled do
MikrotikBackups.list_device_backups(device.id, limit: limit)
else
[]
end
end
@doc """
Loads monitoring checks for a device.
Returns a map with `:checks` (all checks) and `:grouped_checks` (checks grouped by type).
"""
@spec load_checks_data(binary(), binary()) :: %{checks: list(), grouped_checks: map()}
def load_checks_data(device_id, organization_id) do
# Check type to group key mapping
check_type_groups = %{
"snmp_sensor" => :snmp_sensors,
"snmp_interface" => :snmp_interfaces,
"snmp_processor" => :snmp_processors,
"snmp_storage" => :snmp_storage,
"http" => :http_checks,
"tcp" => :tcp_checks,
"dns" => :dns_checks,
"ssl" => :ssl_checks,
"ping" => :ping_checks
}
checks = Monitoring.list_checks(organization_id, device_id: device_id)
grouped_checks =
Enum.group_by(checks, fn check ->
Map.get(check_type_groups, check.check_type, :other_checks)
end)
%{checks: checks, grouped_checks: grouped_checks}
end
@doc """
Loads Gaiia integration data for a device.
Returns a map with `:gaiia_item`, `:gaiia_account`, `:gaiia_subscriptions`, and `:gaiia_network_site`.
"""
@spec load_gaiia_data(Devices.Device.t()) :: map()
def load_gaiia_data(device) do
gaiia_item = Gaiia.get_inventory_item_for_device(device.id)
{gaiia_account, gaiia_subscriptions, gaiia_network_site} =
if gaiia_item do
account =
if gaiia_item.assigned_account_gaiia_id do
Gaiia.get_account(device.organization_id, gaiia_item.assigned_account_gaiia_id)
end
subscriptions =
if gaiia_item.assigned_account_gaiia_id do
Gaiia.list_billing_subscriptions_for_account(
device.organization_id,
gaiia_item.assigned_account_gaiia_id
)
else
[]
end
network_site =
if gaiia_item.assigned_network_site_gaiia_id do
Gaiia.get_network_site(
device.organization_id,
gaiia_item.assigned_network_site_gaiia_id
)
end
{account, subscriptions, network_site}
else
{nil, [], nil}
end
%{
gaiia_item: gaiia_item,
gaiia_account: gaiia_account,
gaiia_subscriptions: gaiia_subscriptions,
gaiia_network_site: gaiia_network_site
}
end
@doc """
Loads Preseem integration data for a device.
Returns a map with `:preseem_access_point`, `:preseem_metrics`, and `:preseem_insights`.
"""
@spec load_preseem_data(Devices.Device.t()) :: map()
def load_preseem_data(device) do
preseem_ap = Towerops.Preseem.get_access_point_for_device(device.id)
{metrics, insights} =
if preseem_ap do
{
Towerops.Preseem.list_subscriber_metrics(preseem_ap.id, limit: 50),
Towerops.Preseem.list_device_insights(device.id)
}
else
{[], []}
end
%{
preseem_access_point: preseem_ap,
preseem_metrics: metrics,
preseem_insights: insights
}
end
@doc """
Loads wireless clients for a device with subscriber matching.
Returns a map with wireless client data, subscriber mappings, and statistics.
"""
@spec load_wireless_data(binary()) :: map()
def load_wireless_data(device_id) do
wireless_clients = Snmp.list_wireless_clients(device_id)
# Build MAC → subscriber link mapping
wireless_client_subscribers =
if Enum.any?(wireless_clients) do
# Get all device-subscriber links for this device
links = Gaiia.list_device_subscriber_links_with_macs(device_id)
# Build a map of MAC address → link
Map.new(links, &{String.downcase(&1.subscriber_mac), &1})
else
%{}
end
# Count matched subscribers
matched_count =
Enum.count(wireless_clients, fn client ->
Map.has_key?(wireless_client_subscribers, String.downcase(client.mac_address))
end)
# Get last update timestamp
last_updated =
wireless_clients
|> Enum.map(& &1.last_seen_at)
|> Enum.max(DateTime, fn -> nil end)
%{
wireless_clients: wireless_clients,
wireless_client_subscribers: wireless_client_subscribers,
wireless_client_count: length(wireless_clients),
wireless_matched_count: matched_count,
wireless_last_updated: last_updated,
wireless_clients_available: wireless_clients != []
}
end
@doc """
Loads transceiver data for a device.
"""
@spec load_transceivers(Snmp.Device.t() | nil) :: map()
def load_transceivers(nil) do
%{
transceivers: [],
transceivers_available: false
}
end
def load_transceivers(snmp_device) do
transceivers = Snmp.list_transceivers(snmp_device.id)
%{
transceivers: transceivers,
transceivers_available: transceivers != []
}
end
@doc """
Loads printer supplies data for a device.
"""
@spec load_printer_supplies(Snmp.Device.t() | nil) :: map()
def load_printer_supplies(nil) do
%{
printer_supplies: [],
printer_supplies_available: false
}
end
def load_printer_supplies(snmp_device) do
printer_supplies = Snmp.list_printer_supplies(snmp_device.id)
%{
printer_supplies: printer_supplies,
printer_supplies_available: printer_supplies != []
}
end
@doc """
Loads hardware inventory (entity physical) data for a device.
"""
@spec load_hardware_inventory(Snmp.Device.t() | nil) :: map()
def load_hardware_inventory(nil) do
%{
hardware_inventory: [],
hardware_inventory_available: false
}
end
def load_hardware_inventory(snmp_device) do
hardware_inventory = Snmp.list_entity_physical(snmp_device.id)
%{
hardware_inventory: hardware_inventory,
hardware_inventory_available: hardware_inventory != []
}
end
@doc """
Groups IP addresses by interface ID for efficient lookup.
"""
@spec group_ip_addresses_by_interface(list()) :: map()
def group_ip_addresses_by_interface(ip_addresses) do
Enum.group_by(ip_addresses, & &1.snmp_interface_id)
end
@doc """
Loads connected devices (topology) for a device.
"""
@spec load_connected_devices(binary()) :: list()
def load_connected_devices(device_id) do
Towerops.Topology.list_connected_devices(device_id)
end
@doc """
Calculates uptime metrics from monitoring checks.
Returns a map with uptime_percentage, total_checks, and successful_checks.
"""
@spec calculate_metrics(list(), Devices.Device.t()) :: map()
def calculate_metrics(checks, _equipment) do
total_checks = length(checks)
if total_checks > 0 do
successful_checks = Enum.count(checks, &(&1.status == :success))
uptime_percentage = Float.round(successful_checks / total_checks * 100, 1)
%{
uptime_percentage: uptime_percentage,
total_checks: total_checks,
successful_checks: successful_checks
}
else
%{
uptime_percentage: 0,
total_checks: 0,
successful_checks: 0
}
end
end
end