- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia) - Jason.decode! → Jason.decode with error handling for untrusted input - inspect() leak: replace with generic error messages, log details server-side - SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes - SSRF validation added to HTTP monitoring executor and integration credentials - GraphQL complexity limits: always applied, not just in prod - GraphQL introspection: also check GET query params, not just body - Stripe webhook: explicit nil/empty checks for signature and body - Cookie security: secure flag for session (prod), http_only+secure for remember_me - Honeybadger API key: read from env var with fallback - String.to_integer → Integer.parse with fallback for URL params - String.to_atom → whitelist map for HTTP methods - Gaiia webhook: remove secret_len and expected signature from log - Admin API: add rate limiting pipeline - to_atom_keys: per-key fallback instead of all-or-nothing rescue
1819 lines
56 KiB
Elixir
1819 lines
56 KiB
Elixir
defmodule ToweropsWeb.DeviceLive.Show do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
import Ecto.Query
|
|
|
|
alias Towerops.Agents
|
|
alias Towerops.Devices
|
|
alias Towerops.Devices.Firmware
|
|
alias Towerops.Devices.MikrotikBackups
|
|
alias Towerops.Devices.VersionComparator
|
|
alias Towerops.Gaiia
|
|
alias Towerops.Monitoring
|
|
alias Towerops.Repo
|
|
alias Towerops.Snmp
|
|
alias Towerops.Workers.DiscoveryWorker
|
|
alias ToweropsWeb.CheckLive.FormComponent
|
|
alias ToweropsWeb.Live.Helpers.AccessControl
|
|
|
|
# SNMP interface type to category mappings
|
|
@interface_type_categories %{
|
|
6 => "Ethernet",
|
|
24 => "Loopback",
|
|
23 => "PPP",
|
|
108 => "PPPoE",
|
|
131 => "Tunnel",
|
|
135 => "VLAN",
|
|
136 => "VLAN",
|
|
161 => "IEEE 802.11",
|
|
244 => "WWP"
|
|
}
|
|
|
|
# Display order for interface categories
|
|
@interface_category_order %{
|
|
"Ethernet" => 1,
|
|
"VLAN" => 2,
|
|
"IEEE 802.11" => 3,
|
|
"PPPoE" => 4,
|
|
"PPP" => 5,
|
|
"Tunnel" => 6,
|
|
"Loopback" => 7,
|
|
"WWP" => 8,
|
|
"Other" => 99
|
|
}
|
|
|
|
# Check type to group key mapping for organizing checks by type
|
|
@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,
|
|
"ping" => :ping_checks
|
|
}
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
{:ok, assign(socket, :show_check_form, false)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(%{"id" => id} = params, _, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
# Check if device exists and verify organization access
|
|
case AccessControl.verify_device_access(id, organization.id) do
|
|
{:ok, _device} ->
|
|
maybe_subscribe_and_schedule_refresh(socket, id)
|
|
|
|
# If no tab parameter, redirect to add it to the URL
|
|
if Map.has_key?(params, "tab") do
|
|
tab = params["tab"]
|
|
page = safe_to_integer(Map.get(params, "page", "1"), 1)
|
|
|
|
socket =
|
|
socket
|
|
|> load_equipment_data(id)
|
|
|> assign(:active_tab, tab)
|
|
|> apply_backups_pagination(tab, page)
|
|
|
|
{:noreply, socket}
|
|
else
|
|
# Redirect to add default tab to URL
|
|
{:noreply, push_patch(socket, to: ~p"/devices/#{id}?tab=overview", replace: true)}
|
|
end
|
|
|
|
{:error, :not_found} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, t_equipment("Device not found"))
|
|
|> push_navigate(to: ~p"/devices")}
|
|
|
|
{:error, :unauthorized} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, t_equipment("You don't have access to this device"))
|
|
|> push_navigate(to: ~p"/devices")}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:refresh_data, socket) do
|
|
# Check if device still exists before scheduling next refresh
|
|
case Devices.get_device(socket.assigns.device.id) do
|
|
nil ->
|
|
# Device was deleted, don't schedule another refresh
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, t_equipment("Device no longer exists"))
|
|
|> push_navigate(to: ~p"/devices")}
|
|
|
|
_device ->
|
|
Process.send_after(self(), :refresh_data, 10_000)
|
|
{:noreply, reload_active_tab_data(socket)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:device_status_changed, _device_id, _new_status, _response_time}, socket) do
|
|
# Device status shows on all tabs in the header - reload base data only
|
|
{:noreply, reload_base_data(socket)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:discovery_completed, _device_id}, socket) do
|
|
# Structural change - full reload needed
|
|
{:noreply,
|
|
socket
|
|
|> load_equipment_data(socket.assigns.device.id)
|
|
|> put_flash(:info, t_equipment("Discovery completed"))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:sensors_updated, _device_id}, socket) do
|
|
{:noreply, reload_if_tab(socket, ["overview"])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:interfaces_updated, _device_id}, socket) do
|
|
{:noreply, reload_if_tab(socket, ["overview", "ports"])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:neighbors_updated, _device_id}, socket) do
|
|
{:noreply, reload_if_tab(socket, ["neighbors"])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:arp_updated, _device_id}, socket) do
|
|
{:noreply, reload_if_tab(socket, ["arp"])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:mac_updated, _device_id}, socket) do
|
|
{:noreply, reload_if_tab(socket, ["mac"])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:state_sensors_updated, _device_id}, socket) do
|
|
{:noreply, reload_if_tab(socket, ["overview"])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:wireless_clients_updated, _device_id, _client_count}, socket) do
|
|
{:noreply, reload_if_tab(socket, ["wireless"])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:processors_updated, _device_id}, socket) do
|
|
{:noreply, reload_if_tab(socket, ["overview"])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:storage_updated, _device_id}, socket) do
|
|
{:noreply, reload_if_tab(socket, ["overview"])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:monitoring_check_updated, _device_id}, socket) do
|
|
{:noreply, reload_if_tab(socket, ["overview"])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:device_event, _event_attrs}, socket) do
|
|
# Device event logged (interface changes, sensor thresholds, etc.)
|
|
{:noreply, reload_if_tab(socket, ["logs"])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:agent_connected, agent_token_id, _organization_id}, socket) do
|
|
# Reload agent info if this device's agent just connected
|
|
if socket.assigns.agent_info.agent_token_id == agent_token_id do
|
|
{:noreply, reload_base_data(socket)}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:agent_disconnected, agent_token_id, _organization_id}, socket) do
|
|
# Reload agent info if this device's agent just disconnected
|
|
if socket.assigns.agent_info.agent_token_id == agent_token_id do
|
|
{:noreply, reload_base_data(socket)}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:agent_heartbeat, agent_token_id, _organization_id}, socket) do
|
|
# Reload agent info if this device's agent sent a heartbeat
|
|
if socket.assigns.agent_info.agent_token_id == agent_token_id do
|
|
{:noreply, reload_base_data(socket)}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:agents_stale, stale_agents}, socket) do
|
|
# Reload agent info if this device's agent went stale
|
|
device_agent_id = socket.assigns.agent_info.agent_token_id
|
|
|
|
if device_agent_id && Enum.any?(stale_agents, &(&1.id == device_agent_id)) do
|
|
{:noreply, reload_base_data(socket)}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({FormComponent, {:check_created, _check}}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_check_form, false)
|
|
|> put_flash(:info, t("Check created successfully"))
|
|
|> load_equipment_data(socket.assigns.device.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({FormComponent, :close}, socket) do
|
|
{:noreply, assign(socket, :show_check_form, false)}
|
|
end
|
|
|
|
# Private functions
|
|
|
|
defp maybe_subscribe_and_schedule_refresh(socket, device_id) do
|
|
_ =
|
|
if connected?(socket) do
|
|
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}")
|
|
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
|
|
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "wireless_clients:device:#{device_id}")
|
|
Process.send_after(self(), :refresh_data, 10_000)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
# -- Full data load (initial page load and discovery_completed) --
|
|
|
|
# Loads all data for every tab. Called from handle_params (initial load)
|
|
# and discovery_completed (structural change requiring full refresh).
|
|
defp load_equipment_data(socket, device_id) do
|
|
socket
|
|
|> assign_base_data(device_id)
|
|
|> assign_tab_nav_data(device_id)
|
|
|> assign_overview_data(device_id)
|
|
|> assign_ports_data()
|
|
|> assign_wireless_data(device_id)
|
|
|> assign_logs_data(device_id)
|
|
|> assign_backups_data(device_id)
|
|
|> assign_checks_data(device_id)
|
|
|> assign_preseem_data()
|
|
|> assign_gaiia_data()
|
|
|> assign_subscriber_impact()
|
|
end
|
|
|
|
# -- Selective reload helpers (used by handle_info handlers) --
|
|
|
|
# Reload only base data (device, agent_info, snmp_device).
|
|
# Used when device status or agent state changes - these show in the header on all tabs.
|
|
defp reload_base_data(socket) do
|
|
assign_base_data(socket, socket.assigns.device.id)
|
|
end
|
|
|
|
# Reload base data + data for the currently active tab only.
|
|
# Used by the periodic refresh timer.
|
|
defp reload_active_tab_data(socket) do
|
|
device_id = socket.assigns.device.id
|
|
|
|
socket
|
|
|> assign_base_data(device_id)
|
|
|> assign_tab_nav_data(device_id)
|
|
|> reload_current_tab_data(device_id)
|
|
end
|
|
|
|
# Reload data only if the active tab is one of the relevant tabs.
|
|
# Otherwise does nothing - the event is irrelevant to what the user sees.
|
|
defp reload_if_tab(socket, relevant_tabs) do
|
|
if socket.assigns.active_tab in relevant_tabs do
|
|
reload_current_tab_data(socket, socket.assigns.device.id)
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
# Dispatch to the appropriate tab-specific loader based on active_tab.
|
|
defp reload_current_tab_data(socket, device_id) do
|
|
# Tabs that need device_id parameter
|
|
tabs_with_device_id = ["overview", "logs", "backups", "checks", "wireless"]
|
|
# Tabs that don't need device_id parameter
|
|
tabs_without_device_id = ["ports", "preseem", "gaiia"]
|
|
|
|
tab = socket.assigns.active_tab
|
|
|
|
cond do
|
|
tab in tabs_with_device_id -> reload_tab_with_device_id(socket, device_id, tab)
|
|
tab in tabs_without_device_id -> reload_tab_without_device_id(socket, tab)
|
|
# neighbors, arp, mac, vlans, ip_addresses, debug use data from tab_nav/base
|
|
true -> socket
|
|
end
|
|
end
|
|
|
|
defp reload_tab_with_device_id(socket, device_id, tab) do
|
|
case tab do
|
|
"overview" -> assign_overview_data(socket, device_id)
|
|
"logs" -> assign_logs_data(socket, device_id)
|
|
"backups" -> assign_backups_data(socket, device_id)
|
|
"checks" -> assign_checks_data(socket, device_id)
|
|
"wireless" -> assign_wireless_data(socket, device_id)
|
|
end
|
|
end
|
|
|
|
defp reload_tab_without_device_id(socket, tab) do
|
|
case tab do
|
|
"ports" -> assign_ports_data(socket)
|
|
"preseem" -> assign_preseem_data(socket)
|
|
"gaiia" -> assign_gaiia_data(socket)
|
|
end
|
|
end
|
|
|
|
# -- Focused data loaders --
|
|
|
|
# Base data needed by all tabs: device record, agent info, SNMP device/sensors/interfaces.
|
|
defp assign_base_data(socket, device_id) do
|
|
device = Devices.get_device!(device_id)
|
|
device_with_associations = Repo.preload(device, [:organization, site: [organization: :default_agent_token]])
|
|
{agent_token_id, agent_source} = Agents.get_effective_agent_token_with_source(device_with_associations)
|
|
agent_info = load_agent_info(agent_token_id, agent_source)
|
|
snmp_data = load_snmp_data(device_id)
|
|
|
|
socket
|
|
|> assign(:page_title, device.name)
|
|
|> assign(:timezone, socket.assigns.current_scope.timezone)
|
|
|> assign(:device, device)
|
|
|> assign(:agent_info, agent_info)
|
|
|> assign(:snmp_device, snmp_data.device)
|
|
|> assign(:snmp_interfaces, snmp_data.interfaces)
|
|
|> assign(:snmp_sensors, snmp_data.sensors)
|
|
end
|
|
|
|
# Data needed for the tab navigation bar to decide which tabs to show.
|
|
# Neighbors, ARP, MAC, VLANs, and IP addresses determine tab visibility.
|
|
defp assign_tab_nav_data(socket, device_id) do
|
|
snmp_device = socket.assigns.snmp_device
|
|
neighbors = Snmp.list_neighbors_with_equipment(device_id)
|
|
arp_entries = Snmp.list_arp_entries(device_id)
|
|
mac_addresses = Snmp.list_mac_addresses(device_id)
|
|
vlans = load_vlans(snmp_device)
|
|
ip_addresses = load_ip_addresses(snmp_device)
|
|
|
|
socket
|
|
|> assign(:neighbors, neighbors)
|
|
|> assign(:arp_entries, arp_entries)
|
|
|> assign(:mac_addresses, mac_addresses)
|
|
|> assign(:vlans, vlans)
|
|
|> assign(:ipv4_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv4")))
|
|
|> assign(:ipv6_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv6")))
|
|
|> assign(:ip_addresses_by_interface, group_ip_addresses_by_interface(ip_addresses))
|
|
end
|
|
|
|
# Overview tab: charts, sensor classifications, metrics, processors, storage, firmware.
|
|
# This is the most expensive loader due to time-series chart data queries.
|
|
defp assign_overview_data(socket, device_id) do
|
|
device = socket.assigns.device
|
|
snmp_device = socket.assigns.snmp_device
|
|
sensors = socket.assigns.snmp_sensors
|
|
|
|
processors = load_processors(snmp_device)
|
|
storage = load_storage(snmp_device)
|
|
latency_chart_data = load_latency_chart_data(device_id)
|
|
processor_chart_data = load_processor_chart_data(processors)
|
|
memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"])
|
|
storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"])
|
|
storage_volume_chart_data = load_storage_volume_chart_data(storage)
|
|
traffic_chart_data = load_overall_traffic_chart_data(device_id)
|
|
|
|
wireless_chart_data =
|
|
load_sensor_chart_data(device_id, [
|
|
"frequency",
|
|
"power",
|
|
"rssi",
|
|
"ccq",
|
|
"clients",
|
|
"distance",
|
|
"noise-floor",
|
|
"quality",
|
|
"rate",
|
|
"utilization"
|
|
])
|
|
|
|
# Classify sensors by type
|
|
{transceiver_sensors, non_transceiver_sensors} =
|
|
Enum.split_with(sensors, fn sensor ->
|
|
sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "sfp")
|
|
end)
|
|
|
|
temperature_sensors = Enum.filter(non_transceiver_sensors, &temperature_sensor?/1)
|
|
voltage_sensors = Enum.filter(non_transceiver_sensors, &voltage_sensor?/1)
|
|
current_sensors = Enum.filter(non_transceiver_sensors, ¤t_sensor?/1)
|
|
power_sensors = Enum.filter(non_transceiver_sensors, &power_sensor?/1)
|
|
fan_sensors = Enum.filter(non_transceiver_sensors, &fan_sensor?/1)
|
|
load_sensors = Enum.filter(non_transceiver_sensors, &load_sensor?/1)
|
|
storage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["disk_usage"]))
|
|
count_sensors = Enum.filter(non_transceiver_sensors, &counter_sensor?/1)
|
|
|
|
{firewall_counters, general_counters} =
|
|
Enum.split_with(count_sensors, &firewall_counter?/1)
|
|
|
|
wireless_sensors = Enum.filter(non_transceiver_sensors, &wireless_sensor?/1)
|
|
signal_sensors = Enum.filter(non_transceiver_sensors, &signal_sensor?/1)
|
|
grouped_transceivers = group_transceiver_sensors(transceiver_sensors)
|
|
metrics = calculate_metrics([], device)
|
|
available_firmware = get_available_firmware(snmp_device)
|
|
|
|
socket
|
|
|> assign(:metrics, metrics)
|
|
|> assign(:processors, processors)
|
|
|> assign(:storage, storage)
|
|
|> assign(:latency_chart_data, latency_chart_data)
|
|
|> assign(:processor_chart_data, processor_chart_data)
|
|
|> assign(:memory_chart_data, memory_chart_data)
|
|
|> assign(:storage_chart_data, storage_chart_data)
|
|
|> assign(:storage_volume_chart_data, storage_volume_chart_data)
|
|
|> assign(:traffic_chart_data, traffic_chart_data)
|
|
|> assign(:wireless_chart_data, wireless_chart_data)
|
|
|> assign(:temperature_sensors, temperature_sensors)
|
|
|> assign(:voltage_sensors, voltage_sensors)
|
|
|> assign(:current_sensors, current_sensors)
|
|
|> assign(:power_sensors, power_sensors)
|
|
|> assign(:fan_sensors, fan_sensors)
|
|
|> assign(:load_sensors, load_sensors)
|
|
|> assign(:storage_sensors, storage_sensors)
|
|
|> assign(:count_sensors, count_sensors)
|
|
|> assign(:general_counters, general_counters)
|
|
|> assign(:firewall_counters, firewall_counters)
|
|
|> assign(:wireless_sensors, wireless_sensors)
|
|
|> assign(:signal_sensors, signal_sensors)
|
|
|> assign(:grouped_transceivers, grouped_transceivers)
|
|
|> assign(:available_firmware, available_firmware)
|
|
|> assign(:connected_devices, Towerops.Topology.list_connected_devices(device_id))
|
|
|> assign(:recent_config_changes, load_recent_config_changes(device))
|
|
end
|
|
|
|
defp 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
|
|
|
|
# Ports tab: interfaces grouped by type, enriched with utilization data.
|
|
defp assign_ports_data(socket) do
|
|
interfaces = socket.assigns.snmp_interfaces
|
|
|
|
interfaces_with_utilization =
|
|
Enum.map(interfaces, fn interface ->
|
|
utilization =
|
|
if interface.configured_capacity_bps do
|
|
Towerops.Capacity.get_utilization(interface)
|
|
end
|
|
|
|
Map.put(interface, :utilization, utilization)
|
|
end)
|
|
|
|
interfaces_by_type = group_interfaces_by_type(interfaces_with_utilization)
|
|
|
|
assign(socket, :interfaces_by_type, interfaces_by_type)
|
|
end
|
|
|
|
# Logs/events tab data.
|
|
defp assign_logs_data(socket, device_id) do
|
|
events = Devices.list_devices_events(device_id, 100)
|
|
assign(socket, :events, events)
|
|
end
|
|
|
|
# Backups tab data.
|
|
defp assign_backups_data(socket, device_id) do
|
|
device = socket.assigns.device
|
|
|
|
mikrotik_backups =
|
|
if device.mikrotik_enabled do
|
|
MikrotikBackups.list_device_backups(device_id, limit: 50)
|
|
else
|
|
[]
|
|
end
|
|
|
|
socket
|
|
|> assign(:mikrotik_backups, mikrotik_backups)
|
|
|> assign(:selected_backup_ids, MapSet.new())
|
|
end
|
|
|
|
# Checks tab data.
|
|
defp assign_checks_data(socket, device_id) do
|
|
# Load all checks for this device
|
|
checks = Monitoring.list_checks(socket.assigns.device.organization_id, device_id: device_id)
|
|
|
|
# Group checks by type for organized display
|
|
grouped_checks =
|
|
Enum.group_by(checks, fn check ->
|
|
Map.get(@check_type_groups, check.check_type, :other_checks)
|
|
end)
|
|
|
|
socket
|
|
|> assign(:checks, checks)
|
|
|> assign(:grouped_checks, grouped_checks)
|
|
end
|
|
|
|
# Gaiia tab data.
|
|
defp assign_gaiia_data(socket) do
|
|
device = socket.assigns.device
|
|
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
|
|
|
|
socket
|
|
|> assign(:gaiia_item, gaiia_item)
|
|
|> assign(:gaiia_account, gaiia_account)
|
|
|> assign(:gaiia_subscriptions, gaiia_subscriptions)
|
|
|> assign(:gaiia_network_site, gaiia_network_site)
|
|
end
|
|
|
|
# Subscriber impact data (from Gaiia device-to-subscriber matching).
|
|
defp assign_subscriber_impact(socket) do
|
|
device = socket.assigns.device
|
|
|
|
impact =
|
|
try do
|
|
Gaiia.get_device_impact(device.id)
|
|
rescue
|
|
_ -> %{subscriber_count: 0, mrr: nil, accounts: []}
|
|
end
|
|
|
|
assign(socket, :subscriber_impact, impact)
|
|
end
|
|
|
|
# Preseem tab data.
|
|
defp assign_preseem_data(socket) do
|
|
device = socket.assigns.device
|
|
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
|
|
|
|
socket
|
|
|> assign(:preseem_access_point, preseem_ap)
|
|
|> assign(:preseem_metrics, metrics)
|
|
|> assign(:preseem_insights, insights)
|
|
end
|
|
|
|
# Wireless clients tab data.
|
|
defp assign_wireless_data(socket, 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 =
|
|
Repo.all(
|
|
from(l in Towerops.Gaiia.DeviceSubscriberLink,
|
|
where: l.device_id == ^device_id,
|
|
where: not is_nil(l.subscriber_mac),
|
|
preload: [:gaiia_account]
|
|
)
|
|
)
|
|
|
|
# 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)
|
|
|
|
socket
|
|
|> assign(:wireless_clients, wireless_clients)
|
|
|> assign(:wireless_client_subscribers, wireless_client_subscribers)
|
|
|> assign(:wireless_client_count, length(wireless_clients))
|
|
|> assign(:wireless_matched_count, matched_count)
|
|
|> assign(:wireless_last_updated, last_updated)
|
|
|> assign(:wireless_clients_available, wireless_clients != [])
|
|
end
|
|
|
|
defp get_available_firmware(nil), do: nil
|
|
|
|
defp 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
|
|
|
|
defp firmware_update_available?(nil, _), do: false
|
|
defp firmware_update_available?(_, nil), do: false
|
|
|
|
defp firmware_update_available?(snmp_device, available_firmware) do
|
|
current = snmp_device.firmware_version
|
|
available = available_firmware.version
|
|
|
|
# Extract clean version numbers for comparison
|
|
# "RouterOS 7.20.6 (Level 6)" -> "7.20.6"
|
|
# "7.21.2" -> "7.21.2"
|
|
current_clean = extract_version_number(current)
|
|
available_clean = extract_version_number(available)
|
|
|
|
current_clean && available_clean && VersionComparator.newer?(current_clean, available_clean)
|
|
end
|
|
|
|
# Extract semantic version number from version string
|
|
# Examples:
|
|
# "RouterOS 7.20.6 (Level 6)" -> "7.20.6"
|
|
# "7.21.2" -> "7.21.2"
|
|
# "v7.14.1" -> "7.14.1"
|
|
defp extract_version_number(nil), do: nil
|
|
defp extract_version_number(""), do: nil
|
|
|
|
defp extract_version_number(version_string) when is_binary(version_string) do
|
|
# Match semantic version pattern: X.Y or X.Y.Z
|
|
case Regex.run(~r/\d+\.\d+(?:\.\d+)?/, version_string) do
|
|
[version] -> version
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp format_date(nil), do: ""
|
|
|
|
defp format_date(date) do
|
|
Calendar.strftime(date, "%B %-d, %Y")
|
|
end
|
|
|
|
defp 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
|
|
|
|
defp 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
|
|
|
|
defp 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
|
|
|
|
defp format_speed(speed_bps) when is_integer(speed_bps) do
|
|
cond do
|
|
speed_bps >= 1_000_000_000 ->
|
|
"#{Float.round(speed_bps / 1_000_000_000, 1)} Gbps"
|
|
|
|
speed_bps >= 1_000_000 ->
|
|
"#{Float.round(speed_bps / 1_000_000, 1)} Mbps"
|
|
|
|
speed_bps >= 1_000 ->
|
|
"#{Float.round(speed_bps / 1_000, 1)} Kbps"
|
|
|
|
true ->
|
|
"#{speed_bps} bps"
|
|
end
|
|
end
|
|
|
|
defp format_speed(_), do: "-"
|
|
|
|
defp capacity_source_label("manual"), do: "Manual"
|
|
defp capacity_source_label("sensor"), do: "Sensor"
|
|
defp capacity_source_label("if_speed"), do: "Link"
|
|
defp capacity_source_label("peak"), do: "Peak"
|
|
defp capacity_source_label(_), do: ""
|
|
|
|
defp capacity_source_class("manual"), do: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
|
|
defp capacity_source_class("sensor"), do: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"
|
|
defp capacity_source_class("if_speed"), do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
|
defp capacity_source_class("peak"), do: "bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300"
|
|
defp capacity_source_class(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
|
|
|
defp utilization_color(pct) when pct >= 90, do: "bg-red-500"
|
|
defp utilization_color(pct) when pct >= 70, do: "bg-yellow-500"
|
|
defp utilization_color(_pct), do: "bg-green-500"
|
|
|
|
defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400"
|
|
defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400"
|
|
defp utilization_text_color(_pct), do: "text-green-600 dark:text-green-400"
|
|
|
|
defp format_bytes(nil), do: "-"
|
|
defp format_bytes(0), do: "0 B"
|
|
|
|
defp format_bytes(bytes) when is_integer(bytes) do
|
|
cond do
|
|
bytes >= 1_099_511_627_776 ->
|
|
"#{Float.round(bytes / 1_099_511_627_776, 1)} TB"
|
|
|
|
bytes >= 1_073_741_824 ->
|
|
"#{Float.round(bytes / 1_073_741_824, 1)} GB"
|
|
|
|
bytes >= 1_048_576 ->
|
|
"#{Float.round(bytes / 1_048_576, 1)} MB"
|
|
|
|
bytes >= 1024 ->
|
|
"#{Float.round(bytes / 1024, 1)} KB"
|
|
|
|
true ->
|
|
"#{bytes} B"
|
|
end
|
|
end
|
|
|
|
defp format_bytes(_), do: "-"
|
|
|
|
defp format_sensor_value(value, _divisor) when is_number(value) do
|
|
# Value is already divided by divisor when stored in sensor_reading
|
|
Float.round(value, 1)
|
|
end
|
|
|
|
defp format_sensor_value(_, _), do: "-"
|
|
|
|
defp time_ago(nil), do: "Never"
|
|
|
|
defp time_ago(datetime) do
|
|
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
|
|
|
cond do
|
|
diff < 60 -> "#{diff}s ago"
|
|
diff < 3600 -> "#{div(diff, 60)}m ago"
|
|
diff < 86_400 -> "#{div(diff, 3600)}h ago"
|
|
true -> "#{div(diff, 86_400)}d ago"
|
|
end
|
|
end
|
|
|
|
defp format_uptime(nil), do: "N/A"
|
|
|
|
defp format_uptime(timeticks) when is_integer(timeticks) do
|
|
# Timeticks are in hundredths of a second
|
|
seconds = div(timeticks, 100)
|
|
format_duration(seconds)
|
|
end
|
|
|
|
defp format_uptime(_), do: "N/A"
|
|
|
|
defp format_duration(total_seconds) do
|
|
{weeks, remainder} = seconds_to_weeks(total_seconds)
|
|
{days, remainder} = seconds_to_days(remainder)
|
|
{hours, remainder} = seconds_to_hours(remainder)
|
|
{minutes, seconds} = seconds_to_minutes(remainder)
|
|
|
|
parts =
|
|
Enum.reject(
|
|
[
|
|
format_time_part(weeks, "week"),
|
|
format_time_part(days, "day"),
|
|
format_time_part(hours, "hour"),
|
|
format_time_part(minutes, "minute"),
|
|
format_time_part(seconds, "second")
|
|
],
|
|
&is_nil/1
|
|
)
|
|
|
|
case parts do
|
|
[] -> "0 seconds"
|
|
parts -> Enum.join(parts, " ")
|
|
end
|
|
end
|
|
|
|
@spec seconds_to_weeks(integer()) :: {integer(), integer()}
|
|
defp seconds_to_weeks(seconds), do: {div(seconds, 604_800), rem(seconds, 604_800)}
|
|
|
|
@spec seconds_to_days(integer()) :: {integer(), integer()}
|
|
defp seconds_to_days(seconds), do: {div(seconds, 86_400), rem(seconds, 86_400)}
|
|
|
|
@spec seconds_to_hours(integer()) :: {integer(), integer()}
|
|
defp seconds_to_hours(seconds), do: {div(seconds, 3600), rem(seconds, 3600)}
|
|
|
|
@spec seconds_to_minutes(integer()) :: {integer(), integer()}
|
|
defp seconds_to_minutes(seconds), do: {div(seconds, 60), rem(seconds, 60)}
|
|
|
|
@spec format_time_part(integer(), String.t()) :: String.t() | nil
|
|
defp format_time_part(0, _unit), do: nil
|
|
defp format_time_part(1, unit), do: "1 #{unit}"
|
|
defp format_time_part(count, unit), do: "#{count} #{unit}s"
|
|
|
|
defp format_device_age(nil), do: "N/A"
|
|
|
|
defp format_device_age(datetime) do
|
|
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
|
format_duration(diff) <> " ago"
|
|
end
|
|
|
|
defp format_event_type(event_type) do
|
|
event_type
|
|
|> String.replace("_", " ")
|
|
|> String.capitalize()
|
|
end
|
|
|
|
defp 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
|
|
|
|
defp load_vlans(nil), do: []
|
|
defp load_vlans(snmp_device), do: Snmp.list_vlans(snmp_device.id)
|
|
|
|
defp load_ip_addresses(nil), do: []
|
|
defp load_ip_addresses(snmp_device), do: Snmp.list_ip_addresses(snmp_device.id)
|
|
|
|
defp load_processors(nil), do: []
|
|
defp load_processors(snmp_device), do: Snmp.list_processors(snmp_device.id)
|
|
|
|
defp load_storage(nil), do: []
|
|
defp load_storage(snmp_device), do: Snmp.list_storage(snmp_device.id)
|
|
|
|
defp load_processor_chart_data([]), do: nil
|
|
|
|
defp load_processor_chart_data(processors) do
|
|
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
|
|
|
|
datasets =
|
|
processors
|
|
|> Enum.map(&processor_to_chart_dataset(&1, twenty_four_hours_ago))
|
|
|> Enum.reject(&Enum.empty?(&1.data))
|
|
|
|
if Enum.empty?(datasets) do
|
|
nil
|
|
else
|
|
Jason.encode!(%{datasets: datasets})
|
|
end
|
|
end
|
|
|
|
defp processor_to_chart_dataset(processor, since) do
|
|
readings =
|
|
processor.id
|
|
|> Snmp.get_processor_readings(since: since, limit: 1000)
|
|
|> Enum.reverse()
|
|
|
|
label =
|
|
if processor.description do
|
|
processor.description
|
|
else
|
|
"CPU #{processor.processor_index}"
|
|
end
|
|
|
|
%{
|
|
label: label,
|
|
data: Enum.map(readings, &processor_reading_to_data_point/1)
|
|
}
|
|
end
|
|
|
|
defp processor_reading_to_data_point(reading) do
|
|
%{
|
|
x: DateTime.to_unix(reading.checked_at, :millisecond),
|
|
y: if(reading.load_percent, do: Float.round(reading.load_percent, 1))
|
|
}
|
|
end
|
|
|
|
defp load_storage_volume_chart_data([]), do: nil
|
|
|
|
defp load_storage_volume_chart_data(storage_volumes) do
|
|
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
|
|
|
|
datasets =
|
|
storage_volumes
|
|
|> Enum.map(&storage_volume_to_chart_dataset(&1, twenty_four_hours_ago))
|
|
|> Enum.reject(&Enum.empty?(&1.data))
|
|
|
|
if Enum.empty?(datasets) do
|
|
nil
|
|
else
|
|
Jason.encode!(%{datasets: datasets})
|
|
end
|
|
end
|
|
|
|
defp storage_volume_to_chart_dataset(storage, since) do
|
|
readings =
|
|
storage.id
|
|
|> Snmp.get_storage_readings(since: since, limit: 1000)
|
|
|> Enum.reverse()
|
|
|
|
label = storage.description || "Storage #{storage.storage_index}"
|
|
|
|
%{
|
|
label: label,
|
|
data: Enum.map(readings, &storage_reading_to_data_point/1)
|
|
}
|
|
end
|
|
|
|
defp storage_reading_to_data_point(reading) do
|
|
%{
|
|
x: DateTime.to_unix(reading.checked_at, :millisecond),
|
|
y: if(reading.usage_percent, do: Float.round(reading.usage_percent, 1))
|
|
}
|
|
end
|
|
|
|
defp load_sensor_chart_data(device_id, sensor_types) when is_list(sensor_types) do
|
|
case Snmp.get_device_with_associations(device_id) do
|
|
nil -> nil
|
|
device -> build_sensor_datasets_json(device, sensor_types)
|
|
end
|
|
end
|
|
|
|
defp build_sensor_datasets_json(device, sensor_types) do
|
|
sensors =
|
|
device.sensors
|
|
|> Enum.filter(&(&1.sensor_type in sensor_types))
|
|
|> Enum.sort_by(& &1.sensor_index)
|
|
|
|
if Enum.empty?(sensors) do
|
|
nil
|
|
else
|
|
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
|
|
datasets = Enum.map(sensors, &sensor_to_chart_dataset(&1, twenty_four_hours_ago))
|
|
Jason.encode!(%{datasets: datasets})
|
|
end
|
|
end
|
|
|
|
defp sensor_to_chart_dataset(sensor, since) do
|
|
readings =
|
|
sensor.id
|
|
|> Snmp.get_sensor_readings(since: since, limit: 1000)
|
|
|> Enum.reverse()
|
|
|
|
%{
|
|
label: sensor.sensor_descr,
|
|
data: Enum.map(readings, &reading_to_data_point/1)
|
|
}
|
|
end
|
|
|
|
defp reading_to_data_point(reading) do
|
|
%{
|
|
x: DateTime.to_unix(reading.checked_at, :millisecond),
|
|
y: if(reading.value, do: Float.round(reading.value, 1))
|
|
}
|
|
end
|
|
|
|
defp load_latency_chart_data(device_id) do
|
|
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
|
|
|
|
checks =
|
|
device_id
|
|
|> Monitoring.get_latency_data(since: twenty_four_hours_ago, limit: 1000)
|
|
|> Enum.reverse()
|
|
|
|
if Enum.empty?(checks) do
|
|
nil
|
|
else
|
|
dataset = %{
|
|
label: "ICMP Latency",
|
|
data: Enum.map(checks, &latency_check_to_data_point/1)
|
|
}
|
|
|
|
Jason.encode!(%{datasets: [dataset]})
|
|
end
|
|
end
|
|
|
|
defp latency_check_to_data_point(check) do
|
|
%{
|
|
x: DateTime.to_unix(check.checked_at, :millisecond),
|
|
y: check.response_time_ms
|
|
}
|
|
end
|
|
|
|
defp load_overall_traffic_chart_data(device_id) do
|
|
case Snmp.get_device_with_associations(device_id) do
|
|
nil -> nil
|
|
device -> build_overall_traffic_chart_json(device)
|
|
end
|
|
end
|
|
|
|
defp build_overall_traffic_chart_json(device) do
|
|
if Enum.empty?(device.interfaces) do
|
|
nil
|
|
else
|
|
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
|
|
all_stats = aggregate_interface_traffic_stats(device.interfaces, twenty_four_hours_ago)
|
|
|
|
if Enum.empty?(all_stats) do
|
|
nil
|
|
else
|
|
build_traffic_json_datasets(all_stats)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Calculate BPS per interface first, then aggregate by timestamp
|
|
# This avoids issues with misaligned timestamps between interfaces
|
|
defp aggregate_interface_traffic_stats(interfaces, since) do
|
|
interfaces
|
|
|> Enum.flat_map(fn interface ->
|
|
interface.id
|
|
|> Snmp.get_interface_stats(since: since, limit: 1000)
|
|
|> Enum.reverse()
|
|
|> calculate_interface_bps()
|
|
end)
|
|
|> Enum.group_by(& &1.timestamp_ms)
|
|
|> Enum.map(fn {ts, bps_list} ->
|
|
total_in = Enum.reduce(bps_list, 0.0, fn x, acc -> acc + x.in_bps end)
|
|
total_out = Enum.reduce(bps_list, 0.0, fn x, acc -> acc + x.out_bps end)
|
|
%{timestamp_ms: ts, in_bps: total_in, out_bps: total_out}
|
|
end)
|
|
|> Enum.sort_by(& &1.timestamp_ms)
|
|
end
|
|
|
|
# Calculate BPS for a single interface's stats
|
|
defp calculate_interface_bps(stats) do
|
|
stats
|
|
|> Enum.chunk_every(2, 1, :discard)
|
|
|> Enum.map(fn [s1, s2] ->
|
|
time_diff = s2.checked_at |> DateTime.diff(s1.checked_at, :second) |> max(1)
|
|
|
|
in_bps =
|
|
if s2.if_in_octets && s1.if_in_octets do
|
|
max((s2.if_in_octets - s1.if_in_octets) * 8 / time_diff, 0)
|
|
else
|
|
0.0
|
|
end
|
|
|
|
out_bps =
|
|
if s2.if_out_octets && s1.if_out_octets do
|
|
max((s2.if_out_octets - s1.if_out_octets) * 8 / time_diff, 0)
|
|
else
|
|
0.0
|
|
end
|
|
|
|
# Round timestamp to nearest minute for aggregation (avoids microsecond misalignment)
|
|
ts_seconds = DateTime.to_unix(s2.checked_at)
|
|
rounded_ts_ms = div(ts_seconds, 60) * 60 * 1000
|
|
|
|
%{timestamp_ms: rounded_ts_ms, in_bps: in_bps, out_bps: out_bps}
|
|
end)
|
|
end
|
|
|
|
defp build_traffic_json_datasets(all_stats) do
|
|
in_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: Float.round(s.in_bps, 2)} end)
|
|
out_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: -Float.round(s.out_bps, 2)} end)
|
|
|
|
datasets = [
|
|
%{label: "Outbound", data: out_data},
|
|
%{label: "Inbound", data: in_data}
|
|
]
|
|
|
|
Jason.encode!(%{datasets: datasets})
|
|
end
|
|
|
|
# Group interfaces by type for organized display
|
|
defp group_interfaces_by_type(interfaces) do
|
|
interfaces
|
|
|> Enum.group_by(&get_interface_type_category(&1.if_type))
|
|
|> Enum.map(fn {category, interfaces} ->
|
|
{category, Enum.sort_by(interfaces, & &1.if_index)}
|
|
end)
|
|
|> Enum.sort_by(fn {category, _} -> interface_type_order(category) end)
|
|
end
|
|
|
|
# Map SNMP interface types to human-readable categories
|
|
defp get_interface_type_category(if_type) when is_integer(if_type) do
|
|
Map.get(@interface_type_categories, if_type, "Other")
|
|
end
|
|
|
|
defp get_interface_type_category(_), do: "Other"
|
|
|
|
# Define display order for interface categories
|
|
defp interface_type_order(category) do
|
|
Map.get(@interface_category_order, category, 99)
|
|
end
|
|
|
|
# Group transceiver sensors by transceiver name
|
|
# Returns list of {name, sensors} tuples sorted by name
|
|
defp group_transceiver_sensors(transceiver_sensors) do
|
|
transceiver_sensors
|
|
|> Enum.group_by(&extract_transceiver_name/1)
|
|
|> Enum.sort_by(fn {name, _sensors} -> name end)
|
|
end
|
|
|
|
# Extract transceiver name from sensor description
|
|
# Examples:
|
|
# "sfp-sfpplus1 Rx Power" -> "sfp-sfpplus1"
|
|
# "sfp-sfpplus2 Temperature" -> "sfp-sfpplus2"
|
|
# "SFP1 Voltage" -> "SFP1"
|
|
defp extract_transceiver_name(sensor) do
|
|
case sensor.sensor_descr do
|
|
nil ->
|
|
"unknown"
|
|
|
|
descr ->
|
|
# Extract the first word (transceiver identifier)
|
|
descr
|
|
|> String.split(" ")
|
|
|> List.first()
|
|
|> String.downcase()
|
|
end
|
|
end
|
|
|
|
# Group IP addresses by interface ID for efficient lookup in templates
|
|
defp group_ip_addresses_by_interface(ip_addresses) do
|
|
Enum.group_by(ip_addresses, & &1.snmp_interface_id)
|
|
end
|
|
|
|
# Sensor type classification helpers
|
|
# Trust sensor_type first (set during SNMP discovery based on OID)
|
|
# Only fall back to unit/description matching if sensor_type is missing/unknown
|
|
defp temperature_sensor?(sensor) do
|
|
# Primary: Check sensor_type (set during discovery based on OID)
|
|
type_match = sensor.sensor_type in ["temperature", "cpu_temperature", "celsius"]
|
|
|
|
# Fallback: Check unit for devices that don't set sensor_type correctly
|
|
unit_match = sensor.sensor_unit in ["°C", "C", "celsius"]
|
|
|
|
# Only use description as last resort if type and unit don't match
|
|
# and only for unambiguous temperature keywords
|
|
descr_match =
|
|
!type_match && !unit_match &&
|
|
sensor.sensor_descr &&
|
|
String.contains?(String.downcase(sensor.sensor_descr), "temperature") &&
|
|
!String.contains?(String.downcase(sensor.sensor_descr), "voltage")
|
|
|
|
(type_match || unit_match || descr_match) && !voltage_by_description?(sensor)
|
|
end
|
|
|
|
defp voltage_sensor?(sensor) do
|
|
type_match = sensor.sensor_type in ["voltage", "volts"]
|
|
unit_match = sensor.sensor_unit in ["V", "mV", "volts"]
|
|
descr_match = voltage_by_description?(sensor)
|
|
|
|
type_match || unit_match || descr_match
|
|
end
|
|
|
|
defp voltage_by_description?(sensor) do
|
|
sensor.sensor_descr &&
|
|
String.contains?(String.downcase(sensor.sensor_descr), "voltage")
|
|
end
|
|
|
|
defp wireless_sensor?(sensor) do
|
|
sensor.sensor_type in [
|
|
"frequency",
|
|
"power",
|
|
"rssi",
|
|
"ccq",
|
|
"clients",
|
|
"distance",
|
|
"noise-floor",
|
|
"quality",
|
|
"rate",
|
|
"utilization"
|
|
]
|
|
end
|
|
|
|
# Counter sensors include various count and gauge types
|
|
# (connections, sessions, clients, DHCP leases, etc.)
|
|
defp counter_sensor?(sensor) do
|
|
sensor.sensor_type in [
|
|
"count",
|
|
"pppoe_sessions",
|
|
"connections",
|
|
"clients",
|
|
"leases",
|
|
"sessions"
|
|
]
|
|
end
|
|
|
|
# Firewall counters are connection-related metrics
|
|
defp firewall_counter?(sensor) do
|
|
descr = String.downcase(sensor.sensor_descr || "")
|
|
String.contains?(descr, "connection")
|
|
end
|
|
|
|
# Current sensors (amperes)
|
|
defp current_sensor?(sensor) do
|
|
type_match = sensor.sensor_type in ["current", "amperes"]
|
|
unit_match = sensor.sensor_unit in ["A", "mA", "amperes"]
|
|
|
|
type_match || unit_match
|
|
end
|
|
|
|
# Power sensors (watts) - separate from wireless power
|
|
defp power_sensor?(sensor) do
|
|
type_match = sensor.sensor_type in ["power", "watts"]
|
|
unit_match = sensor.sensor_unit in ["W", "mW", "watts"]
|
|
|
|
# Exclude wireless power sensors (those are in wireless category)
|
|
(type_match || unit_match) && !wireless_sensor?(sensor)
|
|
end
|
|
|
|
# Fan speed sensors (RPM)
|
|
defp fan_sensor?(sensor) do
|
|
type_match = sensor.sensor_type in ["fanspeed", "rpm"]
|
|
unit_match = sensor.sensor_unit in ["RPM", "rpm"]
|
|
descr_match = sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "fan")
|
|
|
|
type_match || unit_match || descr_match
|
|
end
|
|
|
|
# System load sensors (CPU load, etc.)
|
|
defp load_sensor?(sensor) do
|
|
type_match = sensor.sensor_type in ["load", "cpu_load", "system_load"]
|
|
descr_match = sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "load")
|
|
|
|
type_match || descr_match
|
|
end
|
|
|
|
# Signal quality sensors (SNR, RSSI, RSRP, etc.)
|
|
defp signal_sensor?(sensor) do
|
|
sensor.sensor_type in [
|
|
"snr",
|
|
"rssi",
|
|
"rsrp",
|
|
"rsrq",
|
|
"sinr",
|
|
"ssr",
|
|
"mse",
|
|
"noise",
|
|
"noise-floor",
|
|
"dbm"
|
|
]
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("set_capacity", %{"interface_id" => id, "capacity_mbps" => mbps_str}, socket) do
|
|
case Float.parse(mbps_str) do
|
|
{mbps, _} when mbps > 0 ->
|
|
bps = round(mbps * 1_000_000)
|
|
|
|
case Snmp.set_manual_capacity(id, bps) do
|
|
{:ok, _} ->
|
|
socket = reload_snmp_and_ports(socket)
|
|
{:noreply, put_flash(socket, :info, t("Capacity updated"))}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to update capacity"))}
|
|
end
|
|
|
|
_ ->
|
|
{:noreply, put_flash(socket, :error, t("Invalid capacity value"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("clear_capacity", %{"interface_id" => id}, socket) do
|
|
case Snmp.clear_manual_capacity(id) do
|
|
{:ok, _} ->
|
|
socket = reload_snmp_and_ports(socket)
|
|
{:noreply, put_flash(socket, :info, t("Capacity cleared"))}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to clear capacity"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("add_check", _params, socket) do
|
|
{:noreply, assign(socket, :show_check_form, true)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("run_discovery", _params, socket) do
|
|
device = socket.assigns.device
|
|
|
|
# Queue discovery job
|
|
case %{device_id: device.id} |> DiscoveryWorker.new() |> Oban.insert() do
|
|
{:ok, _job} ->
|
|
{:noreply, put_flash(socket, :info, t("Discovery started for %{name}", name: device.name))}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to start discovery. Please try again."))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("download_backup", %{"id" => backup_id}, socket) do
|
|
backup = MikrotikBackups.get_backup!(backup_id)
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
# Verify the backup belongs to a device in the user's organization
|
|
case AccessControl.verify_device_access(backup.device_id, organization.id) do
|
|
{:ok, device} ->
|
|
config_text = MikrotikBackups.decompress_config(backup.config_compressed)
|
|
filename = "#{device.name}_backup_#{Calendar.strftime(backup.backed_up_at, "%Y%m%d_%H%M%S")}.rsc"
|
|
|
|
{:noreply, push_event(socket, "download", %{content: config_text, filename: filename, mime_type: "text/plain"})}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("You don't have permission to access this backup"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_backup_selection", %{"id" => backup_id}, socket) do
|
|
selected = socket.assigns.selected_backup_ids
|
|
|
|
updated_selected =
|
|
if MapSet.member?(selected, backup_id) do
|
|
MapSet.delete(selected, backup_id)
|
|
else
|
|
# Limit to 2 selections max
|
|
if MapSet.size(selected) >= 2 do
|
|
selected
|
|
else
|
|
MapSet.put(selected, backup_id)
|
|
end
|
|
end
|
|
|
|
{:noreply, assign(socket, :selected_backup_ids, updated_selected)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("compare_selected", _params, socket) do
|
|
device_id = socket.assigns.device.id
|
|
selected = MapSet.to_list(socket.assigns.selected_backup_ids)
|
|
|
|
case selected do
|
|
[backup_a, backup_b] ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:selected_backup_ids, MapSet.new())
|
|
|> push_navigate(to: ~p"/devices/#{device_id}/backups/compare?backup_a=#{backup_a}&backup_b=#{backup_b}")}
|
|
|
|
_ ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("Please select exactly 2 backups to compare"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("clear_selection", _params, socket) do
|
|
{:noreply, assign(socket, :selected_backup_ids, MapSet.new())}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("dismiss_insight", %{"id" => insight_id}, socket) do
|
|
case Towerops.Preseem.dismiss_insight(insight_id) do
|
|
{:ok, _} ->
|
|
{:noreply, assign_preseem_data(socket)}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to dismiss insight"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("delete_backup", %{"id" => backup_id}, socket) do
|
|
import ToweropsWeb.Permissions
|
|
|
|
if owner?(socket) do
|
|
do_delete_backup(backup_id, socket)
|
|
else
|
|
{:noreply, put_flash(socket, :error, t_equipment("Only organization owners can delete backups"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("backup_now", _params, socket) do
|
|
device = socket.assigns.device
|
|
agent_token_id = socket.assigns.agent_info.agent_token_id
|
|
|
|
cond do
|
|
is_nil(agent_token_id) ->
|
|
{:noreply,
|
|
put_flash(socket, :error, t_equipment("Device has no agent assigned. Assign an agent to create backups."))}
|
|
|
|
!device.mikrotik_enabled ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("MikroTik API is not enabled for this device."))}
|
|
|
|
true ->
|
|
trigger_manual_backup(socket, device, agent_token_id)
|
|
end
|
|
end
|
|
|
|
defp reload_snmp_and_ports(socket) do
|
|
device_id = socket.assigns.device.id
|
|
snmp_data = load_snmp_data(device_id)
|
|
|
|
socket
|
|
|> assign(:snmp_interfaces, snmp_data.interfaces)
|
|
|> assign_ports_data()
|
|
end
|
|
|
|
defp trigger_manual_backup(socket, device, agent_token_id) do
|
|
alias Towerops.Agent.AgentJob
|
|
alias Towerops.Agent.MikrotikCommand
|
|
alias Towerops.Agent.MikrotikDevice
|
|
alias Towerops.Devices.BackupRequests
|
|
|
|
job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}"
|
|
mikrotik_config = Devices.get_mikrotik_config(device)
|
|
|
|
# Generate unique filename for this backup (without .rsc extension, RouterOS adds it)
|
|
backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}"
|
|
|
|
# Build chunked read commands
|
|
# Max chunk size is 32KB (32768 bytes), we'll read 10 chunks to cover 320KB
|
|
chunk_size = 32_768
|
|
num_chunks = 10
|
|
|
|
read_commands =
|
|
for i <- 0..(num_chunks - 1) do
|
|
%MikrotikCommand{
|
|
command: "/file/read",
|
|
args: %{
|
|
"file" => "#{backup_filename}.rsc",
|
|
"offset" => to_string(i * chunk_size),
|
|
"chunk-size" => to_string(chunk_size)
|
|
}
|
|
}
|
|
end
|
|
|
|
job = %AgentJob{
|
|
job_id: job_id,
|
|
job_type: :MIKROTIK,
|
|
device_id: device.id,
|
|
mikrotik_device: %MikrotikDevice{
|
|
ip: device.ip_address,
|
|
username: mikrotik_config.username,
|
|
password: mikrotik_config.password,
|
|
port: mikrotik_config.port,
|
|
ssh_port: mikrotik_config.ssh_port,
|
|
use_ssl: mikrotik_config.use_ssl
|
|
},
|
|
mikrotik_commands:
|
|
[
|
|
# Step 1: Export config to file on router (compact format, no defaults)
|
|
%MikrotikCommand{
|
|
command: "/export",
|
|
args: %{"file" => backup_filename, "compact" => ""}
|
|
}
|
|
] ++
|
|
read_commands ++
|
|
[
|
|
# Final step: Delete the temporary file
|
|
%MikrotikCommand{
|
|
command: "/file/remove",
|
|
args: %{"numbers" => "#{backup_filename}.rsc"}
|
|
}
|
|
]
|
|
}
|
|
|
|
case BackupRequests.create_request(device.id, job_id, "manual") do
|
|
{:ok, _request} ->
|
|
Phoenix.PubSub.broadcast(
|
|
Towerops.PubSub,
|
|
"agent:#{agent_token_id}:backup",
|
|
{:backup_requested, job}
|
|
)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t_equipment("Manual backup requested. Results will appear shortly."))
|
|
|> load_equipment_data(device.id)}
|
|
|
|
{:error, _reason} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("Failed to create backup request. Please try again."))}
|
|
end
|
|
end
|
|
|
|
# Private helper for deleting backups
|
|
defp do_delete_backup(backup_id, socket) do
|
|
backup = MikrotikBackups.get_backup!(backup_id)
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
# Verify the backup belongs to a device in the user's organization
|
|
case AccessControl.verify_device_access(backup.device_id, organization.id) do
|
|
{:ok, _device} ->
|
|
case MikrotikBackups.delete_backup(backup) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t_equipment("Backup deleted successfully"))
|
|
|> load_equipment_data(socket.assigns.device.id)}
|
|
|
|
{:error, _changeset} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("Failed to delete backup"))}
|
|
end
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("You don't have permission to access this backup"))}
|
|
end
|
|
end
|
|
|
|
# Apply pagination to backups list if on the backups tab
|
|
defp apply_backups_pagination(socket, "backups", page) when is_map_key(socket.assigns, :mikrotik_backups) do
|
|
per_page = 20
|
|
all_backups = socket.assigns.mikrotik_backups
|
|
total_count = length(all_backups)
|
|
total_pages = ceil(total_count / per_page)
|
|
|
|
# Ensure page is within valid range
|
|
page = max(1, min(page, max(1, total_pages)))
|
|
|
|
# Slice backups for current page
|
|
offset = (page - 1) * per_page
|
|
backups_page = Enum.slice(all_backups, offset, per_page)
|
|
|
|
socket
|
|
|> assign(:mikrotik_backups, backups_page)
|
|
|> assign(:pagination, %{
|
|
page: page,
|
|
per_page: per_page,
|
|
total_count: total_count,
|
|
total_pages: total_pages
|
|
})
|
|
end
|
|
|
|
defp apply_backups_pagination(socket, _tab, _page), do: socket
|
|
|
|
# Checks tab helpers
|
|
|
|
defp group_title(:snmp_sensors), do: "SNMP Sensors"
|
|
defp group_title(:snmp_interfaces), do: "SNMP Interfaces"
|
|
defp group_title(:snmp_processors), do: "SNMP Processors"
|
|
defp group_title(:snmp_storage), do: "SNMP Storage"
|
|
defp group_title(:http_checks), do: "HTTP Checks"
|
|
defp group_title(:tcp_checks), do: "TCP Checks"
|
|
defp group_title(:dns_checks), do: "DNS Checks"
|
|
defp group_title(:ping_checks), do: "Ping Checks"
|
|
defp group_title(:other_checks), do: "Other Checks"
|
|
|
|
defp render_status_badge(0) do
|
|
assigns = %{}
|
|
|
|
~H"""
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
|
OK
|
|
</span>
|
|
"""
|
|
end
|
|
|
|
defp render_status_badge(1) do
|
|
assigns = %{}
|
|
|
|
~H"""
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
|
WARNING
|
|
</span>
|
|
"""
|
|
end
|
|
|
|
defp render_status_badge(2) do
|
|
assigns = %{}
|
|
|
|
~H"""
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
|
|
CRITICAL
|
|
</span>
|
|
"""
|
|
end
|
|
|
|
defp render_status_badge(3) do
|
|
assigns = %{}
|
|
|
|
~H"""
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400">
|
|
UNKNOWN
|
|
</span>
|
|
"""
|
|
end
|
|
|
|
defp render_status_badge(_) do
|
|
assigns = %{}
|
|
|
|
~H"""
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400">
|
|
PENDING
|
|
</span>
|
|
"""
|
|
end
|
|
|
|
defp get_latest_value(check) do
|
|
# Get the latest check result to show current value
|
|
case Monitoring.get_latest_check_result(check.id) do
|
|
nil -> "-"
|
|
result -> format_check_value(result, check)
|
|
end
|
|
end
|
|
|
|
defp format_check_value(%{value: nil}, _check), do: "-"
|
|
|
|
defp format_check_value(%{value: value}, check) when is_number(value) do
|
|
case check.check_type do
|
|
"snmp_sensor" -> format_check_sensor_value(value, check.config)
|
|
"snmp_processor" -> "#{Float.round(value, 1)}%"
|
|
"snmp_storage" -> "#{Float.round(value, 1)}%"
|
|
_ -> value |> Float.round(2) |> to_string()
|
|
end
|
|
end
|
|
|
|
defp format_check_value(_result, _check), do: "-"
|
|
|
|
@sensor_value_formats %{
|
|
"temperature" => {1, "°C"},
|
|
"voltage" => {2, "V"},
|
|
"current" => {2, "A"},
|
|
"power" => {1, "W"},
|
|
"frequency" => {0, "Hz"},
|
|
"humidity" => {1, "%"}
|
|
}
|
|
|
|
defp format_check_sensor_value(value, %{"sensor_type" => "fanspeed", "sensor_unit" => unit}) do
|
|
"#{round(value)}#{unit || " RPM"}"
|
|
end
|
|
|
|
defp format_check_sensor_value(value, config) do
|
|
sensor_type = config["sensor_type"]
|
|
unit = config["sensor_unit"]
|
|
{precision, default_unit} = Map.get(@sensor_value_formats, sensor_type, {2, ""})
|
|
"#{Float.round(value, precision)}#{unit || default_unit}"
|
|
end
|
|
|
|
defp format_relative_time(datetime) do
|
|
alias ToweropsWeb.TimeHelpers
|
|
|
|
TimeHelpers.format_time_ago(datetime)
|
|
end
|
|
|
|
defp format_mrr(nil), do: "$0"
|
|
|
|
defp format_mrr(%Decimal{} = d) do
|
|
"$#{d |> Decimal.round(2) |> Decimal.to_string(:normal)}"
|
|
end
|
|
|
|
defp format_mrr(n) when is_number(n), do: "$#{:erlang.float_to_binary(n / 1, decimals: 2)}"
|
|
defp format_mrr(_), do: "$0"
|
|
|
|
defp config_change_dot_color(event) do
|
|
cond do
|
|
event.change_size > 50 -> "bg-red-500"
|
|
event.change_size > 20 -> "bg-yellow-500"
|
|
true -> "bg-green-500"
|
|
end
|
|
end
|
|
|
|
# Format wireless client data rate (Kbps → Mbps)
|
|
defp format_rate(nil), do: "—"
|
|
|
|
defp format_rate(kbps) when is_integer(kbps) do
|
|
mbps = kbps / 1000
|
|
"#{Float.round(mbps, 1)} Mbps"
|
|
end
|
|
|
|
defp safe_to_integer(value, _default) when is_integer(value), do: value
|
|
|
|
defp safe_to_integer(value, default) when is_binary(value) do
|
|
case Integer.parse(value) do
|
|
{int, _} -> int
|
|
:error -> default
|
|
end
|
|
end
|
|
|
|
defp safe_to_integer(_, default), do: default
|
|
end
|