banner dissmiss and mikrotik version and license

This commit is contained in:
Graham McIntire 2026-01-31 13:07:09 -06:00
parent 12a777d13d
commit 480789c3e2
8 changed files with 291 additions and 13 deletions

View file

@ -913,3 +913,4 @@ When writing new LiveView code or JavaScript hooks:
- Navigate away and back multiple times - Navigate away and back multiple times
- Take another snapshot - Take another snapshot
- Compare - memory should not continuously grow - Compare - memory should not continuously grow
- assets are rebuilt automatically on filesystem change

View file

@ -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 // Network Map hook for Cytoscape.js topology visualization
const NetworkMap = { const NetworkMap = {
cy: null as any, cy: null as any,
@ -666,7 +707,7 @@ const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
const liveSocket = new LiveSocket("/live", Socket, { const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500, longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken, timezone: userTimezone }, 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 // Show progress bar on live navigation and form submits

View file

@ -40,13 +40,18 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
detect_vendor_version(profile, client_opts) || detect_vendor_version(profile, client_opts) ||
format_firmware_version(profile, device_data) 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 # Merge with system_info
system_info system_info
|> Map.merge(%{ |> Map.merge(%{
manufacturer: profile.vendor, manufacturer: profile.vendor,
model: model, model: model,
firmware_version: firmware_version, firmware_version: firmware_version,
serial_number: Map.get(device_data, :serial_number), serial_number: serial_number,
latitude: Map.get(device_data, :latitude), latitude: Map.get(device_data, :latitude),
longitude: Map.get(device_data, :longitude) 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 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 # Extract hardware model from sysDescr using profile's sysDescr_regex
# LibreNMS uses named capture groups like (?<hardware>...) in the regex # LibreNMS uses named capture groups like (?<hardware>...) in the regex
defp extract_hardware_from_regex(%{hardware_regex: regex}, sys_descr) defp extract_hardware_from_regex(%{hardware_regex: regex}, sys_descr)

View file

@ -18,8 +18,14 @@ defmodule Towerops.Snmp.Profiles.Vendors.Routeros do
require Logger require Logger
# OID for license level (used in firmware version display) # OID for license level (integer) - MIKROTIK-MIB::mtxrLicLevel.0
@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"
# 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 # Table OIDs for walking
@wl_ap_table "1.3.6.1.4.1.14988.1.1.1.3.1" @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 @impl true
def detect_hardware(client_opts) do def detect_hardware(client_opts) do
# MikroTik hardware is typically identified by sysDescr # MikroTik hardware is typically identified by sysDescr
# We enhance the firmware version with license level # Use the license level OID to get the integer license level
case Client.get(client_opts, @license_oid) do case Client.get(client_opts, @license_level_oid) do
{:ok, license} when is_integer(license) -> {:ok, license} when is_integer(license) ->
"RouterOS Level #{license}" "RouterOS Level #{license}"
@ -60,6 +66,68 @@ defmodule Towerops.Snmp.Profiles.Vendors.Routeros do
end end
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 @impl true
def discover_wireless_sensors(client_opts) do def discover_wireless_sensors(client_opts) do
# Discover from multiple tables like LibreNMS does # Discover from multiple tables like LibreNMS does

View file

@ -127,11 +127,15 @@ defmodule ToweropsWeb.Layouts do
# Get policies needing consent from assigns (set in CheckPolicyConsent plug) # Get policies needing consent from assigns (set in CheckPolicyConsent plug)
policies_needing_consent = Map.get(assigns, :policies_needing_consent, []) 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 =
assigns assigns
|> Map.put(:current_organization, current_organization) |> Map.put(:current_organization, current_organization)
|> Map.put(:timezone, timezone) |> Map.put(:timezone, timezone)
|> Map.put(:policies_needing_consent, policies_needing_consent) |> Map.put(:policies_needing_consent, policies_needing_consent)
|> Map.put(:always_show_banner, always_show_banner)
~H""" ~H"""
<div class="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950"> <div class="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
@ -400,7 +404,12 @@ defmodule ToweropsWeb.Layouts do
<main id="main-content" tabindex="-1" class="flex-1 py-6 sm:py-10"> <main id="main-content" tabindex="-1" class="flex-1 py-6 sm:py-10">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<!-- Beta Testing Banner --> <!-- Beta Testing Banner -->
<div class="mb-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800"> <div
id="beta-banner"
phx-hook="BetaBannerDismiss"
data-always-show={@always_show_banner}
class="mb-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800"
>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<.icon <.icon
name="hero-heart" name="hero-heart"
@ -421,8 +430,38 @@ defmodule ToweropsWeb.Layouts do
for support, feature requests, or just to say hello! for support, feature requests, or just to say hello!
</p> </p>
</div> </div>
<button
type="button"
data-dismiss
class="flex-shrink-0 rounded-md p-1.5 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-800/50 focus:outline-none focus:ring-2 focus:ring-blue-600 dark:focus:ring-blue-400"
aria-label="Dismiss banner"
>
<.icon name="hero-x-mark" class="h-5 w-5" />
</button>
</div> </div>
</div> </div>
<script>
(function() {
var banner = document.getElementById('beta-banner');
if (banner) {
var isHelpPage = banner.dataset.alwaysShow === 'true';
if (isHelpPage) {
// On help page: keep banner visible, hide dismiss button
var dismissBtn = banner.querySelector('[data-dismiss]');
if (dismissBtn) {
dismissBtn.style.display = 'none';
}
} else {
// On other pages: check if dismissed
var isDismissed = localStorage.getItem('betaBannerDismissed') === 'true';
if (isDismissed) {
banner.style.display = 'none';
}
}
}
})();
</script>
{render_slot(@inner_block)} {render_slot(@inner_block)}
</div> </div>

View file

@ -255,6 +255,16 @@
</dd> </dd>
</div> </div>
<div
:if={@snmp_device.serial_number}
class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"
>
<dt class="text-sm text-gray-600 dark:text-gray-400">Serial Number</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono">
{@snmp_device.serial_number}
</dd>
</div>
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5"> <div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">Object ID</dt> <dt class="text-sm text-gray-600 dark:text-gray-400">Object ID</dt>
<dd class="text-sm font-medium text-gray-900 dark:text-white font-mono"> <dd class="text-sm font-medium text-gray-900 dark:text-white font-mono">

View file

@ -1,11 +1,12 @@
Devices Working Devices Tested & Working
* Mikrotik RouterOS * Mikrotik RB5009UG+S+, CCR1009-7G-1C-1S+,
* Ubiquiti AC, LTU, AirFiber * Ubiquiti AC, LTU, AirFiber
2026-01-31 2026-01-31
* Small ui tweaks * Small ui tweaks
* Backend refactoring * Backend refactoring
* Patched some potential memory leaks * Patched some potential memory leaks
* Show version and license for Mikrotik devices
2026-01-30 2026-01-30
* Completely overhaul poller agent to not track any state * Completely overhaul poller agent to not track any state

View file

@ -17,7 +17,9 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do
] ]
# OID constants from the module # 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_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_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" @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 describe "detect_hardware/1" do
test "returns RouterOS Level when SNMP responds with integer license" 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} {:ok, 6}
end) end)
@ -41,7 +43,7 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do
end end
test "returns nil when SNMP fails" do test "returns nil when SNMP fails" do
expect(SnmpMock, :get, fn _, @license_oid, _ -> expect(SnmpMock, :get, fn _, @license_level_oid, _ ->
{:error, :timeout} {:error, :timeout}
end) end)
@ -49,7 +51,7 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do
end end
test "returns nil when license is not an integer" do 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"} {:ok, "not an integer"}
end) end)
@ -57,6 +59,101 @@ defmodule Towerops.Snmp.Profiles.Vendors.RouterosTest do
end end
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 describe "wireless_oid_defs/0" do
test "returns list of sensor definitions" do test "returns list of sensor definitions" do
defs = Routeros.wireless_oid_defs() defs = Routeros.wireless_oid_defs()