diff --git a/lib/towerops_web/live/graph_live/show.ex b/lib/towerops_web/live/graph_live/show.ex
index 2a1cd597..df2f4e66 100644
--- a/lib/towerops_web/live/graph_live/show.ex
+++ b/lib/towerops_web/live/graph_live/show.ex
@@ -98,7 +98,8 @@ defmodule ToweropsWeb.GraphLive.Show do
defp get_sensor_types_for_chart("temperature"), do: ["temperature", "cpu_temperature", "celsius"]
defp get_sensor_types_for_chart("voltage"), do: ["voltage", "volts"]
defp get_sensor_types_for_chart("traffic"), do: nil
- defp get_sensor_types_for_chart(_), do: []
+ # For unknown sensor types, use the type directly (supports count, pppoe_sessions, etc.)
+ defp get_sensor_types_for_chart(sensor_type), do: [sensor_type]
defp get_chart_config("latency"), do: {"ICMP Latency", "ms", true}
defp get_chart_config("processors"), do: {"Processor Usage", "%", false}
@@ -107,7 +108,18 @@ defmodule ToweropsWeb.GraphLive.Show do
defp get_chart_config("temperature"), do: {"Temperature", "°C", true}
defp get_chart_config("voltage"), do: {"Voltage", "V", true}
defp get_chart_config("traffic"), do: {"Overall Traffic", "bps", true}
- defp get_chart_config(_), do: {"Sensor Data", "", false}
+ defp get_chart_config("count"), do: {"Count", "", true}
+ defp get_chart_config("pppoe_sessions"), do: {"PPPoE Sessions", "", true}
+ defp get_chart_config("connections"), do: {"Connections", "", true}
+ # Humanize unknown sensor types for title
+ defp get_chart_config(sensor_type), do: {humanize_sensor_type(sensor_type), "", true}
+
+ defp humanize_sensor_type(sensor_type) do
+ sensor_type
+ |> String.replace("_", " ")
+ |> String.split()
+ |> Enum.map_join(" ", &String.capitalize/1)
+ end
defp load_sensor_chart_data(device_id, sensor_types, range) do
case Snmp.get_device_with_associations(device_id) do
diff --git a/priv/repo/migrations/20260122214253_create_snmp_processors.exs b/priv/repo/migrations/20260122214253_create_snmp_processors.exs
new file mode 100644
index 00000000..5bffad39
--- /dev/null
+++ b/priv/repo/migrations/20260122214253_create_snmp_processors.exs
@@ -0,0 +1,24 @@
+defmodule Towerops.Repo.Migrations.CreateSnmpProcessors do
+ use Ecto.Migration
+
+ def change do
+ create table(:snmp_processors, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :snmp_device_id, references(:snmp_devices, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :processor_index, :string, null: false
+ add :description, :string
+ add :processor_type, :string, null: false
+ add :load_percent, :float
+ add :last_checked_at, :utc_datetime
+ add :metadata, :map, default: %{}
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:snmp_processors, [:snmp_device_id])
+ create unique_index(:snmp_processors, [:snmp_device_id, :processor_index])
+ end
+end
diff --git a/priv/repo/migrations/20260122223758_add_processor_readings.exs b/priv/repo/migrations/20260122223758_add_processor_readings.exs
new file mode 100644
index 00000000..dee8e663
--- /dev/null
+++ b/priv/repo/migrations/20260122223758_add_processor_readings.exs
@@ -0,0 +1,22 @@
+defmodule Towerops.Repo.Migrations.AddProcessorReadings do
+ use Ecto.Migration
+
+ def change do
+ create table(:snmp_processor_readings, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :processor_id, references(:snmp_processors, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :load_percent, :float
+ add :status, :string, null: false, default: "ok"
+ add :checked_at, :utc_datetime, null: false
+
+ timestamps(type: :utc_datetime, updated_at: false)
+ end
+
+ create index(:snmp_processor_readings, [:processor_id])
+ create index(:snmp_processor_readings, [:checked_at])
+ create index(:snmp_processor_readings, [:processor_id, :checked_at])
+ end
+end
diff --git a/priv/static/android-icon-144x144.png b/priv/static/android-icon-144x144.png
new file mode 100644
index 00000000..8f39b461
Binary files /dev/null and b/priv/static/android-icon-144x144.png differ
diff --git a/priv/static/android-icon-192x192.png b/priv/static/android-icon-192x192.png
new file mode 100644
index 00000000..12fff576
Binary files /dev/null and b/priv/static/android-icon-192x192.png differ
diff --git a/priv/static/android-icon-36x36.png b/priv/static/android-icon-36x36.png
new file mode 100644
index 00000000..6de73020
Binary files /dev/null and b/priv/static/android-icon-36x36.png differ
diff --git a/priv/static/android-icon-48x48.png b/priv/static/android-icon-48x48.png
new file mode 100644
index 00000000..c4f4cae0
Binary files /dev/null and b/priv/static/android-icon-48x48.png differ
diff --git a/priv/static/android-icon-72x72.png b/priv/static/android-icon-72x72.png
new file mode 100644
index 00000000..2b04a2df
Binary files /dev/null and b/priv/static/android-icon-72x72.png differ
diff --git a/priv/static/android-icon-96x96.png b/priv/static/android-icon-96x96.png
new file mode 100644
index 00000000..9afd32e9
Binary files /dev/null and b/priv/static/android-icon-96x96.png differ
diff --git a/priv/static/apple-icon-114x114.png b/priv/static/apple-icon-114x114.png
new file mode 100644
index 00000000..4e638b84
Binary files /dev/null and b/priv/static/apple-icon-114x114.png differ
diff --git a/priv/static/apple-icon-120x120.png b/priv/static/apple-icon-120x120.png
new file mode 100644
index 00000000..505f41a9
Binary files /dev/null and b/priv/static/apple-icon-120x120.png differ
diff --git a/priv/static/apple-icon-144x144.png b/priv/static/apple-icon-144x144.png
new file mode 100644
index 00000000..8f39b461
Binary files /dev/null and b/priv/static/apple-icon-144x144.png differ
diff --git a/priv/static/apple-icon-152x152.png b/priv/static/apple-icon-152x152.png
new file mode 100644
index 00000000..35844d8b
Binary files /dev/null and b/priv/static/apple-icon-152x152.png differ
diff --git a/priv/static/apple-icon-180x180.png b/priv/static/apple-icon-180x180.png
new file mode 100644
index 00000000..8b3b1ac6
Binary files /dev/null and b/priv/static/apple-icon-180x180.png differ
diff --git a/priv/static/apple-icon-57x57.png b/priv/static/apple-icon-57x57.png
new file mode 100644
index 00000000..5e08c630
Binary files /dev/null and b/priv/static/apple-icon-57x57.png differ
diff --git a/priv/static/apple-icon-60x60.png b/priv/static/apple-icon-60x60.png
new file mode 100644
index 00000000..1d0fe038
Binary files /dev/null and b/priv/static/apple-icon-60x60.png differ
diff --git a/priv/static/apple-icon-72x72.png b/priv/static/apple-icon-72x72.png
new file mode 100644
index 00000000..2b04a2df
Binary files /dev/null and b/priv/static/apple-icon-72x72.png differ
diff --git a/priv/static/apple-icon-76x76.png b/priv/static/apple-icon-76x76.png
new file mode 100644
index 00000000..d9205d1d
Binary files /dev/null and b/priv/static/apple-icon-76x76.png differ
diff --git a/priv/static/apple-icon-precomposed.png b/priv/static/apple-icon-precomposed.png
new file mode 100644
index 00000000..df396cfa
Binary files /dev/null and b/priv/static/apple-icon-precomposed.png differ
diff --git a/priv/static/apple-icon.png b/priv/static/apple-icon.png
new file mode 100644
index 00000000..df396cfa
Binary files /dev/null and b/priv/static/apple-icon.png differ
diff --git a/priv/static/favicon-16x16.png b/priv/static/favicon-16x16.png
new file mode 100644
index 00000000..c2fd1e58
Binary files /dev/null and b/priv/static/favicon-16x16.png differ
diff --git a/priv/static/favicon-32x32.png b/priv/static/favicon-32x32.png
new file mode 100644
index 00000000..5be4d7c4
Binary files /dev/null and b/priv/static/favicon-32x32.png differ
diff --git a/priv/static/favicon-96x96.png b/priv/static/favicon-96x96.png
new file mode 100644
index 00000000..9afd32e9
Binary files /dev/null and b/priv/static/favicon-96x96.png differ
diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico
index 5c36dc20..ede5aee4 100644
Binary files a/priv/static/favicon.ico and b/priv/static/favicon.ico differ
diff --git a/priv/static/ms-icon-144x144.png b/priv/static/ms-icon-144x144.png
new file mode 100644
index 00000000..8f39b461
Binary files /dev/null and b/priv/static/ms-icon-144x144.png differ
diff --git a/priv/static/ms-icon-150x150.png b/priv/static/ms-icon-150x150.png
new file mode 100644
index 00000000..93144417
Binary files /dev/null and b/priv/static/ms-icon-150x150.png differ
diff --git a/priv/static/ms-icon-310x310.png b/priv/static/ms-icon-310x310.png
new file mode 100644
index 00000000..029e2407
Binary files /dev/null and b/priv/static/ms-icon-310x310.png differ
diff --git a/priv/static/ms-icon-70x70.png b/priv/static/ms-icon-70x70.png
new file mode 100644
index 00000000..32dc6f17
Binary files /dev/null and b/priv/static/ms-icon-70x70.png differ
diff --git a/test/towerops/monitoring/check_test.exs b/test/towerops/monitoring/check_test.exs
index 88786abf..8df77a5b 100644
--- a/test/towerops/monitoring/check_test.exs
+++ b/test/towerops/monitoring/check_test.exs
@@ -1,5 +1,6 @@
defmodule Towerops.Monitoring.CheckTest do
- use Towerops.DataCase, async: true
+ # async: false to prevent deadlocks when testing foreign key constraints
+ use Towerops.DataCase, async: false
import Towerops.AccountsFixtures
diff --git a/test/towerops/snmp/processor_test.exs b/test/towerops/snmp/processor_test.exs
new file mode 100644
index 00000000..307f14d9
--- /dev/null
+++ b/test/towerops/snmp/processor_test.exs
@@ -0,0 +1,214 @@
+defmodule Towerops.Snmp.ProcessorTest do
+ use Towerops.DataCase, async: true
+
+ alias Towerops.Snmp.Processor
+
+ describe "changeset/2" do
+ setup do
+ # Create a valid snmp_device_id for testing
+ snmp_device_id = Ecto.UUID.generate()
+ {:ok, snmp_device_id: snmp_device_id}
+ end
+
+ test "valid changeset with required fields", %{snmp_device_id: snmp_device_id} do
+ attrs = %{
+ snmp_device_id: snmp_device_id,
+ processor_index: "hr_1",
+ processor_type: "hr_processor"
+ }
+
+ changeset = Processor.changeset(%Processor{}, attrs)
+
+ assert changeset.valid?
+ assert get_change(changeset, :processor_index) == "hr_1"
+ assert get_change(changeset, :processor_type) == "hr_processor"
+ end
+
+ test "valid changeset with all fields", %{snmp_device_id: snmp_device_id} do
+ attrs = %{
+ snmp_device_id: snmp_device_id,
+ processor_index: "cisco_1",
+ description: "Cisco CPU Core 1",
+ processor_type: "cisco_cpu",
+ load_percent: 45.5,
+ last_checked_at: DateTime.utc_now(),
+ metadata: %{cores: 4}
+ }
+
+ changeset = Processor.changeset(%Processor{}, attrs)
+
+ assert changeset.valid?
+ assert get_change(changeset, :description) == "Cisco CPU Core 1"
+ assert get_change(changeset, :load_percent) == 45.5
+ assert get_change(changeset, :metadata) == %{cores: 4}
+ end
+
+ test "requires snmp_device_id" do
+ attrs = %{
+ processor_index: "hr_1",
+ processor_type: "hr_processor"
+ }
+
+ changeset = Processor.changeset(%Processor{}, attrs)
+
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).snmp_device_id
+ end
+
+ test "requires processor_index", %{snmp_device_id: snmp_device_id} do
+ attrs = %{
+ snmp_device_id: snmp_device_id,
+ processor_type: "hr_processor"
+ }
+
+ changeset = Processor.changeset(%Processor{}, attrs)
+
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).processor_index
+ end
+
+ test "requires processor_type", %{snmp_device_id: snmp_device_id} do
+ attrs = %{
+ snmp_device_id: snmp_device_id,
+ processor_index: "hr_1"
+ }
+
+ changeset = Processor.changeset(%Processor{}, attrs)
+
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).processor_type
+ end
+
+ test "validates processor_type inclusion", %{snmp_device_id: snmp_device_id} do
+ attrs = %{
+ snmp_device_id: snmp_device_id,
+ processor_index: "invalid_1",
+ processor_type: "invalid_type"
+ }
+
+ changeset = Processor.changeset(%Processor{}, attrs)
+
+ refute changeset.valid?
+ assert "is invalid" in errors_on(changeset).processor_type
+ end
+
+ test "accepts all valid processor types", %{snmp_device_id: snmp_device_id} do
+ for processor_type <- ~w(hr_processor cisco_cpu ucd_cpu) do
+ attrs = %{
+ snmp_device_id: snmp_device_id,
+ processor_index: "test_1",
+ processor_type: processor_type
+ }
+
+ changeset = Processor.changeset(%Processor{}, attrs)
+ assert changeset.valid?, "Expected #{processor_type} to be valid"
+ end
+ end
+
+ test "validates load_percent is between 0 and 100", %{snmp_device_id: snmp_device_id} do
+ # Valid at 0
+ attrs = %{
+ snmp_device_id: snmp_device_id,
+ processor_index: "hr_1",
+ processor_type: "hr_processor",
+ load_percent: 0.0
+ }
+
+ changeset = Processor.changeset(%Processor{}, attrs)
+ assert changeset.valid?
+
+ # Valid at 100
+ attrs = %{attrs | load_percent: 100.0}
+ changeset = Processor.changeset(%Processor{}, attrs)
+ assert changeset.valid?
+
+ # Valid in middle
+ attrs = %{attrs | load_percent: 50.5}
+ changeset = Processor.changeset(%Processor{}, attrs)
+ assert changeset.valid?
+
+ # Invalid above 100
+ attrs = %{attrs | load_percent: 100.1}
+ changeset = Processor.changeset(%Processor{}, attrs)
+ refute changeset.valid?
+ assert "must be between 0 and 100" in errors_on(changeset).load_percent
+
+ # Invalid below 0
+ attrs = %{attrs | load_percent: -1.0}
+ changeset = Processor.changeset(%Processor{}, attrs)
+ refute changeset.valid?
+ assert "must be between 0 and 100" in errors_on(changeset).load_percent
+ end
+
+ test "allows nil load_percent", %{snmp_device_id: snmp_device_id} do
+ attrs = %{
+ snmp_device_id: snmp_device_id,
+ processor_index: "hr_1",
+ processor_type: "hr_processor",
+ load_percent: nil
+ }
+
+ changeset = Processor.changeset(%Processor{}, attrs)
+ assert changeset.valid?
+ end
+
+ test "description can be nil", %{snmp_device_id: snmp_device_id} do
+ attrs = %{
+ snmp_device_id: snmp_device_id,
+ processor_index: "hr_1",
+ processor_type: "hr_processor",
+ description: nil
+ }
+
+ changeset = Processor.changeset(%Processor{}, attrs)
+ assert changeset.valid?
+ end
+
+ test "metadata defaults to empty map", %{snmp_device_id: snmp_device_id} do
+ attrs = %{
+ snmp_device_id: snmp_device_id,
+ processor_index: "ucd_0",
+ processor_type: "ucd_cpu"
+ }
+
+ changeset = Processor.changeset(%Processor{}, attrs)
+ assert changeset.valid?
+
+ # Default comes from schema, not changeset
+ processor = %Processor{}
+ assert processor.metadata == %{}
+ end
+ end
+
+ describe "schema" do
+ test "has correct fields" do
+ fields = Processor.__schema__(:fields)
+ assert :id in fields
+ assert :snmp_device_id in fields
+ assert :processor_index in fields
+ assert :description in fields
+ assert :processor_type in fields
+ assert :load_percent in fields
+ assert :last_checked_at in fields
+ assert :metadata in fields
+ assert :inserted_at in fields
+ assert :updated_at in fields
+ end
+
+ test "belongs to snmp_device" do
+ assocs = Processor.__schema__(:associations)
+ assert :snmp_device in assocs
+
+ assoc = Processor.__schema__(:association, :snmp_device)
+ assert assoc.queryable == Towerops.Snmp.Device
+ end
+
+ test "uses binary_id for primary key" do
+ assert Processor.__schema__(:type, :id) == :binary_id
+ end
+
+ test "uses binary_id for foreign keys" do
+ assert Processor.__schema__(:type, :snmp_device_id) == :binary_id
+ end
+ end
+end
diff --git a/test/towerops/snmp/profiles/base_test.exs b/test/towerops/snmp/profiles/base_test.exs
index 257e93a5..eb08d98f 100644
--- a/test/towerops/snmp/profiles/base_test.exs
+++ b/test/towerops/snmp/profiles/base_test.exs
@@ -89,8 +89,9 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
]}
end)
- # Mock interface data for each interface (8 OIDs per interface × 3 interfaces = 24 calls)
- expect(SnmpMock, :get, 24, fn _, oid, _ ->
+ # Mock interface data for each interface (9 OIDs per interface × 3 interfaces = 27 calls)
+ # OIDs: ifDescr, ifType, ifSpeed, ifPhysAddress, ifAdminStatus, ifOperStatus, ifName, ifHighSpeed, ifAlias
+ expect(SnmpMock, :get, 27, fn _, oid, _ ->
cond do
String.ends_with?(oid, ".1") ->
parts = String.split(oid, ".")
@@ -104,6 +105,7 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 1}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "eth0"}}
+ second_to_last == "15" and String.contains?(oid, "31.1.1.1") -> {:ok, {:gauge32, 1000}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "WAN"}}
true -> {:error, :no_such_object}
end
@@ -120,6 +122,7 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 2}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "eth1"}}
+ second_to_last == "15" and String.contains?(oid, "31.1.1.1") -> {:ok, {:gauge32, 100}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "LAN"}}
true -> {:error, :no_such_object}
end
@@ -136,6 +139,7 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
second_to_last == "7" -> {:ok, {:integer, 1}}
second_to_last == "8" -> {:ok, {:integer, 1}}
second_to_last == "1" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "lo"}}
+ second_to_last == "15" and String.contains?(oid, "31.1.1.1") -> {:ok, {:gauge32, 10}}
second_to_last == "18" and String.contains?(oid, "31.1.1.1") -> {:ok, {:octet_string, "Loopback"}}
true -> {:error, :no_such_object}
end
@@ -884,6 +888,122 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
end
end
+ describe "discover_ipv6_addresses/1" do
+ test "discovers IPv6 addresses from IPV6-MIB ipv6AddrTable" do
+ stub(SnmpMock, :walk, fn _, oid, _ ->
+ case oid do
+ # ipAddressIfIndex - modern RFC 4293 table (empty, fallback to IPV6-MIB)
+ "1.3.6.1.2.1.4.34.1.3" ->
+ {:ok, []}
+
+ # ipv6AddrPfxLength - indexed by ifIndex.ipv6Address
+ # Example: interface 2 with address 2001:db8::1 (16 octets)
+ "1.3.6.1.2.1.55.1.8.1.2" ->
+ {:ok,
+ [
+ # ifIndex=2, IPv6=2001:0db8:0000:0000:0000:0000:0000:0001
+ %{
+ oid: "1.3.6.1.2.1.55.1.8.1.2.2.32.1.13.184.0.0.0.0.0.0.0.0.0.0.0.1",
+ value: {:integer, 64}
+ },
+ # ifIndex=3, IPv6=fe80::1
+ %{
+ oid: "1.3.6.1.2.1.55.1.8.1.2.3.254.128.0.0.0.0.0.0.0.0.0.0.0.0.0.1",
+ value: {:integer, 64}
+ }
+ ]}
+
+ _ ->
+ {:ok, []}
+ end
+ end)
+
+ assert {:ok, ip_addresses} = Base.discover_ipv6_addresses(@client_opts)
+
+ assert length(ip_addresses) == 2
+
+ ipv6_1 = Enum.find(ip_addresses, &(&1.if_index == 2))
+ assert ipv6_1.ip_address == "2001:db8:0:0:0:0:0:1"
+ assert ipv6_1.prefix_length == 64
+ assert ipv6_1.ip_type == "ipv6"
+
+ ipv6_2 = Enum.find(ip_addresses, &(&1.if_index == 3))
+ assert ipv6_2.ip_address == "fe80:0:0:0:0:0:0:1"
+ assert ipv6_2.prefix_length == 64
+ end
+
+ test "returns empty list when IPV6-MIB not supported" do
+ stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
+
+ assert {:ok, ip_addresses} = Base.discover_ipv6_addresses(@client_opts)
+ assert ip_addresses == []
+ end
+
+ test "handles walk errors gracefully" do
+ stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end)
+
+ assert {:ok, ip_addresses} = Base.discover_ipv6_addresses(@client_opts)
+ assert ip_addresses == []
+ end
+ end
+
+ describe "discover_all_ip_addresses/1" do
+ test "combines IPv4 and IPv6 addresses" do
+ stub(SnmpMock, :walk, fn _, oid, _ ->
+ case oid do
+ # IPv4 from IP-MIB
+ "1.3.6.1.2.1.4.20.1.2" ->
+ {:ok,
+ [
+ %{oid: "1.3.6.1.2.1.4.20.1.2.192.168.1.1", value: {:integer, 1}}
+ ]}
+
+ "1.3.6.1.2.1.4.20.1.3" ->
+ {:ok,
+ [
+ %{oid: "1.3.6.1.2.1.4.20.1.3.192.168.1.1", value: {:ip_address, {255, 255, 255, 0}}}
+ ]}
+
+ # IPv6 from modern ipAddressTable (empty, fallback)
+ "1.3.6.1.2.1.4.34.1.3" ->
+ {:ok, []}
+
+ # IPv6 from IPV6-MIB
+ "1.3.6.1.2.1.55.1.8.1.2" ->
+ {:ok,
+ [
+ %{
+ oid: "1.3.6.1.2.1.55.1.8.1.2.1.254.128.0.0.0.0.0.0.0.0.0.0.0.0.0.1",
+ value: {:integer, 64}
+ }
+ ]}
+
+ _ ->
+ {:ok, []}
+ end
+ end)
+
+ assert {:ok, all_addresses} = Base.discover_all_ip_addresses(@client_opts)
+
+ assert length(all_addresses) == 2
+
+ ipv4 = Enum.find(all_addresses, &(&1.ip_type == "ipv4"))
+ assert ipv4.ip_address == "192.168.1.1"
+ assert ipv4.prefix_length == 24
+
+ ipv6 = Enum.find(all_addresses, &(&1.ip_type == "ipv6"))
+ assert ipv6.ip_address == "fe80:0:0:0:0:0:0:1"
+ assert ipv6.prefix_length == 64
+ end
+
+ test "returns empty list when no addresses found" do
+ stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
+
+ assert {:ok, all_addresses} = Base.discover_all_ip_addresses(@client_opts)
+ assert all_addresses == []
+ end
+ end
+
describe "discover_memory_pools/1" do
test "discovers memory pools from HOST-RESOURCES-MIB" do
stub(SnmpMock, :walk, fn _, oid, _ ->
@@ -1326,4 +1446,153 @@ defmodule Towerops.Snmp.Profiles.BaseTest do
end
end
end
+
+ describe "discover_processors/1" do
+ test "discovers processors from HOST-RESOURCES-MIB" do
+ # Mock hrProcessorLoad walk
+ expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ ->
+ {:ok,
+ [
+ %{oid: "1.3.6.1.2.1.25.3.3.1.2.768", value: {:integer, 25}},
+ %{oid: "1.3.6.1.2.1.25.3.3.1.2.769", value: {:integer, 45}}
+ ]}
+ end)
+
+ # Mock hrDeviceDescr walk for processor descriptions
+ expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.2.1.3", _ ->
+ {:ok,
+ [
+ %{oid: "1.3.6.1.2.1.25.3.2.1.3.768", value: {:octet_string, "Intel Core i7-9700 @ 3.00GHz"}},
+ %{oid: "1.3.6.1.2.1.25.3.2.1.3.769", value: {:octet_string, "Intel Core i7-9700 @ 3.00GHz"}}
+ ]}
+ end)
+
+ assert {:ok, processors} = Base.discover_processors(@client_opts)
+
+ assert length(processors) == 2
+
+ cpu1 = Enum.find(processors, &(&1.processor_index == "hr_768"))
+ assert cpu1.description == "Intel Core i7-9700 @ 3.00GHz"
+ assert cpu1.processor_type == "hr_processor"
+ assert cpu1.load_percent == 25.0
+
+ cpu2 = Enum.find(processors, &(&1.processor_index == "hr_769"))
+ assert cpu2.description == "Intel Core i7-9700 @ 3.00GHz"
+ assert cpu2.processor_type == "hr_processor"
+ assert cpu2.load_percent == 45.0
+ end
+
+ test "falls back to CISCO-PROCESS-MIB when HOST-RESOURCES-MIB returns empty" do
+ # HR-MIB returns empty
+ expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ ->
+ {:ok, []}
+ end)
+
+ # Cisco CPU walk returns data
+ expect(SnmpMock, :walk, fn _, "1.3.6.1.4.1.9.9.109.1.1.1.1.5", _ ->
+ {:ok,
+ [
+ %{oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.5.1", value: {:integer, 15}},
+ %{oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.5.2", value: {:integer, 22}}
+ ]}
+ end)
+
+ assert {:ok, processors} = Base.discover_processors(@client_opts)
+
+ assert length(processors) == 2
+
+ cpu1 = Enum.find(processors, &(&1.processor_index == "cisco_1"))
+ assert cpu1.description == "Cisco CPU 1"
+ assert cpu1.processor_type == "cisco_cpu"
+ assert cpu1.load_percent == 15.0
+
+ cpu2 = Enum.find(processors, &(&1.processor_index == "cisco_2"))
+ assert cpu2.description == "Cisco CPU 2"
+ assert cpu2.processor_type == "cisco_cpu"
+ assert cpu2.load_percent == 22.0
+ end
+
+ test "falls back to UCD-SNMP-MIB when other MIBs return empty" do
+ # HR-MIB returns empty
+ expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ ->
+ {:ok, []}
+ end)
+
+ # Cisco CPU returns empty
+ expect(SnmpMock, :walk, fn _, "1.3.6.1.4.1.9.9.109.1.1.1.1.5", _ ->
+ {:ok, []}
+ end)
+
+ # UCD-SNMP-MIB CPU stats
+ expect(SnmpMock, :get, 3, fn _, oid, _ ->
+ case oid do
+ "1.3.6.1.4.1.2021.11.9.0" -> {:ok, {:integer, 10}}
+ "1.3.6.1.4.1.2021.11.10.0" -> {:ok, {:integer, 5}}
+ "1.3.6.1.4.1.2021.11.11.0" -> {:ok, {:integer, 85}}
+ end
+ end)
+
+ assert {:ok, processors} = Base.discover_processors(@client_opts)
+
+ assert length(processors) == 1
+
+ cpu = hd(processors)
+ assert cpu.processor_index == "ucd_0"
+ assert cpu.description == "System CPU"
+ assert cpu.processor_type == "ucd_cpu"
+ assert cpu.load_percent == 15.0
+ assert cpu.metadata.user_percent == 10
+ assert cpu.metadata.system_percent == 5
+ end
+
+ test "returns empty list when no CPU MIBs are supported" do
+ # All walks return empty
+ stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
+ stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end)
+
+ assert {:ok, processors} = Base.discover_processors(@client_opts)
+ assert processors == []
+ end
+
+ test "handles HOST-RESOURCES-MIB walk errors gracefully" do
+ # HR-MIB walk fails
+ expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ ->
+ {:error, :timeout}
+ end)
+
+ # Falls back to Cisco
+ expect(SnmpMock, :walk, fn _, "1.3.6.1.4.1.9.9.109.1.1.1.1.5", _ ->
+ {:ok,
+ [
+ %{oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.5.1", value: {:integer, 30}}
+ ]}
+ end)
+
+ assert {:ok, processors} = Base.discover_processors(@client_opts)
+ assert length(processors) == 1
+ assert hd(processors).processor_type == "cisco_cpu"
+ end
+
+ test "uses default description when hrDeviceDescr is missing" do
+ # HR processor load
+ expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.3.1.2", _ ->
+ {:ok,
+ [
+ %{oid: "1.3.6.1.2.1.25.3.3.1.2.768", value: {:integer, 50}}
+ ]}
+ end)
+
+ # HR device description returns empty
+ expect(SnmpMock, :walk, fn _, "1.3.6.1.2.1.25.3.2.1.3", _ ->
+ {:ok, []}
+ end)
+
+ assert {:ok, processors} = Base.discover_processors(@client_opts)
+
+ assert length(processors) == 1
+ cpu = hd(processors)
+ assert cpu.description == "CPU 768"
+ assert cpu.load_percent == 50.0
+ end
+ end
end
diff --git a/test/towerops/snmp/sanitizer_test.exs b/test/towerops/snmp/sanitizer_test.exs
new file mode 100644
index 00000000..ec1ef064
--- /dev/null
+++ b/test/towerops/snmp/sanitizer_test.exs
@@ -0,0 +1,137 @@
+defmodule Towerops.Snmp.SanitizerTest do
+ use ExUnit.Case, async: true
+
+ alias Towerops.Snmp.Sanitizer
+
+ describe "sanitize_string/1" do
+ test "returns nil for nil input" do
+ assert Sanitizer.sanitize_string(nil) == nil
+ end
+
+ test "returns trimmed string for printable input" do
+ assert Sanitizer.sanitize_string(" Hello World ") == "Hello World"
+ end
+
+ test "passes through normal strings" do
+ assert Sanitizer.sanitize_string("test string") == "test string"
+ end
+
+ test "converts non-printable binary to hex string" do
+ # Raw binary bytes (e.g., MAC address)
+ assert Sanitizer.sanitize_string(<<255, 0, 128, 64>>) == "ff:00:80:40"
+ end
+
+ test "converts erlang charlist to string" do
+ assert Sanitizer.sanitize_string(~c"hello") == "hello"
+ end
+
+ test "converts integers to string" do
+ assert Sanitizer.sanitize_string(123) == "123"
+ end
+
+ test "handles mixed printable/non-printable as hex" do
+ # String with null byte - not printable
+ assert Sanitizer.sanitize_string(<<"test", 0>>) == "74:65:73:74:00"
+ end
+ end
+
+ describe "sanitize_ipv4/1" do
+ test "formats tuple as dotted decimal" do
+ assert Sanitizer.sanitize_ipv4({192, 168, 1, 1}) == "192.168.1.1"
+ end
+
+ test "formats 4-byte binary as dotted decimal" do
+ assert Sanitizer.sanitize_ipv4(<<192, 168, 1, 1>>) == "192.168.1.1"
+ end
+
+ test "passes through already-formatted string" do
+ assert Sanitizer.sanitize_ipv4("10.0.0.1") == "10.0.0.1"
+ end
+
+ test "extracts first 4 bytes from longer binary" do
+ assert Sanitizer.sanitize_ipv4(<<192, 168, 1, 1, 0, 0>>) == "192.168.1.1"
+ end
+
+ test "returns nil for invalid input" do
+ assert Sanitizer.sanitize_ipv4("not an ip") == nil
+ assert Sanitizer.sanitize_ipv4(nil) == nil
+ assert Sanitizer.sanitize_ipv4(123) == nil
+ end
+
+ test "handles subnet mask with 0xff bytes" do
+ # This was the original bug - 0xff is not valid UTF-8
+ assert Sanitizer.sanitize_ipv4(<<255, 255, 255, 0>>) == "255.255.255.0"
+ end
+ end
+
+ describe "sanitize_ipv6/1" do
+ test "formats 16-byte binary as IPv6" do
+ # ::1 (localhost)
+ bytes = <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1>>
+ assert Sanitizer.sanitize_ipv6(bytes) == "0:0:0:0:0:0:0:1"
+ end
+
+ test "passes through already-formatted string" do
+ assert Sanitizer.sanitize_ipv6("2001:db8::1") == "2001:db8::1"
+ end
+
+ test "returns nil for invalid input" do
+ assert Sanitizer.sanitize_ipv6("not an ipv6") == nil
+ assert Sanitizer.sanitize_ipv6(nil) == nil
+ end
+ end
+
+ describe "sanitize_mac/1" do
+ test "formats 6-byte binary as colon-separated hex" do
+ assert Sanitizer.sanitize_mac(<<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>) == "aa:bb:cc:dd:ee:ff"
+ end
+
+ test "normalizes dash-separated to colon-separated" do
+ assert Sanitizer.sanitize_mac("AA-BB-CC-DD-EE-FF") == "aa:bb:cc:dd:ee:ff"
+ end
+
+ test "passes through already-formatted lowercase" do
+ assert Sanitizer.sanitize_mac("aa:bb:cc:dd:ee:ff") == "aa:bb:cc:dd:ee:ff"
+ end
+
+ test "lowercases uppercase input" do
+ assert Sanitizer.sanitize_mac("AA:BB:CC:DD:EE:FF") == "aa:bb:cc:dd:ee:ff"
+ end
+
+ test "returns nil for invalid input" do
+ assert Sanitizer.sanitize_mac("not a mac") == nil
+ assert Sanitizer.sanitize_mac(nil) == nil
+ assert Sanitizer.sanitize_mac(<<1, 2, 3>>) == nil
+ end
+ end
+
+ describe "sanitize_map/1" do
+ test "sanitizes all string values in map" do
+ input = %{
+ name: " Test Name ",
+ value: 123,
+ binary: <<255, 0>>
+ }
+
+ result = Sanitizer.sanitize_map(input)
+
+ assert result.name == "Test Name"
+ assert result.value == 123
+ assert result.binary == "ff:00"
+ end
+
+ test "recursively sanitizes nested maps" do
+ input = %{
+ outer: " outer ",
+ nested: %{
+ inner: " inner "
+ }
+ }
+
+ result = Sanitizer.sanitize_map(input)
+
+ assert result.outer == "outer"
+ assert result.nested.inner == "inner"
+ end
+ end
+end
diff --git a/test/towerops/workers/discovery_worker_test.exs b/test/towerops/workers/discovery_worker_test.exs
index 0a79e17e..63a7741b 100644
--- a/test/towerops/workers/discovery_worker_test.exs
+++ b/test/towerops/workers/discovery_worker_test.exs
@@ -36,16 +36,20 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
end
test "successfully discovers a device", %{device: device} do
- # Mock SNMP responses for discovery (categorize_device_speed + test_connection + system_info = 8 calls)
- expect(SnmpMock, :get, 8, fn _target, oid, _opts ->
+ # Mock SNMP responses - use stub for flexibility as discovery calls vary
+ stub(SnmpMock, :get, fn _target, oid, _opts ->
case oid do
"1.3.6.1.2.1.1.1.0" -> {:ok, "Test Device"}
"1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1, 4, 1, 9]}
- # sysUpTime (also used by test_connection)
"1.3.6.1.2.1.1.3.0" -> {:ok, 12_345}
"1.3.6.1.2.1.1.4.0" -> {:ok, "admin@test.com"}
"1.3.6.1.2.1.1.5.0" -> {:ok, "test-device"}
"1.3.6.1.2.1.1.6.0" -> {:ok, "Test Location"}
+ # UCD-SNMP-MIB CPU stats (processor discovery fallback)
+ "1.3.6.1.4.1.2021.11.9.0" -> {:ok, 10}
+ "1.3.6.1.4.1.2021.11.10.0" -> {:ok, 5}
+ "1.3.6.1.4.1.2021.11.11.0" -> {:ok, 85}
+ _ -> {:error, :timeout}
end
end)
@@ -82,8 +86,8 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
end
test "successfully completes discovery with proper mocks", %{device: device} do
- # Mock SNMP responses (categorize_device_speed + test_connection + system_info = 8 calls)
- expect(SnmpMock, :get, 8, fn _target, oid, _opts ->
+ # Mock SNMP responses - use stub for flexibility as discovery calls vary
+ stub(SnmpMock, :get, fn _target, oid, _opts ->
case oid do
"1.3.6.1.2.1.1.1.0" -> {:ok, "Test"}
"1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1]}
@@ -91,6 +95,11 @@ defmodule Towerops.Workers.DiscoveryWorkerTest do
"1.3.6.1.2.1.1.4.0" -> {:ok, ""}
"1.3.6.1.2.1.1.5.0" -> {:ok, "test"}
"1.3.6.1.2.1.1.6.0" -> {:ok, ""}
+ # UCD-SNMP-MIB CPU stats (processor discovery fallback)
+ "1.3.6.1.4.1.2021.11.9.0" -> {:ok, 10}
+ "1.3.6.1.4.1.2021.11.10.0" -> {:ok, 5}
+ "1.3.6.1.4.1.2021.11.11.0" -> {:ok, 85}
+ _ -> {:error, :timeout}
end
end)