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.
510 lines
15 KiB
Elixir
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
|