towerops/lib/towerops_web/live/device_live/show.ex
Graham McIntire 91e3181bbc dialyzer: fix all unmatched_return warnings (154 → 0)
Prefix fire-and-forget calls (PubSub.broadcast, Oban enqueue, Logger,
update_*, etc.) with `_ =` so dialyzer knows the return value is
intentionally discarded. Wrap a few if/case expressions whose
branches produce mixed types the same way.

No behavior changes — only explicit acknowledgement of discarded
returns.

Warnings: 242 → 88.
2026-04-21 10:03:55 -05:00

1214 lines
40 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
require Logger
# 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)
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_event_type(event_type) do
event_type
|> String.replace("_", " ")
|> String.capitalize()
end
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
current_clean = extract_version_number(current)
available_clean = extract_version_number(available)
current_clean && available_clean &&
VersionComparator.newer(current_clean, available_clean)
end
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
# 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
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,
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 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.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."))
|> 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
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(:ssl_checks), do: "SSL Certificate 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)}%"
t when t in ["ping", "http", "tcp", "dns"] -> "#{Float.round(value, 2)} ms"
_ -> 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
@impl true
def terminate(_reason, _socket) do
:ok
end
end