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)} 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 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""" OK """ end defp render_status_badge(1) do assigns = %{} ~H""" WARNING """ end defp render_status_badge(2) do assigns = %{} ~H""" CRITICAL """ end defp render_status_badge(3) do assigns = %{} ~H""" UNKNOWN """ end defp render_status_badge(_) do assigns = %{} ~H""" PENDING """ 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