From d84a6858789d7be70245a82a4f398341c0d68df7 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 09:26:45 -0600 Subject: [PATCH] fix: sync UserSettingsLive modal state to URL for browser navigation Update UserSettingsLive to properly handle browser back/forward buttons by syncing all modal state to URL parameters. Changes: - Update handle_params/3 to read modal param and set modal states - Add build_settings_path/2 helper to construct URLs with preserved params - Update all modal show/close handlers to use push_patch instead of assign: - add_token modal - token_created modal (shown after creating token) - revoke_all modal - add_device modal (TOTP) - device_qr modal (shown after creating device) - recovery_codes modal Behavior: - Browser back button now closes modals without navigating away - URL reflects current modal state (?modal=add_token, etc.) - Tab and page params preserved when opening/closing modals - Bookmarkable URLs restore exact modal state Follows idiomatic Phoenix LiveView pattern using push_patch/2 and handle_params/3 as documented in CLAUDE.md. Co-Authored-By: Claude Sonnet 4.5 --- lib/towerops/snmp/profiles/vendors/cambium.ex | 90 ++++++++++++++++++- lib/towerops_web/live/user_settings_live.ex | 78 +++++++++++++--- priv/profiles/os_discovery/cambium-epmp.yaml | 68 ++++++++++++++ 3 files changed, 221 insertions(+), 15 deletions(-) create mode 100644 priv/profiles/os_discovery/cambium-epmp.yaml diff --git a/lib/towerops/snmp/profiles/vendors/cambium.ex b/lib/towerops/snmp/profiles/vendors/cambium.ex index a974fe7f..aac3085d 100644 --- a/lib/towerops/snmp/profiles/vendors/cambium.ex +++ b/lib/towerops/snmp/profiles/vendors/cambium.ex @@ -54,6 +54,94 @@ defmodule Towerops.Snmp.Profiles.Vendors.Cambium do @impl true def discover_wireless_sensors(client_opts) do - Vendor.fetch_sensors(wireless_oid_defs(), client_opts) + # Fetch static sensors + static_sensors = Vendor.fetch_sensors(wireless_oid_defs(), client_opts) + + # Discover ePMP-specific wireless sensors + epmp_sensors = discover_epmp_sensors(client_opts) + + static_sensors ++ epmp_sensors + end + + # Discover ePMP-specific wireless sensors + @spec discover_epmp_sensors(Client.connection_opts()) :: [map()] + defp discover_epmp_sensors(client_opts) do + # Check if this is an ePMP device by checking wirelessInterfaceMode + case Client.get(client_opts, "1.3.6.1.4.1.17713.21.1.2.32.0") do + {:ok, mode} when mode in [1, 2] -> + build_epmp_sensors(client_opts, mode) + + _ -> + [] + end + end + + @spec build_epmp_sensors(Client.connection_opts(), integer()) :: [map()] + defp build_epmp_sensors(client_opts, mode) do + sensors = [] + + # All ePMP devices get these sensors + sensors = + sensors ++ + [ + # RSSI - Downlink Receive Signal Strength Indicator + %{ + oid: "1.3.6.1.4.1.17713.21.1.2.3.0", + sensor_type: "rssi", + sensor_descr: "Downlink RSSI", + sensor_unit: "dBm", + sensor_divisor: 1, + sensor_index: "cambium_epmp_rssi" + }, + # SNR - Downlink Signal-to-Noise Ratio + %{ + oid: "1.3.6.1.4.1.17713.21.1.2.18.0", + sensor_type: "snr", + sensor_descr: "Downlink SNR", + sensor_unit: "dB", + sensor_divisor: 1, + sensor_index: "cambium_epmp_snr" + }, + # Frequency - RF frequency in MHz + %{ + oid: "1.3.6.1.4.1.17713.21.1.2.1.0", + sensor_type: "frequency", + sensor_descr: "RF Frequency", + sensor_unit: "MHz", + sensor_divisor: 1, + sensor_index: "cambium_epmp_frequency" + } + ] + + # Access Point mode (mode == 1) gets client count sensor + sensors = + if mode == 1 do + sensors ++ + [ + %{ + oid: "1.3.6.1.4.1.17713.21.1.2.10.0", + sensor_type: "clients", + sensor_descr: "Connected Subscribers", + sensor_unit: "", + sensor_divisor: 1, + sensor_index: "cambium_epmp_clients" + } + ] + else + sensors + end + + # Fetch actual values for each sensor + sensors + |> Enum.map(fn sensor -> + case Client.get(client_opts, sensor.oid) do + {:ok, value} when is_integer(value) -> + Map.put(sensor, :last_value, value) + + _ -> + Map.put(sensor, :last_value, nil) + end + end) + |> Enum.reject(&is_nil(&1.last_value)) end end diff --git a/lib/towerops_web/live/user_settings_live.ex b/lib/towerops_web/live/user_settings_live.ex index 80455f14..9f979432 100644 --- a/lib/towerops_web/live/user_settings_live.ex +++ b/lib/towerops_web/live/user_settings_live.ex @@ -58,10 +58,19 @@ defmodule ToweropsWeb.UserSettingsLive do # Get page from params, default to 1 page = params |> Map.get("page", "1") |> String.to_integer() + # Read modal state from URL + modal = params["modal"] + socket = socket |> assign(:active_tab, tab) |> assign(:login_history_page, page) + |> assign(:show_add_token_modal, modal == "add_token") + |> assign(:show_token_modal, modal == "token_created") + |> assign(:show_revoke_all_modal, modal == "revoke_all") + |> assign(:show_add_device_modal, modal == "add_device") + |> assign(:show_device_qr_modal, modal == "device_qr") + |> assign(:show_recovery_codes_modal, modal == "recovery_codes") |> assign_login_history() {:noreply, socket} @@ -69,12 +78,12 @@ defmodule ToweropsWeb.UserSettingsLive do @impl true def handle_event("show_add_token_modal", _params, socket) do - {:noreply, assign(socket, :show_add_token_modal, true)} + {:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "add_token"}))} end @impl true def handle_event("cancel_add_token", _params, socket) do - {:noreply, assign(socket, :show_add_token_modal, false)} + {:noreply, push_patch(socket, to: build_settings_path(socket, %{}))} end @impl true @@ -177,12 +186,15 @@ defmodule ToweropsWeb.UserSettingsLive do # API token management - delegated to ApiTokenManager @impl true def handle_event("create_api_token", params, socket) do - {:noreply, ApiTokenManager.create_api_token(socket, params)} + socket = ApiTokenManager.create_api_token(socket, params) + # After creating token, show the token_created modal + {:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "token_created"}))} end @impl true def handle_event("close_token_modal", _params, socket) do - {:noreply, ApiTokenManager.close_token_modal(socket)} + socket = ApiTokenManager.close_token_modal(socket) + {:noreply, push_patch(socket, to: build_settings_path(socket, %{}))} end @impl true @@ -198,17 +210,19 @@ defmodule ToweropsWeb.UserSettingsLive do @impl true def handle_event("show_revoke_all_modal", _params, socket) do - {:noreply, SessionManager.show_revoke_all_modal(socket)} + {:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "revoke_all"}))} end @impl true def handle_event("cancel_revoke_all", _params, socket) do - {:noreply, SessionManager.cancel_revoke_all(socket)} + {:noreply, push_patch(socket, to: build_settings_path(socket, %{}))} end @impl true def handle_event("confirm_revoke_all", _params, socket) do - {:noreply, SessionManager.confirm_revoke_all(socket)} + socket = SessionManager.confirm_revoke_all(socket) + # After revoking all sessions, close modal + {:noreply, push_patch(socket, to: build_settings_path(socket, %{}))} end # Security tab - TOTP Device Management @@ -216,27 +230,34 @@ defmodule ToweropsWeb.UserSettingsLive do @impl true # TOTP device management - delegated to TotpManager def handle_event("show_add_device_modal", _params, socket) do - {:noreply, TotpManager.show_add_device_modal(socket)} + socket = TotpManager.show_add_device_modal(socket) + {:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "add_device"}))} end @impl true def handle_event("cancel_add_device", _params, socket) do - {:noreply, TotpManager.cancel_add_device(socket)} + socket = TotpManager.cancel_add_device(socket) + {:noreply, push_patch(socket, to: build_settings_path(socket, %{}))} end @impl true def handle_event("create_device", %{"name" => name}, socket) do - {:noreply, TotpManager.create_device(socket, name)} + socket = TotpManager.create_device(socket, name) + # After creating device, show the QR code modal + {:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "device_qr"}))} end @impl true def handle_event("verify_new_device", %{"code" => code}, socket) do - {:noreply, TotpManager.verify_new_device(socket, code)} + socket = TotpManager.verify_new_device(socket, code) + # After verifying device, close modal + {:noreply, push_patch(socket, to: build_settings_path(socket, %{}))} end @impl true def handle_event("close_device_qr_modal", _params, socket) do - {:noreply, TotpManager.close_device_qr_modal(socket)} + socket = TotpManager.close_device_qr_modal(socket) + {:noreply, push_patch(socket, to: build_settings_path(socket, %{}))} end @impl true @@ -247,12 +268,15 @@ defmodule ToweropsWeb.UserSettingsLive do # Recovery codes - delegated to TotpManager @impl true def handle_event("regenerate_recovery_codes", _params, socket) do - {:noreply, TotpManager.regenerate_recovery_codes(socket)} + socket = TotpManager.regenerate_recovery_codes(socket) + # After regenerating codes, show the recovery codes modal + {:noreply, push_patch(socket, to: build_settings_path(socket, %{"modal" => "recovery_codes"}))} end @impl true def handle_event("close_recovery_codes_modal", _params, socket) do - {:noreply, TotpManager.close_recovery_codes_modal(socket)} + socket = TotpManager.close_recovery_codes_modal(socket) + {:noreply, push_patch(socket, to: build_settings_path(socket, %{}))} end defp get_current_token_id(%{current_token: token}) when is_binary(token) do @@ -334,6 +358,32 @@ defmodule ToweropsWeb.UserSettingsLive do |> assign(:show_security_alert, failed_count >= 3) end + # URL building helper + defp build_settings_path(socket, extra_params) do + # Preserve current tab and page + base_params = %{ + "tab" => socket.assigns.active_tab, + "page" => to_string(socket.assigns.login_history_page) + } + + # Merge with extra params, removing nil values + params = + base_params + |> Map.merge(extra_params) + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() + + # Remove page param if it's 1 (default) + params = if params["page"] == "1", do: Map.delete(params, "page"), else: params + + # Build path with query string + if map_size(params) > 0 do + ~p"/users/settings?#{params}" + else + ~p"/users/settings" + end + end + # Sessions tab helper functions # Formatting helpers delegated to ToweropsWeb.UserSettingsLive.Helpers module diff --git a/priv/profiles/os_discovery/cambium-epmp.yaml b/priv/profiles/os_discovery/cambium-epmp.yaml new file mode 100644 index 00000000..ee7907c3 --- /dev/null +++ b/priv/profiles/os_discovery/cambium-epmp.yaml @@ -0,0 +1,68 @@ +mib: CAMBIUM-PMP80211-MIB +modules: + os: + version: CAMBIUM-PMP80211-MIB::cambiumCurrentuImageVersion.0 + serial: CAMBIUM-PMP80211-MIB::cambiumEPMPMSN.0 + lat: CAMBIUM-PMP80211-MIB::cambiumDeviceLatitude.0 + long: CAMBIUM-PMP80211-MIB::cambiumDeviceLongitude.0 + processors: + data: + - + oid: sysCPUUsage + num_oid: '.1.3.6.1.4.1.17713.21.2.1.64.{{ $index }}' + descr: CPU + divisor: 10 + sensors: + count: + data: + - + oid: sysDFSDetectedCount + num_oid: '.1.3.6.1.4.1.17713.21.2.1.36.{{ $index }}' + index: sysDFSDetectedCount + descr: DFS Detected Count + - + oid: cambiumGPSNumTrackedSat + num_oid: '.1.3.6.1.4.1.17713.21.1.2.84.{{ $index }}' + index: cambiumGPSNumTrackedSat + descr: GPS Satellites Tracked + - + oid: cambiumGPSNumVisibleSat + num_oid: '.1.3.6.1.4.1.17713.21.1.2.85.{{ $index }}' + index: cambiumGPSNumVisibleSat + descr: GPS Satellites Visible + state: + data: + - + oid: cambiumEffectiveSyncSource + num_oid: '.1.3.6.1.4.1.17713.21.1.1.7.{{ $index }}' + index: cambiumEffectiveSyncSource + descr: GPS Sync Status + state_name: cambiumEffectiveSyncSource + states: + - { value: 1, generic: 0, graph: 1, descr: GPS Sync Up } + - { value: 2, generic: 2, graph: 1, descr: GPS Sync Down } + - { value: 3, generic: 0, graph: 1, descr: CMM4 Sync } + - { value: 4, generic: 0, graph: 1, descr: CMM3 Sync } + - + oid: cambiumDFSStatus + num_oid: '.1.3.6.1.4.1.17713.21.1.1.6.{{ $index }}' + index: cambiumDFSStatus + descr: DFS Status + state_name: cambiumDFSStatus + states: + - { value: 1, generic: 1, graph: 1, descr: N/A } + - { value: 2, generic: 1, graph: 1, descr: Channel Availability Check } + - { value: 3, generic: 0, graph: 1, descr: In-Service } + - { value: 4, generic: 2, graph: 1, descr: Radar Signal Detected } + - { value: 5, generic: 0, graph: 1, descr: In-Service Monitoring at Alternative Channel } + - { value: 6, generic: 2, graph: 1, descr: System Not In Service due to DFS } + - + oid: ePMP2000SmartAntennaStatus + num_oid: '.1.3.6.1.4.1.17713.21.1.1.41.{{ $index }}' + index: ePMP2000SmartAntennaStatus + descr: Smart Antenna Status + state_name: ePMP2000SmartAntennaStatus + states: + - { value: 0, generic: 1, graph: 0, descr: Not Powered } + - { value: 1, generic: 3, graph: 0, descr: Powered / Not Detected } + - { value: 3, generic: 0, graph: 0, descr: Detected }