defmodule ToweropsWeb.DeviceLive.Show do @moduledoc false use ToweropsWeb, :live_view alias Towerops.Devices alias Towerops.Monitoring alias Towerops.Snmp # 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 # Check if device exists before setting up subscriptions case Devices.get_device(id) do nil -> {:noreply, socket |> put_flash(:error, "Device not found") |> push_navigate(to: ~p"/devices")} _device -> _ = if connected?(socket) do _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{id}") Process.send_after(self(), :refresh_data, 10_000) end tab = Map.get(params, "tab", "overview") socket |> load_equipment_data(id) |> assign(:active_tab, tab) |> then(&{:noreply, &1}) 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, "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, "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({: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 # Private functions defp load_equipment_data(socket, device_id) do device = Devices.get_device!(device_id) 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) 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"]) traffic_chart_data = load_overall_traffic_chart_data(device_id) # 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, &(&1.sensor_type in ["temperature", "cpu_temperature", "celsius"])) voltage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["voltage", "volts"])) storage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["disk_usage"])) count_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type == "count")) # 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) socket |> assign(:page_title, device.name) |> 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(: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(: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(:traffic_chart_data, traffic_chart_data) |> assign(:temperature_sensors, temperature_sensors) |> assign(:voltage_sensors, voltage_sensors) |> assign(:storage_sensors, storage_sensors) |> assign(:count_sensors, count_sensors) |> assign(:grouped_transceivers, grouped_transceivers) 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_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 defp aggregate_interface_traffic_stats(interfaces, since) do interfaces |> Enum.flat_map(fn interface -> stats = interface.id |> Snmp.get_interface_stats(since: since, limit: 1000) |> Enum.reverse() Enum.map(stats, &interface_stat_to_tuple/1) end) |> Enum.group_by(&elem(&1, 0)) |> Enum.map(&sum_traffic_stats_by_timestamp/1) |> Enum.sort_by(&elem(&1, 0), DateTime) end defp interface_stat_to_tuple(stat) do {stat.checked_at, stat.if_in_octets, stat.if_out_octets} end defp sum_traffic_stats_by_timestamp({timestamp, stats}) do total_in = Enum.reduce(stats, 0, fn {_, in_octets, _}, acc -> acc + (in_octets || 0) end) total_out = Enum.reduce(stats, 0, fn {_, _, out_octets}, acc -> acc + (out_octets || 0) end) {timestamp, total_in, total_out} end defp build_traffic_json_datasets(all_stats) do in_data = calculate_bps_data(all_stats, :inbound) out_data = calculate_bps_data(all_stats, :outbound) datasets = [ %{label: "Outbound", data: out_data}, %{label: "Inbound", data: in_data} ] Jason.encode!(%{datasets: datasets}) end defp calculate_bps_data(all_stats, :inbound) do all_stats |> Enum.chunk_every(2, 1, :discard) |> Enum.map(fn [{t1, in1, _out1}, {t2, in2, _out2}] -> time_diff = t2 |> DateTime.diff(t1, :second) |> max(1) bps = ((in2 - in1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2) %{ x: DateTime.to_unix(t2, :millisecond), y: bps } end) end defp calculate_bps_data(all_stats, :outbound) do all_stats |> Enum.chunk_every(2, 1, :discard) |> Enum.map(fn [{t1, _in1, out1}, {t2, _in2, out2}] -> time_diff = t2 |> DateTime.diff(t1, :second) |> max(1) bps = ((out2 - out1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2) %{ x: DateTime.to_unix(t2, :millisecond), y: -bps } 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 # 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 end