towerops/lib/towerops_web/live/device_live/show.ex
Graham McIntire 26bb3d5ca1
color network map nodes by device status
green for online, yellow for unknown, red for offline. previously
nodes were colored by device type which didn't convey health state.
also remove agent last_seen_at display from device detail page since
the offline badge already communicates agent health.
2026-02-11 15:41:50 -06:00

1290 lines
40 KiB
Elixir

defmodule ToweropsWeb.DeviceLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Agents
alias Towerops.Devices
alias Towerops.Devices.Firmware
alias Towerops.Devices.MikrotikBackups
alias Towerops.Devices.VersionComparator
alias Towerops.Monitoring
alias Towerops.Repo
alias Towerops.Snmp
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}
end
@impl true
def handle_params(%{"id" => id} = params, _, socket) do
organization = socket.assigns.current_scope.organization
# Check if device exists and verify organization access
case AccessControl.verify_device_access(id, organization.id) do
{:ok, _device} ->
maybe_subscribe_and_schedule_refresh(socket, id)
# If no tab parameter, redirect to add it to the URL
if Map.has_key?(params, "tab") do
tab = params["tab"]
page = params |> Map.get("page", "1") |> String.to_integer()
socket =
socket
|> load_equipment_data(id)
|> assign(:active_tab, tab)
|> apply_backups_pagination(tab, page)
{:noreply, socket}
else
# Redirect to add default tab to URL
{:noreply, push_patch(socket, to: ~p"/devices/#{id}?tab=overview")}
end
{:error, :not_found} ->
{:noreply,
socket
|> put_flash(:error, t_equipment("Device not found"))
|> push_navigate(to: ~p"/devices")}
{:error, :unauthorized} ->
{:noreply,
socket
|> put_flash(:error, t_equipment("You don't have access to this device"))
|> push_navigate(to: ~p"/devices")}
end
end
@impl true
def handle_info(:refresh_data, socket) do
# Check if device still exists before scheduling next refresh
case Devices.get_device(socket.assigns.device.id) do
nil ->
# Device was deleted, don't schedule another refresh
{:noreply,
socket
|> put_flash(:error, t_equipment("Device no longer exists"))
|> push_navigate(to: ~p"/devices")}
_device ->
Process.send_after(self(), :refresh_data, 10_000)
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
end
@impl true
def handle_info({:device_status_changed, _device_id, _new_status, _response_time}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:discovery_completed, _device_id}, socket) do
{:noreply,
socket
|> load_equipment_data(socket.assigns.device.id)
|> put_flash(:info, t_equipment("Discovery completed"))}
end
@impl true
def handle_info({:sensors_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:interfaces_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:neighbors_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:arp_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:mac_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:state_sensors_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:processors_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:storage_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:monitoring_check_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:device_event, _event_attrs}, socket) do
# Device event logged (interface changes, sensor thresholds, etc.)
# Reload to update events list
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end
@impl true
def handle_info({:agent_connected, agent_token_id, _organization_id}, socket) do
# Reload if this device's agent just connected
if socket.assigns.agent_info.agent_token_id == agent_token_id do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
else
{:noreply, socket}
end
end
@impl true
def handle_info({:agent_disconnected, agent_token_id, _organization_id}, socket) do
# Reload if this device's agent just disconnected
if socket.assigns.agent_info.agent_token_id == agent_token_id do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
else
{:noreply, socket}
end
end
@impl true
def handle_info({:agent_heartbeat, agent_token_id, _organization_id}, socket) do
# Reload if this device's agent sent a heartbeat (important for stale agents coming back)
if socket.assigns.agent_info.agent_token_id == agent_token_id do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
else
{:noreply, socket}
end
end
@impl true
def handle_info({:agents_stale, stale_agents}, socket) do
# Reload 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, load_equipment_data(socket, socket.assigns.device.id)}
else
{:noreply, socket}
end
end
# Private functions
defp maybe_subscribe_and_schedule_refresh(socket, device_id) do
_ =
if connected?(socket) do
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}")
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
Process.send_after(self(), :refresh_data, 10_000)
end
:ok
end
defp load_equipment_data(socket, device_id) do
device = Devices.get_device!(device_id)
device_with_associations = Repo.preload(device, [:organization, site: [organization: :default_agent_token]])
{agent_token_id, agent_source} = Agents.get_effective_agent_token_with_source(device_with_associations)
agent_info = load_agent_info(agent_token_id, agent_source)
recent_checks = Monitoring.list_devices_checks(device_id, 50)
snmp_data = load_snmp_data(device_id)
events = Devices.list_devices_events(device_id, 100)
neighbors = Snmp.list_neighbors_with_equipment(device_id)
arp_entries = Snmp.list_arp_entries(device_id)
mac_addresses = Snmp.list_mac_addresses(device_id)
vlans = load_vlans(snmp_data.device)
ip_addresses = load_ip_addresses(snmp_data.device)
processors = load_processors(snmp_data.device)
storage = load_storage(snmp_data.device)
latency_chart_data = load_latency_chart_data(device_id)
processor_chart_data = load_processor_chart_data(processors)
memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"])
storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"])
storage_volume_chart_data = load_storage_volume_chart_data(storage)
traffic_chart_data = load_overall_traffic_chart_data(device_id)
# Wireless sensor chart data
wireless_chart_data =
load_sensor_chart_data(device_id, [
"frequency",
"power",
"rssi",
"ccq",
"clients",
"distance",
"noise-floor",
"quality",
"rate",
"utilization"
])
# Filter sensors by type for temperature, voltage, storage, count, and transceivers
# Separate transceiver sensors from general sensors
{transceiver_sensors, non_transceiver_sensors} =
Enum.split_with(snmp_data.sensors, fn sensor ->
sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "sfp")
end)
temperature_sensors =
Enum.filter(non_transceiver_sensors, &temperature_sensor?/1)
voltage_sensors = Enum.filter(non_transceiver_sensors, &voltage_sensor?/1)
current_sensors = Enum.filter(non_transceiver_sensors, &current_sensor?/1)
power_sensors = Enum.filter(non_transceiver_sensors, &power_sensor?/1)
fan_sensors = Enum.filter(non_transceiver_sensors, &fan_sensor?/1)
load_sensors = Enum.filter(non_transceiver_sensors, &load_sensor?/1)
storage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["disk_usage"]))
# Include various counter/gauge sensor types in the Counters section
count_sensors = Enum.filter(non_transceiver_sensors, &counter_sensor?/1)
# Split count sensors into firewall and general
{firewall_counters, general_counters} =
Enum.split_with(count_sensors, &firewall_counter?/1)
# Wireless sensors (frequency, power, rssi, ccq, etc.)
wireless_sensors = Enum.filter(non_transceiver_sensors, &wireless_sensor?/1)
# Signal quality sensors (SNR, RSSI, RSRP, etc.)
signal_sensors = Enum.filter(non_transceiver_sensors, &signal_sensor?/1)
# Group transceiver sensors by transceiver name
grouped_transceivers = group_transceiver_sensors(transceiver_sensors)
# Calculate metrics for dashboard
metrics = calculate_metrics(recent_checks, device)
# Group interfaces by type for the ports tab
interfaces_by_type = group_interfaces_by_type(snmp_data.interfaces)
# Check for available firmware updates
available_firmware = get_available_firmware(snmp_data.device)
# Load MikroTik backups if device has MikroTik API enabled
mikrotik_backups =
if device.mikrotik_enabled do
MikrotikBackups.list_device_backups(device_id, limit: 50)
else
[]
end
socket
|> assign(:page_title, device.name)
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:device, device)
|> assign(:recent_checks, recent_checks)
|> assign(:metrics, metrics)
|> assign(:snmp_device, snmp_data.device)
|> assign(:snmp_interfaces, snmp_data.interfaces)
|> assign(:interfaces_by_type, interfaces_by_type)
|> assign(:snmp_sensors, snmp_data.sensors)
|> assign(:neighbors, neighbors)
|> assign(:arp_entries, arp_entries)
|> assign(:mac_addresses, mac_addresses)
|> assign(:vlans, vlans)
|> assign(:ipv4_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv4")))
|> assign(:ipv6_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv6")))
|> assign(:ip_addresses_by_interface, group_ip_addresses_by_interface(ip_addresses))
|> assign(:processors, processors)
|> assign(:storage, storage)
|> assign(:events, events)
|> 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(:agent_info, agent_info)
|> assign(:available_firmware, available_firmware)
|> assign(:mikrotik_backups, mikrotik_backups)
|> assign(:selected_backup_ids, MapSet.new())
end
defp get_available_firmware(nil), do: nil
defp get_available_firmware(snmp_device) do
vendor = determine_vendor(snmp_device.manufacturer)
product_line = determine_product_line(snmp_device.manufacturer)
# Only check firmware if both vendor and product_line are known
# Currently only MikroTik/RouterOS is supported
if vendor && product_line do
Firmware.get_latest_firmware_release(vendor, product_line)
end
end
defp determine_vendor(manufacturer) when is_binary(manufacturer) do
manufacturer_lower = String.downcase(manufacturer)
cond do
String.contains?(manufacturer_lower, "mikrotik") -> "mikrotik"
String.contains?(manufacturer_lower, "cisco") -> "cisco"
String.contains?(manufacturer_lower, "ubiquiti") -> "ubiquiti"
true -> nil
end
end
defp determine_vendor(_), do: nil
defp determine_product_line(manufacturer) when is_binary(manufacturer) do
manufacturer_lower = String.downcase(manufacturer)
if String.contains?(manufacturer_lower, "mikrotik") do
"routeros"
end
end
defp determine_product_line(_), do: nil
defp firmware_update_available?(nil, _), do: false
defp firmware_update_available?(_, nil), do: false
defp firmware_update_available?(snmp_device, available_firmware) do
current = snmp_device.firmware_version
available = available_firmware.version
# Extract clean version numbers for comparison
# "RouterOS 7.20.6 (Level 6)" -> "7.20.6"
# "7.21.2" -> "7.21.2"
current_clean = extract_version_number(current)
available_clean = extract_version_number(available)
current_clean && available_clean && VersionComparator.newer?(current_clean, available_clean)
end
# Extract semantic version number from version string
# Examples:
# "RouterOS 7.20.6 (Level 6)" -> "7.20.6"
# "7.21.2" -> "7.21.2"
# "v7.14.1" -> "7.14.1"
defp extract_version_number(nil), do: nil
defp extract_version_number(""), do: nil
defp extract_version_number(version_string) when is_binary(version_string) do
# Match semantic version pattern: X.Y or X.Y.Z
case Regex.run(~r/\d+\.\d+(?:\.\d+)?/, version_string) do
[version] -> version
_ -> nil
end
end
defp format_date(nil), do: ""
defp format_date(date) do
Calendar.strftime(date, "%B %-d, %Y")
end
defp load_agent_info(nil, :none) do
%{
agent_token_id: nil,
type: :cloud,
name: "Cloud Polling",
source: nil,
last_seen_at: nil,
is_offline: false
}
end
defp load_agent_info(agent_token_id, source) do
agent_token = Agents.get_agent_token!(agent_token_id)
is_offline = agent_offline?(agent_token)
%{
agent_token_id: agent_token_id,
type: if(agent_token.is_cloud_poller, do: :cloud, else: :agent),
name: agent_token.name,
source: source,
last_seen_at: agent_token.last_seen_at,
is_offline: is_offline
}
rescue
Ecto.NoResultsError ->
# Agent token no longer exists, fall back to cloud polling display
%{
agent_token_id: nil,
type: :cloud,
name: "Cloud Polling (agent not found)",
source: nil,
last_seen_at: nil,
is_offline: false
}
end
# Agent is considered offline if it hasn't been seen in the last 5 minutes
defp agent_offline?(%{is_cloud_poller: true}), do: false
defp agent_offline?(%{last_seen_at: nil}), do: true
defp agent_offline?(%{last_seen_at: last_seen_at}) do
five_minutes_ago = DateTime.add(DateTime.utc_now(), -5, :minute)
DateTime.before?(last_seen_at, five_minutes_ago)
end
defp calculate_metrics(checks, _equipment) do
total_checks = length(checks)
if total_checks > 0 do
successful_checks = Enum.count(checks, &(&1.status == :success))
uptime_percentage = Float.round(successful_checks / total_checks * 100, 1)
%{
uptime_percentage: uptime_percentage,
total_checks: total_checks,
successful_checks: successful_checks
}
else
%{
uptime_percentage: 0,
total_checks: 0,
successful_checks: 0
}
end
end
defp format_speed(speed_bps) when is_integer(speed_bps) do
cond do
speed_bps >= 1_000_000_000 ->
"#{Float.round(speed_bps / 1_000_000_000, 1)} Gbps"
speed_bps >= 1_000_000 ->
"#{Float.round(speed_bps / 1_000_000, 1)} Mbps"
speed_bps >= 1_000 ->
"#{Float.round(speed_bps / 1_000, 1)} Kbps"
true ->
"#{speed_bps} bps"
end
end
defp format_speed(_), do: "-"
defp format_bytes(nil), do: "-"
defp format_bytes(0), do: "0 B"
defp format_bytes(bytes) when is_integer(bytes) do
cond do
bytes >= 1_099_511_627_776 ->
"#{Float.round(bytes / 1_099_511_627_776, 1)} TB"
bytes >= 1_073_741_824 ->
"#{Float.round(bytes / 1_073_741_824, 1)} GB"
bytes >= 1_048_576 ->
"#{Float.round(bytes / 1_048_576, 1)} MB"
bytes >= 1024 ->
"#{Float.round(bytes / 1024, 1)} KB"
true ->
"#{bytes} B"
end
end
defp format_bytes(_), do: "-"
defp format_sensor_value(value, _divisor) when is_number(value) do
# Value is already divided by divisor when stored in sensor_reading
Float.round(value, 1)
end
defp format_sensor_value(_, _), do: "-"
defp time_ago(nil), do: "Never"
defp time_ago(datetime) do
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
diff < 60 -> "#{diff}s ago"
diff < 3600 -> "#{div(diff, 60)}m ago"
diff < 86_400 -> "#{div(diff, 3600)}h ago"
true -> "#{div(diff, 86_400)}d ago"
end
end
defp format_uptime(nil), do: "N/A"
defp format_uptime(timeticks) when is_integer(timeticks) do
# Timeticks are in hundredths of a second
seconds = div(timeticks, 100)
format_duration(seconds)
end
defp format_uptime(_), do: "N/A"
defp format_duration(total_seconds) do
{weeks, remainder} = seconds_to_weeks(total_seconds)
{days, remainder} = seconds_to_days(remainder)
{hours, remainder} = seconds_to_hours(remainder)
{minutes, seconds} = seconds_to_minutes(remainder)
parts =
Enum.reject(
[
format_time_part(weeks, "week"),
format_time_part(days, "day"),
format_time_part(hours, "hour"),
format_time_part(minutes, "minute"),
format_time_part(seconds, "second")
],
&is_nil/1
)
case parts do
[] -> "0 seconds"
parts -> Enum.join(parts, " ")
end
end
@spec seconds_to_weeks(integer()) :: {integer(), integer()}
defp seconds_to_weeks(seconds), do: {div(seconds, 604_800), rem(seconds, 604_800)}
@spec seconds_to_days(integer()) :: {integer(), integer()}
defp seconds_to_days(seconds), do: {div(seconds, 86_400), rem(seconds, 86_400)}
@spec seconds_to_hours(integer()) :: {integer(), integer()}
defp seconds_to_hours(seconds), do: {div(seconds, 3600), rem(seconds, 3600)}
@spec seconds_to_minutes(integer()) :: {integer(), integer()}
defp seconds_to_minutes(seconds), do: {div(seconds, 60), rem(seconds, 60)}
@spec format_time_part(integer(), String.t()) :: String.t() | nil
defp format_time_part(0, _unit), do: nil
defp format_time_part(1, unit), do: "1 #{unit}"
defp format_time_part(count, unit), do: "#{count} #{unit}s"
defp format_device_age(nil), do: "N/A"
defp format_device_age(datetime) do
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
format_duration(diff) <> " ago"
end
defp format_event_type(event_type) do
event_type
|> String.replace("_", " ")
|> String.capitalize()
end
defp load_snmp_data(device_id) do
case Snmp.get_device_with_associations(device_id) do
nil ->
%{device: nil, interfaces: [], sensors: []}
device ->
# Batch load latest readings for all sensors in a single query
sensor_ids = Enum.map(device.sensors, & &1.id)
latest_readings_map = Snmp.get_latest_sensor_readings_batch(sensor_ids)
sensors_with_readings =
Enum.map(device.sensors, fn sensor ->
latest_reading = Map.get(latest_readings_map, sensor.id)
Map.put(sensor, :latest_reading, latest_reading)
end)
# Batch load latest stats for all interfaces in a single query
interface_ids = Enum.map(device.interfaces, & &1.id)
latest_stats_map = Snmp.get_latest_interface_stats_batch(interface_ids)
interfaces_with_stats =
Enum.map(device.interfaces, fn interface ->
latest_stat = Map.get(latest_stats_map, interface.id)
Map.put(interface, :latest_stat, latest_stat)
end)
%{
device: device,
interfaces: interfaces_with_stats,
sensors: sensors_with_readings
}
end
end
defp load_vlans(nil), do: []
defp load_vlans(snmp_device), do: Snmp.list_vlans(snmp_device.id)
defp load_ip_addresses(nil), do: []
defp load_ip_addresses(snmp_device), do: Snmp.list_ip_addresses(snmp_device.id)
defp load_processors(nil), do: []
defp load_processors(snmp_device), do: Snmp.list_processors(snmp_device.id)
defp load_storage(nil), do: []
defp load_storage(snmp_device), do: Snmp.list_storage(snmp_device.id)
defp load_processor_chart_data([]), do: nil
defp load_processor_chart_data(processors) do
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
datasets =
processors
|> Enum.map(&processor_to_chart_dataset(&1, twenty_four_hours_ago))
|> Enum.reject(&Enum.empty?(&1.data))
if Enum.empty?(datasets) do
nil
else
Jason.encode!(%{datasets: datasets})
end
end
defp processor_to_chart_dataset(processor, since) do
readings =
processor.id
|> Snmp.get_processor_readings(since: since, limit: 1000)
|> Enum.reverse()
label =
if processor.description do
processor.description
else
"CPU #{processor.processor_index}"
end
%{
label: label,
data: Enum.map(readings, &processor_reading_to_data_point/1)
}
end
defp processor_reading_to_data_point(reading) do
%{
x: DateTime.to_unix(reading.checked_at, :millisecond),
y: if(reading.load_percent, do: Float.round(reading.load_percent, 1))
}
end
defp load_storage_volume_chart_data([]), do: nil
defp load_storage_volume_chart_data(storage_volumes) do
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
datasets =
storage_volumes
|> Enum.map(&storage_volume_to_chart_dataset(&1, twenty_four_hours_ago))
|> Enum.reject(&Enum.empty?(&1.data))
if Enum.empty?(datasets) do
nil
else
Jason.encode!(%{datasets: datasets})
end
end
defp storage_volume_to_chart_dataset(storage, since) do
readings =
storage.id
|> Snmp.get_storage_readings(since: since, limit: 1000)
|> Enum.reverse()
label = storage.description || "Storage #{storage.storage_index}"
%{
label: label,
data: Enum.map(readings, &storage_reading_to_data_point/1)
}
end
defp storage_reading_to_data_point(reading) do
%{
x: DateTime.to_unix(reading.checked_at, :millisecond),
y: if(reading.usage_percent, do: Float.round(reading.usage_percent, 1))
}
end
defp load_sensor_chart_data(device_id, sensor_types) when is_list(sensor_types) do
case Snmp.get_device_with_associations(device_id) do
nil -> nil
device -> build_sensor_datasets_json(device, sensor_types)
end
end
defp build_sensor_datasets_json(device, sensor_types) do
sensors =
device.sensors
|> Enum.filter(&(&1.sensor_type in sensor_types))
|> Enum.sort_by(& &1.sensor_index)
if Enum.empty?(sensors) do
nil
else
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
datasets = Enum.map(sensors, &sensor_to_chart_dataset(&1, twenty_four_hours_ago))
Jason.encode!(%{datasets: datasets})
end
end
defp sensor_to_chart_dataset(sensor, since) do
readings =
sensor.id
|> Snmp.get_sensor_readings(since: since, limit: 1000)
|> Enum.reverse()
%{
label: sensor.sensor_descr,
data: Enum.map(readings, &reading_to_data_point/1)
}
end
defp reading_to_data_point(reading) do
%{
x: DateTime.to_unix(reading.checked_at, :millisecond),
y: if(reading.value, do: Float.round(reading.value, 1))
}
end
defp load_latency_chart_data(device_id) do
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
checks =
device_id
|> Monitoring.get_latency_data(since: twenty_four_hours_ago, limit: 1000)
|> Enum.reverse()
if Enum.empty?(checks) do
nil
else
dataset = %{
label: "ICMP Latency",
data: Enum.map(checks, &latency_check_to_data_point/1)
}
Jason.encode!(%{datasets: [dataset]})
end
end
defp latency_check_to_data_point(check) do
%{
x: DateTime.to_unix(check.checked_at, :millisecond),
y: check.response_time_ms
}
end
defp load_overall_traffic_chart_data(device_id) do
case Snmp.get_device_with_associations(device_id) do
nil -> nil
device -> build_overall_traffic_chart_json(device)
end
end
defp build_overall_traffic_chart_json(device) do
if Enum.empty?(device.interfaces) do
nil
else
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
all_stats = aggregate_interface_traffic_stats(device.interfaces, twenty_four_hours_ago)
if Enum.empty?(all_stats) do
nil
else
build_traffic_json_datasets(all_stats)
end
end
end
# Calculate BPS per interface first, then aggregate by timestamp
# This avoids issues with misaligned timestamps between interfaces
defp aggregate_interface_traffic_stats(interfaces, since) do
interfaces
|> Enum.flat_map(fn interface ->
interface.id
|> Snmp.get_interface_stats(since: since, limit: 1000)
|> Enum.reverse()
|> calculate_interface_bps()
end)
|> Enum.group_by(& &1.timestamp_ms)
|> Enum.map(fn {ts, bps_list} ->
total_in = Enum.reduce(bps_list, 0.0, fn x, acc -> acc + x.in_bps end)
total_out = Enum.reduce(bps_list, 0.0, fn x, acc -> acc + x.out_bps end)
%{timestamp_ms: ts, in_bps: total_in, out_bps: total_out}
end)
|> Enum.sort_by(& &1.timestamp_ms)
end
# Calculate BPS for a single interface's stats
defp calculate_interface_bps(stats) do
stats
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [s1, s2] ->
time_diff = s2.checked_at |> DateTime.diff(s1.checked_at, :second) |> max(1)
in_bps =
if s2.if_in_octets && s1.if_in_octets do
max((s2.if_in_octets - s1.if_in_octets) * 8 / time_diff, 0)
else
0.0
end
out_bps =
if s2.if_out_octets && s1.if_out_octets do
max((s2.if_out_octets - s1.if_out_octets) * 8 / time_diff, 0)
else
0.0
end
# Round timestamp to nearest minute for aggregation (avoids microsecond misalignment)
ts_seconds = DateTime.to_unix(s2.checked_at)
rounded_ts_ms = div(ts_seconds, 60) * 60 * 1000
%{timestamp_ms: rounded_ts_ms, in_bps: in_bps, out_bps: out_bps}
end)
end
defp build_traffic_json_datasets(all_stats) do
in_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: Float.round(s.in_bps, 2)} end)
out_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: -Float.round(s.out_bps, 2)} end)
datasets = [
%{label: "Outbound", data: out_data},
%{label: "Inbound", data: in_data}
]
Jason.encode!(%{datasets: datasets})
end
# Group interfaces by type for organized display
defp group_interfaces_by_type(interfaces) do
interfaces
|> Enum.group_by(&get_interface_type_category(&1.if_type))
|> Enum.map(fn {category, interfaces} ->
{category, Enum.sort_by(interfaces, & &1.if_index)}
end)
|> Enum.sort_by(fn {category, _} -> interface_type_order(category) end)
end
# Map SNMP interface types to human-readable categories
defp get_interface_type_category(if_type) when is_integer(if_type) do
Map.get(@interface_type_categories, if_type, "Other")
end
defp get_interface_type_category(_), do: "Other"
# Define display order for interface categories
defp interface_type_order(category) do
Map.get(@interface_category_order, category, 99)
end
# Group transceiver sensors by transceiver name
# Returns list of {name, sensors} tuples sorted by name
defp group_transceiver_sensors(transceiver_sensors) do
transceiver_sensors
|> Enum.group_by(&extract_transceiver_name/1)
|> Enum.sort_by(fn {name, _sensors} -> name end)
end
# Extract transceiver name from sensor description
# Examples:
# "sfp-sfpplus1 Rx Power" -> "sfp-sfpplus1"
# "sfp-sfpplus2 Temperature" -> "sfp-sfpplus2"
# "SFP1 Voltage" -> "SFP1"
defp extract_transceiver_name(sensor) do
case sensor.sensor_descr do
nil ->
"unknown"
descr ->
# Extract the first word (transceiver identifier)
descr
|> String.split(" ")
|> List.first()
|> String.downcase()
end
end
# Group IP addresses by interface ID for efficient lookup in templates
defp group_ip_addresses_by_interface(ip_addresses) do
Enum.group_by(ip_addresses, & &1.snmp_interface_id)
end
# Sensor type classification helpers
# Trust sensor_type first (set during SNMP discovery based on OID)
# Only fall back to unit/description matching if sensor_type is missing/unknown
defp temperature_sensor?(sensor) do
# Primary: Check sensor_type (set during discovery based on OID)
type_match = sensor.sensor_type in ["temperature", "cpu_temperature", "celsius"]
# Fallback: Check unit for devices that don't set sensor_type correctly
unit_match = sensor.sensor_unit in ["°C", "C", "celsius"]
# Only use description as last resort if type and unit don't match
# and only for unambiguous temperature keywords
descr_match =
!type_match && !unit_match &&
sensor.sensor_descr &&
String.contains?(String.downcase(sensor.sensor_descr), "temperature") &&
!String.contains?(String.downcase(sensor.sensor_descr), "voltage")
(type_match || unit_match || descr_match) && !voltage_by_description?(sensor)
end
defp voltage_sensor?(sensor) do
type_match = sensor.sensor_type in ["voltage", "volts"]
unit_match = sensor.sensor_unit in ["V", "mV", "volts"]
descr_match = voltage_by_description?(sensor)
type_match || unit_match || descr_match
end
defp voltage_by_description?(sensor) do
sensor.sensor_descr &&
String.contains?(String.downcase(sensor.sensor_descr), "voltage")
end
defp wireless_sensor?(sensor) do
sensor.sensor_type in [
"frequency",
"power",
"rssi",
"ccq",
"clients",
"distance",
"noise-floor",
"quality",
"rate",
"utilization"
]
end
# Counter sensors include various count and gauge types
# (connections, sessions, clients, DHCP leases, etc.)
defp counter_sensor?(sensor) do
sensor.sensor_type in [
"count",
"pppoe_sessions",
"connections",
"clients",
"leases",
"sessions"
]
end
# Firewall counters are connection-related metrics
defp firewall_counter?(sensor) do
descr = String.downcase(sensor.sensor_descr || "")
String.contains?(descr, "connection")
end
# Current sensors (amperes)
defp current_sensor?(sensor) do
type_match = sensor.sensor_type in ["current", "amperes"]
unit_match = sensor.sensor_unit in ["A", "mA", "amperes"]
type_match || unit_match
end
# Power sensors (watts) - separate from wireless power
defp power_sensor?(sensor) do
type_match = sensor.sensor_type in ["power", "watts"]
unit_match = sensor.sensor_unit in ["W", "mW", "watts"]
# Exclude wireless power sensors (those are in wireless category)
(type_match || unit_match) && !wireless_sensor?(sensor)
end
# Fan speed sensors (RPM)
defp fan_sensor?(sensor) do
type_match = sensor.sensor_type in ["fanspeed", "rpm"]
unit_match = sensor.sensor_unit in ["RPM", "rpm"]
descr_match = sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "fan")
type_match || unit_match || descr_match
end
# System load sensors (CPU load, etc.)
defp load_sensor?(sensor) do
type_match = sensor.sensor_type in ["load", "cpu_load", "system_load"]
descr_match = sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "load")
type_match || descr_match
end
# Signal quality sensors (SNR, RSSI, RSRP, etc.)
defp signal_sensor?(sensor) do
sensor.sensor_type in [
"snr",
"rssi",
"rsrp",
"rsrq",
"sinr",
"ssr",
"mse",
"noise",
"noise-floor",
"dbm"
]
end
@impl true
def handle_event("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("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 trigger_manual_backup(socket, device, agent_token_id) do
alias Towerops.Agent.AgentJob
alias Towerops.Agent.MikrotikCommand
alias Towerops.Agent.MikrotikDevice
alias Towerops.Devices.BackupRequests
job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}"
mikrotik_config = Devices.get_mikrotik_config(device)
# Generate unique filename for this backup (without .rsc extension, RouterOS adds it)
backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}"
# Build chunked read commands
# Max chunk size is 32KB (32768 bytes), we'll read 10 chunks to cover 320KB
chunk_size = 32_768
num_chunks = 10
read_commands =
for i <- 0..(num_chunks - 1) do
%MikrotikCommand{
command: "/file/read",
args: %{
"file" => "#{backup_filename}.rsc",
"offset" => to_string(i * chunk_size),
"chunk-size" => to_string(chunk_size)
}
}
end
job = %AgentJob{
job_id: job_id,
job_type: :MIKROTIK,
device_id: device.id,
mikrotik_device: %MikrotikDevice{
ip: device.ip_address,
username: mikrotik_config.username,
password: mikrotik_config.password,
port: mikrotik_config.port,
ssh_port: mikrotik_config.ssh_port,
use_ssl: mikrotik_config.use_ssl
},
mikrotik_commands:
[
# Step 1: Export config to file on router (compact format, no defaults)
%MikrotikCommand{
command: "/export",
args: %{"file" => backup_filename, "compact" => ""}
}
] ++
read_commands ++
[
# Final step: Delete the temporary file
%MikrotikCommand{
command: "/file/remove",
args: %{"numbers" => "#{backup_filename}.rsc"}
}
]
}
case BackupRequests.create_request(device.id, job_id, "manual") do
{:ok, _request} ->
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agent:#{agent_token_id}:backup",
{:backup_requested, job}
)
{:noreply,
socket
|> put_flash(:info, t_equipment("Manual backup requested. Results will appear shortly."))
|> load_equipment_data(device.id)}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, t_equipment("Failed to create backup request. Please try again."))}
end
end
# Private helper for deleting backups
defp do_delete_backup(backup_id, socket) do
backup = MikrotikBackups.get_backup!(backup_id)
organization = socket.assigns.current_scope.organization
# Verify the backup belongs to a device in the user's organization
case AccessControl.verify_device_access(backup.device_id, organization.id) do
{:ok, _device} ->
case MikrotikBackups.delete_backup(backup) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t_equipment("Backup deleted successfully"))
|> load_equipment_data(socket.assigns.device.id)}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, t_equipment("Failed to delete backup"))}
end
{:error, _} ->
{:noreply, put_flash(socket, :error, t_equipment("You don't have permission to access this backup"))}
end
end
# Apply pagination to backups list if on the backups tab
defp apply_backups_pagination(socket, "backups", page) when is_map_key(socket.assigns, :mikrotik_backups) do
per_page = 20
all_backups = socket.assigns.mikrotik_backups
total_count = length(all_backups)
total_pages = ceil(total_count / per_page)
# Ensure page is within valid range
page = max(1, min(page, max(1, total_pages)))
# Slice backups for current page
offset = (page - 1) * per_page
backups_page = Enum.slice(all_backups, offset, per_page)
socket
|> assign(:mikrotik_backups, backups_page)
|> assign(:pagination, %{
page: page,
per_page: per_page,
total_count: total_count,
total_pages: total_pages
})
end
defp apply_backups_pagination(socket, _tab, _page), do: socket
end