towerops/lib/towerops_web/live/device_live/show.ex
Graham McIntire 3bf21c746f refactor: upgrade to Elixir 1.20, extract GraphLive.Polling, async subscriber impact
- Bump elixir requirement from ~> 1.19 to ~> 1.20 for gradual typing
- Extract SNMP polling functions from graph_live/show.ex (1492→1118 lines)
  into new GraphLive.Polling module (363 lines) — 374 lines net reduction
- Defer subscriber impact API call via start_async/handle_async in
  device_live/show.ex, avoiding synchronous Gaiia API call in handle_params
- Add terminate/2 to graph_live/show.ex for live_poll timer cleanup
- Fix .tool-versions to match installed Erlang 27.3.4.2
- Relax AGENTS.md dialyzer policy: allow ignore file for deps/vendored code
2026-07-22 11:01:00 -05:00

1150 lines
38 KiB
Elixir

defmodule ToweropsWeb.DeviceLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
import ToweropsWeb.Permissions
alias Towerops.Agents
alias Towerops.Devices
alias Towerops.Devices.BackupRequests
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
alias ToweropsWeb.TimeHelpers
# 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)
|> assign(:subscriber_impact, nil)}
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}
@impl true
def handle_async(:subscriber_impact, {:ok, impact}, socket) do
{:noreply, assign(socket, :subscriber_impact, impact)}
end
def handle_async(:subscriber_impact, {:exit, _reason}, socket) do
{:noreply, assign(socket, :subscriber_impact, %{subscriber_count: 0, mrr: nil, accounts: []})}
end
# 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(:can_view_financials, can_view_financials?(socket))
|> start_async(:subscriber_impact, fn ->
try do
Towerops.Gaiia.get_device_impact(device.id)
rescue
_ -> %{subscriber_count: 0, mrr: nil, accounts: []}
end
end)
end
end
defp can_view_financials?(socket) do
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_volume_chart_data = ChartBuilders.load_storage_volume_chart_data(storage)
traffic_chart_data = ChartBuilders.load_overall_traffic_chart_data(snmp_device)
# 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)
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)
grouped_transceivers = SensorClassifiers.group_transceiver_sensors(transceiver_sensors)
available_firmware = DataLoaders.get_available_firmware(snmp_device)
socket
|> 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_volume_chart_data, storage_volume_chart_data)
|> assign(:traffic_chart_data, traffic_chart_data)
|> assign(:temperature_sensors, temperature_sensors)
|> assign(:voltage_sensors, voltage_sensors)
|> assign(:storage_sensors, storage_sensors)
|> assign(:general_counters, general_counters)
|> assign(:firewall_counters, firewall_counters)
|> assign(:wireless_sensors, wireless_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
# Batch query utilization for all interfaces in a single query (fixes N+1)
utilization_by_id = Towerops.Capacity.get_utilization_for_interfaces(interfaces)
interfaces_with_utilization =
Enum.map(interfaces, fn interface ->
utilization = Map.get(utilization_by_id, interface.id)
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
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
Snmp.interface_belongs_to_organization?(interface_id, org_id)
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
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
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