Removes unreachable catch-all clauses, drops unused `require Logger` lines, adds pin operators to bitstring size patterns, reorders function heads for correct dispatch, and replaces deprecated LoggerBackends calls.
1197 lines
39 KiB
Elixir
1197 lines
39 KiB
Elixir
defmodule ToweropsWeb.DeviceLive.Show do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Agents
|
|
alias Towerops.Devices
|
|
alias Towerops.Devices.MikrotikBackups
|
|
alias Towerops.Devices.VersionComparator
|
|
alias Towerops.Monitoring
|
|
alias Towerops.Snmp
|
|
alias Towerops.Workers.DiscoveryWorker
|
|
alias ToweropsWeb.CheckLive.FormComponent
|
|
alias ToweropsWeb.DeviceLive.Helpers.ChartBuilders
|
|
alias ToweropsWeb.DeviceLive.Helpers.DataLoaders
|
|
alias ToweropsWeb.DeviceLive.Helpers.Formatters
|
|
alias ToweropsWeb.DeviceLive.Helpers.SensorClassifiers
|
|
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
|
|
}
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
{:ok,
|
|
socket
|
|
|> assign(:show_check_form, false)
|
|
|> assign(:edit_check, nil)
|
|
|> assign(:subscribed, 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} ->
|
|
socket = 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
|
|
|> assign_base_data(id)
|
|
|> assign_tab_nav_data(id)
|
|
|> assign(:active_tab, tab)
|
|
|> reload_current_tab_data(id)
|
|
|> 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
|
|
device_id = socket.assigns.device.id
|
|
|
|
# Schedule next refresh
|
|
Process.send_after(self(), :refresh_data, 10_000)
|
|
|
|
# Check if device still exists and reload
|
|
case Devices.get_device(device_id) do
|
|
nil ->
|
|
# Device was deleted between refreshes
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, t_equipment("Device no longer exists"))
|
|
|> push_navigate(to: ~p"/devices")}
|
|
|
|
_device ->
|
|
# Reload data (assign_base_data will fetch the device with full associations)
|
|
{:noreply,
|
|
socket
|
|
|> assign_base_data(device_id)
|
|
|> assign_tab_nav_data(device_id)
|
|
|> reload_current_tab_data(device_id)}
|
|
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 (new interfaces, sensors, etc.)
|
|
device_id = socket.assigns.device.id
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign_base_data(device_id)
|
|
|> assign_tab_nav_data(device_id)
|
|
|> reload_current_tab_data(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({:transceivers_updated, _device_id}, socket) do
|
|
{:noreply, reload_if_tab(socket, ["transceivers"])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:printer_supplies_updated, _device_id}, socket) do
|
|
{:noreply, reload_if_tab(socket, ["printer_supplies"])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:hardware_inventory_updated, _device_id}, socket) do
|
|
{:noreply, reload_if_tab(socket, ["hardware"])}
|
|
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({:alert_changed, _organization_id}, socket) do
|
|
# Alert changed in this organization - we don't show alerts on device page
|
|
# so just ignore this message
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({FormComponent, {:check_created, _check}}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_check_form, false)
|
|
|> assign(:edit_check, nil)
|
|
|> put_flash(:info, t("Check created successfully"))
|
|
|> assign_checks_data(socket.assigns.device.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({FormComponent, {:check_updated, _check}}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_check_form, false)
|
|
|> assign(:edit_check, nil)
|
|
|> put_flash(:info, t("Check updated successfully"))
|
|
|> assign_checks_data(socket.assigns.device.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({FormComponent, :close}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_check_form, false)
|
|
|> assign(:edit_check, nil)}
|
|
end
|
|
|
|
def handle_info(_msg, socket), do: {:noreply, socket}
|
|
|
|
# Private functions
|
|
|
|
defp maybe_subscribe_and_schedule_refresh(socket, device_id) do
|
|
if connected?(socket) and not socket.assigns.subscribed 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)
|
|
assign(socket, :subscribed, true)
|
|
else
|
|
socket
|
|
end
|
|
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 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",
|
|
"transceivers",
|
|
"printer_supplies",
|
|
"hardware"
|
|
]
|
|
|
|
# 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)
|
|
"transceivers" -> assign_transceivers_data(socket, device_id)
|
|
"printer_supplies" -> assign_printer_supplies_data(socket, device_id)
|
|
"hardware" -> assign_hardware_inventory_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
|
|
case Devices.get_device(device_id) do
|
|
nil ->
|
|
# Device was deleted between access check and fetch (race condition)
|
|
raise Ecto.NoResultsError, queryable: Towerops.Devices.DeviceSchema
|
|
|
|
device ->
|
|
device_with_associations = Devices.get_device_with_agent_info(device)
|
|
|
|
{agent_token_id, agent_source} =
|
|
Agents.get_effective_agent_token_with_source(device_with_associations)
|
|
|
|
agent_info = DataLoaders.load_agent_info(agent_token_id, agent_source)
|
|
snmp_data = DataLoaders.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)
|
|
|> assign_subscriber_impact()
|
|
|> assign(:can_view_financials, can_view_financials?(socket))
|
|
end
|
|
end
|
|
|
|
defp assign_subscriber_impact(socket) do
|
|
device = socket.assigns.device
|
|
|
|
impact =
|
|
try do
|
|
Towerops.Gaiia.get_device_impact(device.id)
|
|
rescue
|
|
_ -> %{subscriber_count: 0, mrr: nil, accounts: []}
|
|
end
|
|
|
|
assign(socket, :subscriber_impact, impact)
|
|
end
|
|
|
|
defp can_view_financials?(socket) do
|
|
import ToweropsWeb.Permissions
|
|
|
|
owner?(socket) || admin?(socket)
|
|
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 = DataLoaders.load_neighbors(device_id)
|
|
arp_entries = DataLoaders.load_arp_entries(device_id)
|
|
mac_addresses = DataLoaders.load_mac_addresses(device_id)
|
|
vlans = DataLoaders.load_vlans(snmp_device)
|
|
ip_addresses = DataLoaders.load_ip_addresses(snmp_device)
|
|
|
|
# Load data needed for tab visibility decisions
|
|
gaiia_item = Towerops.Gaiia.get_inventory_item_for_device(device_id)
|
|
preseem_access_point = Towerops.Preseem.get_access_point_for_device(device_id)
|
|
wireless_data = DataLoaders.load_wireless_data(device_id)
|
|
transceiver_data = DataLoaders.load_transceivers(snmp_device)
|
|
printer_data = DataLoaders.load_printer_supplies(snmp_device)
|
|
hardware_data = DataLoaders.load_hardware_inventory(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, DataLoaders.group_ip_addresses_by_interface(ip_addresses))
|
|
|> assign(:gaiia_item, gaiia_item)
|
|
|> assign(:preseem_access_point, preseem_access_point)
|
|
|> assign(:wireless_clients_available, wireless_data.wireless_clients_available)
|
|
|> assign(:wireless_client_count, wireless_data.wireless_client_count)
|
|
|> assign(:wireless_last_updated, wireless_data.wireless_last_updated)
|
|
|> assign(:transceivers_available, transceiver_data.transceivers_available)
|
|
|> assign(:printer_supplies_available, printer_data.printer_supplies_available)
|
|
|> assign(:hardware_inventory_available, hardware_data.hardware_inventory_available)
|
|
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 = DataLoaders.load_processors(snmp_device)
|
|
storage = DataLoaders.load_storage(snmp_device)
|
|
latency_chart_data = ChartBuilders.load_latency_chart_data(device_id)
|
|
processor_chart_data = ChartBuilders.load_processor_chart_data(processors)
|
|
memory_chart_data = ChartBuilders.load_sensor_chart_data(snmp_device, ["memory_usage"])
|
|
storage_chart_data = ChartBuilders.load_sensor_chart_data(snmp_device, ["disk_usage"])
|
|
storage_volume_chart_data = ChartBuilders.load_storage_volume_chart_data(storage)
|
|
traffic_chart_data = ChartBuilders.load_overall_traffic_chart_data(snmp_device)
|
|
|
|
wireless_chart_data =
|
|
ChartBuilders.load_sensor_chart_data(snmp_device, [
|
|
"frequency",
|
|
"power",
|
|
"rssi",
|
|
"ccq",
|
|
"clients",
|
|
"distance",
|
|
"noise-floor",
|
|
"quality",
|
|
"rate",
|
|
"utilization"
|
|
])
|
|
|
|
# Classify sensors by type
|
|
{transceiver_sensors, non_transceiver_sensors} =
|
|
SensorClassifiers.split_transceiver_sensors(sensors)
|
|
|
|
temperature_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.temperature_sensor?/1)
|
|
voltage_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.voltage_sensor?/1)
|
|
current_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.current_sensor?/1)
|
|
power_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.power_sensor?/1)
|
|
fan_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.fan_sensor?/1)
|
|
load_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.load_sensor?/1)
|
|
storage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["disk_usage"]))
|
|
count_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.counter_sensor?/1)
|
|
|
|
{firewall_counters, general_counters} =
|
|
Enum.split_with(count_sensors, &SensorClassifiers.firewall_counter?/1)
|
|
|
|
wireless_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.wireless_sensor?/1)
|
|
signal_sensors = Enum.filter(non_transceiver_sensors, &SensorClassifiers.signal_sensor?/1)
|
|
grouped_transceivers = SensorClassifiers.group_transceiver_sensors(transceiver_sensors)
|
|
metrics = DataLoaders.calculate_metrics([], device)
|
|
available_firmware = DataLoaders.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, DataLoaders.load_connected_devices(device_id))
|
|
|> assign(:recent_config_changes, DataLoaders.load_recent_config_changes(device))
|
|
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 = DataLoaders.load_device_events(device_id)
|
|
assign(socket, :events, events)
|
|
end
|
|
|
|
# Backups tab data.
|
|
defp assign_backups_data(socket, _device_id) do
|
|
device = socket.assigns.device
|
|
mikrotik_backups = DataLoaders.load_mikrotik_backups(device)
|
|
|
|
socket
|
|
|> assign(:mikrotik_backups, mikrotik_backups)
|
|
|> assign(:selected_backup_ids, MapSet.new())
|
|
end
|
|
|
|
# Checks tab data.
|
|
defp assign_checks_data(socket, device_id) do
|
|
checks_data = DataLoaders.load_checks_data(device_id, socket.assigns.device.organization_id)
|
|
|
|
socket
|
|
|> assign(:checks, checks_data.checks)
|
|
|> assign(:grouped_checks, checks_data.grouped_checks)
|
|
end
|
|
|
|
# Gaiia tab data.
|
|
defp assign_gaiia_data(socket) do
|
|
device = socket.assigns.device
|
|
gaiia_data = DataLoaders.load_gaiia_data(device)
|
|
|
|
socket
|
|
|> assign(:gaiia_item, gaiia_data.gaiia_item)
|
|
|> assign(:gaiia_account, gaiia_data.gaiia_account)
|
|
|> assign(:gaiia_subscriptions, gaiia_data.gaiia_subscriptions)
|
|
|> assign(:gaiia_network_site, gaiia_data.gaiia_network_site)
|
|
end
|
|
|
|
# Preseem tab data.
|
|
defp assign_preseem_data(socket) do
|
|
device = socket.assigns.device
|
|
preseem_data = DataLoaders.load_preseem_data(device)
|
|
|
|
socket
|
|
|> assign(:preseem_access_point, preseem_data.preseem_access_point)
|
|
|> assign(:preseem_metrics, preseem_data.preseem_metrics)
|
|
|> assign(:preseem_insights, preseem_data.preseem_insights)
|
|
end
|
|
|
|
# Wireless clients tab data.
|
|
defp assign_wireless_data(socket, device_id) do
|
|
wireless_data = DataLoaders.load_wireless_data(device_id)
|
|
|
|
socket
|
|
|> assign(:wireless_clients, wireless_data.wireless_clients)
|
|
|> assign(:wireless_client_subscribers, wireless_data.wireless_client_subscribers)
|
|
|> assign(:wireless_client_count, wireless_data.wireless_client_count)
|
|
|> assign(:wireless_matched_count, wireless_data.wireless_matched_count)
|
|
|> assign(:wireless_last_updated, wireless_data.wireless_last_updated)
|
|
|> assign(:wireless_clients_available, wireless_data.wireless_clients_available)
|
|
end
|
|
|
|
# Transceivers tab data.
|
|
defp assign_transceivers_data(socket, _device_id) do
|
|
snmp_device = socket.assigns.snmp_device
|
|
transceiver_data = DataLoaders.load_transceivers(snmp_device)
|
|
|
|
socket
|
|
|> assign(:transceivers, transceiver_data.transceivers)
|
|
|> assign(:transceivers_available, transceiver_data.transceivers_available)
|
|
end
|
|
|
|
# Printer supplies tab data.
|
|
defp assign_printer_supplies_data(socket, _device_id) do
|
|
snmp_device = socket.assigns.snmp_device
|
|
printer_data = DataLoaders.load_printer_supplies(snmp_device)
|
|
|
|
socket
|
|
|> assign(:printer_supplies, printer_data.printer_supplies)
|
|
|> assign(:printer_supplies_available, printer_data.printer_supplies_available)
|
|
end
|
|
|
|
# Hardware inventory tab data.
|
|
defp assign_hardware_inventory_data(socket, _device_id) do
|
|
snmp_device = socket.assigns.snmp_device
|
|
hardware_data = DataLoaders.load_hardware_inventory(snmp_device)
|
|
|
|
socket
|
|
|> assign(:hardware_inventory, hardware_data.hardware_inventory)
|
|
|> assign(:hardware_inventory_available, hardware_data.hardware_inventory_available)
|
|
end
|
|
|
|
# Formatting delegation wrappers for template compatibility
|
|
# These delegate to Formatters module - templates will be updated in next task
|
|
defp format_date(date), do: Formatters.format_date(date)
|
|
defp format_speed(speed_bps), do: Formatters.format_speed(speed_bps)
|
|
defp format_bytes(bytes), do: Formatters.format_bytes(bytes)
|
|
defp format_sensor_value(value, divisor), do: Formatters.format_sensor_value(value, divisor)
|
|
defp format_location(location), do: Formatters.format_location(location)
|
|
defp format_uptime(timeticks), do: Formatters.format_uptime(timeticks)
|
|
defp format_device_age(datetime), do: Formatters.format_device_age(datetime)
|
|
defp time_ago(datetime), do: Formatters.time_ago(datetime)
|
|
defp capacity_source_label(source), do: Formatters.capacity_source_label(source)
|
|
defp capacity_source_class(source), do: Formatters.capacity_source_class(source)
|
|
defp utilization_color(pct), do: Formatters.utilization_color(pct)
|
|
|
|
# Additional template-used formatting functions (not yet in Formatters module)
|
|
@doc false
|
|
def utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400"
|
|
def utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400"
|
|
def utilization_text_color(_pct), do: "text-green-600 dark:text-green-400"
|
|
|
|
@doc false
|
|
def format_event_type(event_type) do
|
|
event_type
|
|
|> String.replace("_", " ")
|
|
|> String.capitalize()
|
|
end
|
|
|
|
@doc false
|
|
def firmware_update_available?(nil, _), do: false
|
|
def firmware_update_available?(_, nil), do: false
|
|
|
|
def firmware_update_available?(snmp_device, available_firmware) do
|
|
current = snmp_device.firmware_version
|
|
available = available_firmware.version
|
|
|
|
current_clean = extract_version_number(current)
|
|
available_clean = extract_version_number(available)
|
|
|
|
current_clean && available_clean &&
|
|
VersionComparator.newer(current_clean, available_clean)
|
|
end
|
|
|
|
@doc false
|
|
def extract_version_number(nil), do: nil
|
|
def extract_version_number(""), do: nil
|
|
|
|
def extract_version_number(version_string) when is_binary(version_string) do
|
|
case Regex.run(~r/\d+\.\d+(?:\.\d+)?/, version_string) do
|
|
[version] -> version
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
def extract_version_number(_), do: nil
|
|
|
|
# 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
|
|
|
|
@impl true
|
|
def handle_event("set_capacity", %{"interface_id" => id, "capacity_mbps" => mbps_str}, socket) do
|
|
org_id = socket.assigns.current_scope.organization.id
|
|
|
|
with {:ok, mbps} <- parse_capacity(mbps_str),
|
|
true <- interface_belongs_to_org?(id, org_id) do
|
|
bps = round(mbps * 1_000_000)
|
|
|
|
case Snmp.set_manual_capacity(id, bps) do
|
|
{:ok, _} ->
|
|
{:noreply, put_flash(reload_snmp_and_ports(socket), :info, t("Capacity updated"))}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to update capacity"))}
|
|
end
|
|
else
|
|
{:error, :invalid} -> {:noreply, put_flash(socket, :error, t("Invalid capacity value"))}
|
|
false -> {:noreply, put_flash(socket, :error, t("Interface not found"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("clear_capacity", %{"interface_id" => id}, socket) do
|
|
org_id = socket.assigns.current_scope.organization.id
|
|
|
|
if interface_belongs_to_org?(id, org_id) 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
|
|
else
|
|
{:noreply, put_flash(socket, :error, t("Interface not found"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("add_check", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_check_form, true)
|
|
|> assign(:edit_check, nil)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("edit_check", %{"id" => check_id}, socket) do
|
|
case Monitoring.get_check(check_id) do
|
|
nil ->
|
|
{:noreply, put_flash(socket, :error, t("Check not found"))}
|
|
|
|
check ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:show_check_form, true)
|
|
|> assign(:edit_check, check)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("delete_check", %{"id" => check_id}, socket) do
|
|
case Monitoring.get_check(check_id) do
|
|
nil ->
|
|
{:noreply, put_flash(socket, :error, t("Check not found"))}
|
|
|
|
check ->
|
|
case Monitoring.delete_check(check) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Check deleted"))
|
|
|> assign_checks_data(socket.assigns.device.id)}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to delete check"))}
|
|
end
|
|
end
|
|
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 parse_capacity(mbps_str) do
|
|
case Float.parse(mbps_str) do
|
|
{mbps, _} when mbps > 0 -> {:ok, mbps}
|
|
_ -> {:error, :invalid}
|
|
end
|
|
end
|
|
|
|
defp interface_belongs_to_org?(interface_id, org_id) do
|
|
{:ok, iid} = Ecto.UUID.dump(interface_id)
|
|
{:ok, oid} = Ecto.UUID.dump(org_id)
|
|
|
|
result =
|
|
Towerops.Repo.query!(
|
|
"""
|
|
SELECT 1 FROM snmp_interfaces i
|
|
JOIN snmp_devices sd ON sd.id = i.snmp_device_id
|
|
JOIN devices d ON d.id = sd.device_id
|
|
WHERE i.id = $1 AND d.organization_id = $2
|
|
LIMIT 1
|
|
""",
|
|
[iid, oid]
|
|
)
|
|
|
|
result.num_rows > 0
|
|
end
|
|
|
|
defp reload_snmp_and_ports(socket) do
|
|
device_id = socket.assigns.device.id
|
|
snmp_data = DataLoaders.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.Devices.BackupRequests
|
|
|
|
job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}"
|
|
|
|
case BackupRequests.create_request(device.id, job_id, "manual") do
|
|
{:ok, _request} ->
|
|
# Send device id + job id over PubSub — agent channel resolves
|
|
# credentials itself to avoid exposing them on PubSub.
|
|
_ =
|
|
Phoenix.PubSub.broadcast(
|
|
Towerops.PubSub,
|
|
"agent:#{agent_token_id}:backup",
|
|
{:backup_requested, device.id, job_id}
|
|
)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t_equipment("Manual backup requested. Results will appear shortly."))
|
|
|> assign_backups_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"))
|
|
|> assign_backups_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
|
|
|
|
@doc false
|
|
def group_title(:snmp_sensors), do: "SNMP Sensors"
|
|
def group_title(:snmp_interfaces), do: "SNMP Interfaces"
|
|
def group_title(:snmp_processors), do: "SNMP Processors"
|
|
def group_title(:snmp_storage), do: "SNMP Storage"
|
|
def group_title(:http_checks), do: "HTTP Checks"
|
|
def group_title(:tcp_checks), do: "TCP Checks"
|
|
def group_title(:dns_checks), do: "DNS Checks"
|
|
def group_title(:ssl_checks), do: "SSL Certificate Checks"
|
|
def group_title(:ping_checks), do: "Ping Checks"
|
|
def 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
|
|
|
|
@doc false
|
|
def format_check_value(%{value: nil}, _check), do: "-"
|
|
|
|
def 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, 1)}%"
|
|
"snmp_storage" -> "#{Float.round(value / 1, 1)}%"
|
|
t when t in ["ping", "http", "tcp", "dns"] -> "#{Float.round(value / 1, 2)} ms"
|
|
_ -> value |> Kernel./(1) |> Float.round(2) |> to_string()
|
|
end
|
|
end
|
|
|
|
def 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(%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
|
|
|
|
@impl true
|
|
def terminate(_reason, _socket) do
|
|
:ok
|
|
end
|
|
end
|