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""" 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 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