banner dissmiss and mikrotik version and license
This commit is contained in:
parent
12a777d13d
commit
480789c3e2
8 changed files with 291 additions and 13 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (?<hardware>...) in the regex
|
||||
defp extract_hardware_from_regex(%{hardware_regex: regex}, sys_descr)
|
||||
|
|
|
|||
76
lib/towerops/snmp/profiles/vendors/routeros.ex
vendored
76
lib/towerops/snmp/profiles/vendors/routeros.ex
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<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">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<!-- 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">
|
||||
<.icon
|
||||
name="hero-heart"
|
||||
|
|
@ -421,8 +430,38 @@ defmodule ToweropsWeb.Layouts do
|
|||
for support, feature requests, or just to say hello!
|
||||
</p>
|
||||
</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>
|
||||
<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)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -255,6 +255,16 @@
|
|||
</dd>
|
||||
</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">
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue