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 # Agent is considered offline if it hasn't been seen in the last 5 minutes defp agent_offline?(%{is_cloud_poller: true}), do: false defp agent_offline?(%{last_seen_at: nil}), do: true defp 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.SNMPDevice.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.SNMPDevice.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.SNMPDevice.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.SNMPDevice.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.SNMPDevice.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.SNMPDevice.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 defp determine_vendor(manufacturer) when is_binary(manufacturer) do manufacturer_lower = String.downcase(manufacturer) cond do String.contains?(manufacturer_lower, "mikrotik") -> "mikrotik" String.contains?(manufacturer_lower, "cisco") -> "cisco" String.contains?(manufacturer_lower, "ubiquiti") -> "ubiquiti" true -> nil end end defp determine_vendor(_), do: nil defp determine_product_line(manufacturer) when is_binary(manufacturer) do manufacturer_lower = String.downcase(manufacturer) if String.contains?(manufacturer_lower, "mikrotik") do "routeros" end end defp 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.SNMPDevice.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.SNMPDevice.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.SNMPDevice.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