From 480789c3e2b0f5385e1ea2b57c274b971bd43652 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 31 Jan 2026 13:07:09 -0600 Subject: [PATCH] banner dissmiss and mikrotik version and license --- CLAUDE.md | 1 + assets/js/app.ts | 43 ++++++- lib/towerops/snmp/profiles/dynamic.ex | 23 +++- .../snmp/profiles/vendors/routeros.ex | 76 ++++++++++++- lib/towerops_web/components/layouts.ex | 41 ++++++- .../live/device_live/show.html.heex | 10 ++ priv/static/changelog.txt | 5 +- .../snmp/profiles/vendors/routeros_test.exs | 105 +++++++++++++++++- 8 files changed, 291 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9645e65c..93603864 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -913,3 +913,4 @@ When writing new LiveView code or JavaScript hooks: - Navigate away and back multiple times - Take another snapshot - Compare - memory should not continuously grow +- assets are rebuilt automatically on filesystem change \ No newline at end of file diff --git a/assets/js/app.ts b/assets/js/app.ts index 44907860..3924c63e 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -402,6 +402,47 @@ const AutoDismissFlash = { } } +// LiveView hook for dismissible beta banner +const BetaBannerDismiss = { + handleDismiss: null as ((e: Event) => void) | null, + + mounted(this: any) { + const isHelpPage = this.el.dataset.alwaysShow === "true" + + if (isHelpPage) { + // On help page: hide dismiss button only + const dismissBtn = this.el.querySelector('[data-dismiss]') + if (dismissBtn) { + (dismissBtn as HTMLElement).style.display = 'none' + } + } else { + // On other pages: check if dismissed and set up dismiss handler + const isDismissed = localStorage.getItem('betaBannerDismissed') === 'true' + if (isDismissed) { + this.el.style.display = 'none' + } + + const dismissBtn = this.el.querySelector('[data-dismiss]') + if (dismissBtn) { + this.handleDismiss = (e: Event) => { + e.preventDefault() + localStorage.setItem('betaBannerDismissed', 'true') + this.el.style.display = 'none' + } + dismissBtn.addEventListener('click', this.handleDismiss) + } + } + }, + + destroyed(this: any) { + const dismissBtn = this.el.querySelector('[data-dismiss]') + if (dismissBtn && this.handleDismiss) { + dismissBtn.removeEventListener('click', this.handleDismiss) + this.handleDismiss = null + } + } +} + // Network Map hook for Cytoscape.js topology visualization const NetworkMap = { cy: null as any, @@ -666,7 +707,7 @@ const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken, timezone: userTimezone }, - hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, NetworkMap, DeviceListReorder }, + hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, DeviceListReorder }, }) // Show progress bar on live navigation and form submits diff --git a/lib/towerops/snmp/profiles/dynamic.ex b/lib/towerops/snmp/profiles/dynamic.ex index 90473e18..ac26c062 100644 --- a/lib/towerops/snmp/profiles/dynamic.ex +++ b/lib/towerops/snmp/profiles/dynamic.ex @@ -40,13 +40,18 @@ defmodule Towerops.Snmp.Profiles.Dynamic do detect_vendor_version(profile, client_opts) || format_firmware_version(profile, device_data) + # Get serial number: YAML serial_number OID → vendor-specific + serial_number = + Map.get(device_data, :serial_number) || + detect_vendor_serial_number(profile, client_opts) + # Merge with system_info system_info |> Map.merge(%{ manufacturer: profile.vendor, model: model, firmware_version: firmware_version, - serial_number: Map.get(device_data, :serial_number), + serial_number: serial_number, latitude: Map.get(device_data, :latitude), longitude: Map.get(device_data, :longitude) }) @@ -153,6 +158,22 @@ defmodule Towerops.Snmp.Profiles.Dynamic do defp detect_vendor_version(_profile, _client_opts), do: nil + # Vendor-specific serial number detection using vendor modules + defp detect_vendor_serial_number(%{name: name}, client_opts) do + case VendorRegistry.get_vendor(name) do + nil -> + nil + + vendor -> + # Check if vendor module implements detect_serial_number/1 + if function_exported?(vendor, :detect_serial_number, 1) do + vendor.detect_serial_number(client_opts) + end + end + end + + defp detect_vendor_serial_number(_profile, _client_opts), do: nil + # Extract hardware model from sysDescr using profile's sysDescr_regex # LibreNMS uses named capture groups like (?...) in the regex defp extract_hardware_from_regex(%{hardware_regex: regex}, sys_descr) diff --git a/lib/towerops/snmp/profiles/vendors/routeros.ex b/lib/towerops/snmp/profiles/vendors/routeros.ex index d3191e8f..28d4e14d 100644 --- a/lib/towerops/snmp/profiles/vendors/routeros.ex +++ b/lib/towerops/snmp/profiles/vendors/routeros.ex @@ -18,8 +18,14 @@ defmodule Towerops.Snmp.Profiles.Vendors.Routeros do require Logger - # OID for license level (used in firmware version display) - @license_oid "1.3.6.1.4.1.14988.1.1.4.4.0" + # OID for license level (integer) - MIKROTIK-MIB::mtxrLicLevel.0 + @license_level_oid "1.3.6.1.4.1.14988.1.1.4.3.0" + + # OID for license version (string) - MIKROTIK-MIB::mtxrLicVersion.0 + @version_oid "1.3.6.1.4.1.14988.1.1.4.4.0" + + # OID for serial number (string) - MIKROTIK-MIB::mtxrSerialNumber.0 + @serial_number_oid "1.3.6.1.4.1.14988.1.1.7.3.0" # Table OIDs for walking @wl_ap_table "1.3.6.1.4.1.14988.1.1.1.3.1" @@ -50,8 +56,8 @@ defmodule Towerops.Snmp.Profiles.Vendors.Routeros do @impl true def detect_hardware(client_opts) do # MikroTik hardware is typically identified by sysDescr - # We enhance the firmware version with license level - case Client.get(client_opts, @license_oid) do + # Use the license level OID to get the integer license level + case Client.get(client_opts, @license_level_oid) do {:ok, license} when is_integer(license) -> "RouterOS Level #{license}" @@ -60,6 +66,68 @@ defmodule Towerops.Snmp.Profiles.Vendors.Routeros do end end + @doc """ + Get RouterOS firmware version from MIKROTIK-MIB. + Returns version string with "RouterOS" prefix and license level in parentheses. + Example: "RouterOS 7.14.1 (Level 6)" + """ + def detect_version(client_opts) do + # Query the version OID which returns the version string + version_result = Client.get(client_opts, @version_oid) + + case version_result do + {:ok, version} when is_binary(version) and version != "" -> + # Got version string, now try to get license level + # The license level can be derived from the version or queried separately + license_level = detect_license_level(client_opts) + + formatted_version = + case license_level do + level when is_integer(level) -> + "RouterOS #{version} (Level #{level})" + + _ -> + "RouterOS #{version}" + end + + Logger.debug("RouterOS: Detected version: #{formatted_version}") + formatted_version + + other -> + Logger.warning("RouterOS: Failed to detect version: #{inspect(other)}") + nil + end + end + + @doc """ + Get RouterOS serial number from MIKROTIK-MIB. + Returns the RouterBOARD serial number string. + Example: "HC907K5P55K" + """ + def detect_serial_number(client_opts) do + case Client.get(client_opts, @serial_number_oid) do + {:ok, serial} when is_binary(serial) and serial != "" -> + Logger.debug("RouterOS: Detected serial number: #{serial}") + serial + + other -> + Logger.debug("RouterOS: Failed to detect serial number: #{inspect(other)}") + nil + end + end + + # Try to detect the license level as an integer from mtxrLicLevel OID + defp detect_license_level(client_opts) do + case Client.get(client_opts, @license_level_oid) do + {:ok, level} when is_integer(level) -> + level + + _ -> + # Couldn't get license level, return nil + nil + end + end + @impl true def discover_wireless_sensors(client_opts) do # Discover from multiple tables like LibreNMS does diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index 0e751b2b..65034291 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -127,11 +127,15 @@ defmodule ToweropsWeb.Layouts do # Get policies needing consent from assigns (set in CheckPolicyConsent plug) policies_needing_consent = Map.get(assigns, :policies_needing_consent, []) + # Check if this is the help page for banner display + always_show_banner = if Map.get(assigns, :active_page) == "help", do: "true", else: "false" + assigns = assigns |> Map.put(:current_organization, current_organization) |> Map.put(:timezone, timezone) |> Map.put(:policies_needing_consent, policies_needing_consent) + |> Map.put(:always_show_banner, always_show_banner) ~H"""
@@ -400,7 +404,12 @@ defmodule ToweropsWeb.Layouts do
-
+
<.icon name="hero-heart" @@ -421,8 +430,38 @@ defmodule ToweropsWeb.Layouts do for support, feature requests, or just to say hello!

+
+ {render_slot(@inner_block)}
diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index 4fd9a4ef..f93f3bca 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -255,6 +255,16 @@
+
+
Serial Number
+
+ {@snmp_device.serial_number} +
+
+
Object ID
diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index c21a76b3..4abe34e1 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -1,11 +1,12 @@ -Devices Working -* Mikrotik RouterOS +Devices Tested & Working +* Mikrotik RB5009UG+S+, CCR1009-7G-1C-1S+, * Ubiquiti AC, LTU, AirFiber 2026-01-31 * Small ui tweaks * Backend refactoring * Patched some potential memory leaks +* Show version and license for Mikrotik devices 2026-01-30 * Completely overhaul poller agent to not track any state diff --git a/test/towerops/snmp/profiles/vendors/routeros_test.exs b/test/towerops/snmp/profiles/vendors/routeros_test.exs index fb69e2dc..a37eb018 100644 --- a/test/towerops/snmp/profiles/vendors/routeros_test.exs +++ b/test/towerops/snmp/profiles/vendors/routeros_test.exs @@ -17,7 +17,9 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do ] # OID constants from the module - @license_oid "1.3.6.1.4.1.14988.1.1.4.4.0" + @license_level_oid "1.3.6.1.4.1.14988.1.1.4.3.0" + @version_oid "1.3.6.1.4.1.14988.1.1.4.4.0" + @serial_number_oid "1.3.6.1.4.1.14988.1.1.7.3.0" @wl_ap_table "1.3.6.1.4.1.14988.1.1.1.3.1" @wl_stat_table "1.3.6.1.4.1.14988.1.1.1.1.1" @wl_60g_table "1.3.6.1.4.1.14988.1.1.1.8.1" @@ -33,7 +35,7 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do describe "detect_hardware/1" do test "returns RouterOS Level when SNMP responds with integer license" do - expect(SnmpMock, :get, fn _, @license_oid, _ -> + expect(SnmpMock, :get, fn _, @license_level_oid, _ -> {:ok, 6} end) @@ -41,7 +43,7 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do end test "returns nil when SNMP fails" do - expect(SnmpMock, :get, fn _, @license_oid, _ -> + expect(SnmpMock, :get, fn _, @license_level_oid, _ -> {:error, :timeout} end) @@ -49,7 +51,7 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do end test "returns nil when license is not an integer" do - expect(SnmpMock, :get, fn _, @license_oid, _ -> + expect(SnmpMock, :get, fn _, @license_level_oid, _ -> {:ok, "not an integer"} end) @@ -57,6 +59,101 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do end end + describe "detect_version/1" do + test "returns RouterOS version with prefix and license level when both available" do + expect(SnmpMock, :get, 2, fn + _, @version_oid, _ -> {:ok, "7.14.1"} + _, @license_level_oid, _ -> {:ok, 6} + end) + + assert Routeros.detect_version(@client_opts) == "RouterOS 7.14.1 (Level 6)" + end + + test "returns RouterOS version without license level when level unavailable" do + expect(SnmpMock, :get, 2, fn + _, @version_oid, _ -> {:ok, "7.14.1"} + _, @license_level_oid, _ -> {:error, :timeout} + end) + + assert Routeros.detect_version(@client_opts) == "RouterOS 7.14.1" + end + + test "handles different version formats" do + expect(SnmpMock, :get, 2, fn + _, @version_oid, _ -> {:ok, "6.48.3"} + _, @license_level_oid, _ -> {:ok, 4} + end) + + assert Routeros.detect_version(@client_opts) == "RouterOS 6.48.3 (Level 4)" + end + + test "returns nil when version is empty string" do + expect(SnmpMock, :get, fn _, @version_oid, _ -> + {:ok, ""} + end) + + assert Routeros.detect_version(@client_opts) == nil + end + + test "returns nil when SNMP times out" do + expect(SnmpMock, :get, fn _, @version_oid, _ -> + {:error, :timeout} + end) + + assert Routeros.detect_version(@client_opts) == nil + end + + test "returns nil when SNMP returns no_such_object" do + expect(SnmpMock, :get, fn _, @version_oid, _ -> + {:error, :no_such_object} + end) + + assert Routeros.detect_version(@client_opts) == nil + end + + test "returns nil when version is not a string" do + expect(SnmpMock, :get, fn _, @version_oid, _ -> + {:ok, 123} + end) + + assert Routeros.detect_version(@client_opts) == nil + end + end + + describe "detect_serial_number/1" do + test "returns serial number when SNMP responds" do + expect(SnmpMock, :get, fn _, @serial_number_oid, _ -> + {:ok, "HC907K5P55K"} + end) + + assert Routeros.detect_serial_number(@client_opts) == "HC907K5P55K" + end + + test "returns nil when serial number is empty string" do + expect(SnmpMock, :get, fn _, @serial_number_oid, _ -> + {:ok, ""} + end) + + assert Routeros.detect_serial_number(@client_opts) == nil + end + + test "returns nil when SNMP times out" do + expect(SnmpMock, :get, fn _, @serial_number_oid, _ -> + {:error, :timeout} + end) + + assert Routeros.detect_serial_number(@client_opts) == nil + end + + test "returns nil when serial number is not a string" do + expect(SnmpMock, :get, fn _, @serial_number_oid, _ -> + {:ok, 12_345} + end) + + assert Routeros.detect_serial_number(@client_opts) == nil + end + end + describe "wireless_oid_defs/0" do test "returns list of sensor definitions" do defs = Routeros.wireless_oid_defs()