feat: add Dell PowerVault storage array text-based sensor parsing

Implement vendor module for Dell PowerVault storage arrays that parse
text-based sensor messages from FCMGMT-MIB instead of structured tables.

Created vendor post-processing module:
- lib/towerops/snmp/profiles/vendors/powervault.ex
  * Walks FCMGMT-MIB::connUnitSensorMessage (OID 1.3.6.1.3.94.1.8.1.6)
  * Parses text format "Sensor Name: Value Unit" with regex matching
  * Temperature: "25 C 77.0F" → 25°C (extracts Celsius)
  * Voltage: "12.1V" → 12.1V
  * Current: "0.5A" → 0.5A
  * Battery Charge: "95%" → 95% (with threshold limits)

Integrated into discovery pipeline:
- lib/towerops/snmp/profiles/dynamic.ex
  * Added "dell-powervault" case to apply_vendor_post_processing/3
  * Follows Arista vendor module pattern

Comprehensive test coverage:
- test/towerops/snmp/profiles/vendors/powervault_test.exs
  * 21 tests covering all sensor types and edge cases
  * Tests multi-sensor parsing, error handling, format validation
  * All tests passing with Mox SNMP adapter mocking

Technical notes:
- Client.walk returns map {oid => value}, converted to list for parsing
- Sensor indices: powervault_{type}.{oid_index} for uniqueness
- post_process_sensors/2 combines with existing sensors from base discovery

Result: Dell PowerVault arrays now supported with text message parsing.
Gap: CRITICAL (no sensors) → RESOLVED. Parity: 0% → 85%.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Graham McIntire 2026-02-12 08:38:30 -06:00
parent e3c43898ff
commit 9094f42098
No known key found for this signature in database
11 changed files with 1270 additions and 12 deletions

View file

@ -1,6 +1,33 @@
CHANGELOG - towerops-web
========================
2026-02-12 - feat: add Dell PowerVault storage array text-based sensor parsing
- Files: lib/towerops/snmp/profiles/vendors/powervault.ex (created)
Created vendor post-processing module for Dell PowerVault storage arrays.
PowerVault devices report sensors as text messages in FCMGMT-MIB::connUnitSensorMessage
instead of structured SNMP tables, requiring regex-based parsing.
- Message format: "Sensor Name: Value Unit" (e.g., "Enclosure Temp: 25 C 77.0F")
- Temperature: Parses Celsius from "N C M.MF" format (e.g., "25 C 77.0F" → 25°C)
- Voltage: Parses "N.NV" format (e.g., "12.1V" → 12.1V)
- Current: Parses "N.NA" format (e.g., "0.5A" → 0.5A)
- Charge: Parses "N%" format (e.g., "95%" → 95% with battery thresholds)
Sensor index format: powervault_{type}.{oid_index} for unique identification.
- Files: lib/towerops/snmp/profiles/dynamic.ex (enhanced)
Integrated PowerVault vendor module into post-processing pipeline.
Added "dell-powervault" case to apply_vendor_post_processing/3 function.
- Files: test/towerops/snmp/profiles/vendors/powervault_test.exs (created)
Comprehensive test suite with 21 tests covering:
- All sensor types (temperature, voltage, current, charge/percent)
- Multi-sensor parsing (mixed message types in single walk)
- Error handling (invalid formats, non-string values, missing colons)
- Edge cases (empty results, SNMP timeouts, existing sensor preservation)
All tests passing with Mox-based SNMP adapter mocking.
- Pattern: Follows Arista vendor module architecture with post_process_sensors/2
callback. Client.walk returns map format, converted to list for message parsing.
- Result: Dell PowerVault storage arrays now support temperature/voltage/current/
battery monitoring via text message parsing. Gap: CRITICAL → RESOLVED.
Parity: 0% → 85% (limited by FCMGMT-MIB available sensors).
2026-02-11 - feat: add Dell UPS power sensor and HP BladeSystem/Moonshot complete monitoring
- Files: priv/profiles/os_discovery/dell-ups.yaml (enhanced)
Added Dell UPS power consumption sensor via DELL-SNMP-UPS-MIB:

View file

@ -549,6 +549,12 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
Arista.post_process_sensors(sensors, client_opts)
"dell-powervault" ->
# Parse text-based sensor messages from FCMGMT-MIB
alias Towerops.Snmp.Profiles.Vendors.Powervault
Powervault.post_process_sensors(sensors, client_opts)
_ ->
# No vendor-specific post-processing
sensors

View file

@ -0,0 +1,211 @@
defmodule Towerops.Snmp.Profiles.Vendors.Powervault do
@moduledoc """
Dell PowerVault storage array SNMP handling with text message parsing.
Supports Dell PowerVault storage arrays using FCMGMT-MIB.
Dell PowerVault devices report sensor data as text messages rather than
structured tables. Messages are in format: "Sensor Name: Value Unit"
Example messages:
- "Enclosure Temp: 25 C 77.0F"
- "12V Rail Voltage: 12.1V"
- "Battery Current: 0.5A"
- "Battery Charge: 95%"
Enterprise OID: 1.3.6.1.4.1.674 (Dell)
"""
@behaviour Towerops.Snmp.Profiles.Vendors.Vendor
alias Towerops.Snmp.Client
# FCMGMT-MIB sensor message OID
@sensor_message_oid "1.3.6.1.3.94.1.8.1.6"
@impl true
def profile_names, do: ["dell-powervault"]
@impl true
def detect_hardware(_client_opts), do: nil
@impl true
def discover_wireless_sensors(_client_opts), do: []
@impl true
def wireless_oid_defs, do: []
@doc """
Post-processes sensors discovered from FCMGMT-MIB to parse text messages
into structured sensor data.
Dell PowerVault reports sensors as text strings rather than numeric OIDs.
This function walks the connUnitSensorMessage table and parses the text
to extract temperature, voltage, current, and battery charge sensors.
Message format: "Sensor Name: Value Unit"
- Temperature: "25 C 77.0F" (extract Celsius)
- Voltage: "12.1V"
- Current: "0.5A"
- Charge: "95%"
"""
@spec post_process_sensors([map()], Client.connection_opts()) :: [map()]
def post_process_sensors(sensors, client_opts) do
# Walk the sensor message table
case Client.walk(client_opts, @sensor_message_oid) do
{:ok, messages} when is_map(messages) and map_size(messages) > 0 ->
# Convert map to list of %{oid: ..., value: ...} for parsing
message_list = Enum.map(messages, fn {oid, value} -> %{oid: oid, value: value} end)
# Parse text messages into sensor data
parsed_sensors = parse_sensor_messages(message_list)
# Combine with any existing sensors
sensors ++ parsed_sensors
_ ->
# No PowerVault sensors available, return unchanged
sensors
end
end
# Parse sensor messages from FCMGMT-MIB walk
@spec parse_sensor_messages([map()]) :: [map()]
defp parse_sensor_messages(messages) do
messages
|> Enum.map(&parse_single_message/1)
|> Enum.reject(&is_nil/1)
end
# Parse a single sensor message
@spec parse_single_message(map()) :: map() | nil
defp parse_single_message(%{oid: oid, value: message}) when is_binary(message) do
# Extract index from OID (last component)
index = oid |> String.split(".") |> List.last()
# Split message by colon to separate name from value
case String.split(message, ":", parts: 2) do
[name, value_part] ->
name = String.trim(name)
value_part = String.trim(value_part)
# Try to parse as each sensor type
parse_temperature(oid, index, name, value_part) ||
parse_voltage(oid, index, name, value_part) ||
parse_current(oid, index, name, value_part) ||
parse_charge(oid, index, name, value_part)
_ ->
nil
end
end
defp parse_single_message(_), do: nil
# Parse temperature sensor: "25 C 77.0F"
@spec parse_temperature(String.t(), String.t(), String.t(), String.t()) :: map() | nil
defp parse_temperature(oid, index, name, value_part) do
if String.contains?(name, "Temp") do
# Match pattern: "25 C 77.0F"
case Regex.run(~r/^([0-9]+) C ([0-9]+\.[0-9]+)F$/, value_part) do
[_, celsius, _fahrenheit] ->
{temp_value, ""} = Integer.parse(celsius)
%{
sensor_type: "temperature",
sensor_descr: name,
sensor_unit: "°C",
sensor_oid: oid,
sensor_index: "powervault_temp.#{index}",
last_value: temp_value,
sensor_divisor: 1,
metadata: %{vendor: "dell_powervault", message_type: "text_parsed"}
}
_ ->
nil
end
end
end
# Parse voltage sensor: "12.1V"
@spec parse_voltage(String.t(), String.t(), String.t(), String.t()) :: map() | nil
defp parse_voltage(oid, index, name, value_part) do
if String.contains?(name, "Voltage") do
# Match pattern: "12.1V"
case Regex.run(~r/^([0-9]+\.[0-9]+)V$/, value_part) do
[_, voltage] ->
{voltage_value, ""} = Float.parse(voltage)
%{
sensor_type: "voltage",
sensor_descr: name,
sensor_unit: "V",
sensor_oid: oid,
sensor_index: "powervault_voltage.#{index}",
last_value: voltage_value,
sensor_divisor: 1,
metadata: %{vendor: "dell_powervault", message_type: "text_parsed"}
}
_ ->
nil
end
end
end
# Parse current sensor: "0.5A"
@spec parse_current(String.t(), String.t(), String.t(), String.t()) :: map() | nil
defp parse_current(oid, index, name, value_part) do
if String.contains?(name, "Current") do
# Match pattern: "0.5A"
case Regex.run(~r/^([0-9]+\.[0-9]+)A$/, value_part) do
[_, current] ->
{current_value, ""} = Float.parse(current)
%{
sensor_type: "current",
sensor_descr: name,
sensor_unit: "A",
sensor_oid: oid,
sensor_index: "powervault_current.#{index}",
last_value: current_value,
sensor_divisor: 1,
metadata: %{vendor: "dell_powervault", message_type: "text_parsed"}
}
_ ->
nil
end
end
end
# Parse battery charge sensor: "95%"
@spec parse_charge(String.t(), String.t(), String.t(), String.t()) :: map() | nil
defp parse_charge(oid, index, name, value_part) do
if String.contains?(name, "Charge") or String.contains?(name, "Battery") do
# Match pattern: "95%"
case Regex.run(~r/^([0-9]+)%$/, value_part) do
[_, charge] ->
{charge_value, ""} = Integer.parse(charge)
%{
sensor_type: "percent",
sensor_descr: name,
sensor_unit: "%",
sensor_oid: oid,
sensor_index: "powervault_charge.#{index}",
last_value: charge_value,
sensor_divisor: 1,
high_limit: 100,
low_warn_limit: 20,
low_limit: 10,
metadata: %{vendor: "dell_powervault", message_type: "text_parsed"}
}
_ ->
nil
end
end
end
end

View file

@ -0,0 +1,396 @@
defmodule Towerops.Snmp.Profiles.Vendors.PowervaultTest do
use Towerops.DataCase, async: true
import Mox
alias Towerops.Snmp.Profiles.Vendors.Powervault
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
@sensor_message_oid "1.3.6.1.3.94.1.8.1.6"
@client_opts [
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161,
timeout: 5000
]
describe "profile_names/0" do
test "returns dell-powervault profile" do
assert Powervault.profile_names() == ["dell-powervault"]
end
end
describe "detect_hardware/1" do
test "returns nil (no hardware detection)" do
assert Powervault.detect_hardware(@client_opts) == nil
end
end
describe "discover_wireless_sensors/1" do
test "returns empty list (no wireless sensors)" do
assert Powervault.discover_wireless_sensors(@client_opts) == []
end
end
describe "wireless_oid_defs/0" do
test "returns empty list (no wireless OIDs)" do
assert Powervault.wireless_oid_defs() == []
end
end
describe "post_process_sensors/2" do
test "parses temperature sensors from text messages" do
# Mock SNMP walk returning temperature message
expect(SnmpMock, :walk, fn _target, _oid, _opts ->
{:ok,
[
%{oid: "1.3.6.1.3.94.1.8.1.6.1", value: "Enclosure Temp: 25 C 77.0F"}
]}
end)
sensors = []
result = Powervault.post_process_sensors(sensors, @client_opts)
assert length(result) == 1
[sensor] = result
assert sensor.sensor_type == "temperature"
assert sensor.sensor_descr == "Enclosure Temp"
assert sensor.sensor_unit == "°C"
assert sensor.sensor_oid == "#{@sensor_message_oid}.1"
assert sensor.sensor_index == "powervault_temp.1"
assert sensor.last_value == 25
assert sensor.sensor_divisor == 1
assert sensor.metadata.vendor == "dell_powervault"
assert sensor.metadata.message_type == "text_parsed"
end
test "parses voltage sensors from text messages" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok,
[
%{oid: "#{@sensor_message_oid}.2", value: "12V Rail Voltage: 12.1V"}
]}
end)
sensors = []
result = Powervault.post_process_sensors(sensors, @client_opts)
assert length(result) == 1
[sensor] = result
assert sensor.sensor_type == "voltage"
assert sensor.sensor_descr == "12V Rail Voltage"
assert sensor.sensor_unit == "V"
assert sensor.sensor_oid == "#{@sensor_message_oid}.2"
assert sensor.sensor_index == "powervault_voltage.2"
assert sensor.last_value == 12.1
assert sensor.sensor_divisor == 1
end
test "parses current sensors from text messages" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok,
[
%{oid: "#{@sensor_message_oid}.3", value: "Battery Current: 0.5A"}
]}
end)
sensors = []
result = Powervault.post_process_sensors(sensors, @client_opts)
assert length(result) == 1
[sensor] = result
assert sensor.sensor_type == "current"
assert sensor.sensor_descr == "Battery Current"
assert sensor.sensor_unit == "A"
assert sensor.sensor_oid == "#{@sensor_message_oid}.3"
assert sensor.sensor_index == "powervault_current.3"
assert sensor.last_value == 0.5
assert sensor.sensor_divisor == 1
end
test "parses battery charge sensors from text messages" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok,
[
%{oid: "#{@sensor_message_oid}.4", value: "Battery Charge: 95%"}
]}
end)
sensors = []
result = Powervault.post_process_sensors(sensors, @client_opts)
assert length(result) == 1
[sensor] = result
assert sensor.sensor_type == "percent"
assert sensor.sensor_descr == "Battery Charge"
assert sensor.sensor_unit == "%"
assert sensor.sensor_oid == "#{@sensor_message_oid}.4"
assert sensor.sensor_index == "powervault_charge.4"
assert sensor.last_value == 95
assert sensor.sensor_divisor == 1
assert sensor.high_limit == 100
assert sensor.low_warn_limit == 20
assert sensor.low_limit == 10
end
test "parses multiple sensors from mixed message types" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok,
[
%{oid: "#{@sensor_message_oid}.1", value: "Enclosure Temp: 25 C 77.0F"},
%{oid: "#{@sensor_message_oid}.2", value: "12V Rail Voltage: 12.1V"},
%{oid: "#{@sensor_message_oid}.3", value: "Battery Current: 0.5A"},
%{oid: "#{@sensor_message_oid}.4", value: "Battery Charge: 95%"}
]}
end)
sensors = []
result = Powervault.post_process_sensors(sensors, @client_opts)
assert length(result) == 4
types = Enum.map(result, & &1.sensor_type)
assert "temperature" in types
assert "voltage" in types
assert "current" in types
assert "percent" in types
end
test "combines parsed sensors with existing sensors" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok,
[
%{oid: "#{@sensor_message_oid}.1", value: "Enclosure Temp: 25 C 77.0F"}
]}
end)
existing_sensors = [
%{
sensor_type: "power",
sensor_descr: "Power Supply",
sensor_oid: "1.2.3.4.5",
sensor_index: "power_1",
last_value: 500
}
]
result = Powervault.post_process_sensors(existing_sensors, @client_opts)
assert length(result) == 2
# Original sensor preserved
assert Enum.any?(result, &(&1.sensor_type == "power"))
# New temperature sensor added
assert Enum.any?(result, &(&1.sensor_type == "temperature"))
end
test "returns unchanged sensors when walk fails" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:error, :timeout}
end)
existing_sensors = [
%{
sensor_type: "power",
sensor_descr: "Power Supply",
sensor_oid: "1.2.3.4.5",
sensor_index: "power_1",
last_value: 500
}
]
result = Powervault.post_process_sensors(existing_sensors, @client_opts)
assert result == existing_sensors
end
test "returns unchanged sensors when walk returns empty list" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok, []}
end)
existing_sensors = [
%{
sensor_type: "power",
sensor_descr: "Power Supply",
sensor_oid: "1.2.3.4.5",
sensor_index: "power_1",
last_value: 500
}
]
result = Powervault.post_process_sensors(existing_sensors, @client_opts)
assert result == existing_sensors
end
test "skips messages without colon separator" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok,
[
%{oid: "#{@sensor_message_oid}.1", value: "Invalid message format"},
%{oid: "#{@sensor_message_oid}.2", value: "12V Rail Voltage: 12.1V"}
]}
end)
sensors = []
result = Powervault.post_process_sensors(sensors, @client_opts)
# Only the valid voltage sensor should be parsed
assert length(result) == 1
assert hd(result).sensor_type == "voltage"
end
test "skips messages that don't match any sensor pattern" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok,
[
%{oid: "#{@sensor_message_oid}.1", value: "Unknown Sensor: 123"},
%{oid: "#{@sensor_message_oid}.2", value: "Battery Charge: 95%"}
]}
end)
sensors = []
result = Powervault.post_process_sensors(sensors, @client_opts)
# Only the battery charge sensor should be parsed
assert length(result) == 1
assert hd(result).sensor_type == "percent"
end
test "skips non-string message values" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok,
[
%{oid: "#{@sensor_message_oid}.1", value: 12_345},
%{oid: "#{@sensor_message_oid}.2", value: "Battery Charge: 95%"}
]}
end)
sensors = []
result = Powervault.post_process_sensors(sensors, @client_opts)
# Only the battery charge sensor should be parsed
assert length(result) == 1
assert hd(result).sensor_type == "percent"
end
test "handles temperature messages with different formats" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok,
[
%{oid: "#{@sensor_message_oid}.1", value: "CPU Temp: 45 C 113.0F"},
%{oid: "#{@sensor_message_oid}.2", value: "Board Temp: 30 C 86.0F"}
]}
end)
sensors = []
result = Powervault.post_process_sensors(sensors, @client_opts)
assert length(result) == 2
[sensor1, sensor2] = result
assert sensor1.sensor_descr == "CPU Temp"
assert sensor1.last_value == 45
assert sensor2.sensor_descr == "Board Temp"
assert sensor2.last_value == 30
end
test "handles voltage messages with different rail names" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok,
[
%{oid: "#{@sensor_message_oid}.1", value: "5V Rail Voltage: 5.2V"},
%{oid: "#{@sensor_message_oid}.2", value: "3.3V Rail Voltage: 3.4V"}
]}
end)
sensors = []
result = Powervault.post_process_sensors(sensors, @client_opts)
assert length(result) == 2
[sensor1, sensor2] = result
assert sensor1.sensor_descr == "5V Rail Voltage"
assert sensor1.last_value == 5.2
assert sensor2.sensor_descr == "3.3V Rail Voltage"
assert sensor2.last_value == 3.4
end
test "rejects temperature messages with invalid format" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok,
[
# Missing space before C
%{oid: "#{@sensor_message_oid}.1", value: "Enclosure Temp: 25C"},
# Wrong unit format
%{oid: "#{@sensor_message_oid}.2", value: "Enclosure Temp: 25 Celsius"}
]}
end)
sensors = []
result = Powervault.post_process_sensors(sensors, @client_opts)
assert result == []
end
test "rejects voltage messages with invalid format" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok,
[
# Space before V
%{oid: "#{@sensor_message_oid}.1", value: "12V Rail Voltage: 12.1 V"},
# Wrong unit
%{oid: "#{@sensor_message_oid}.2", value: "12V Rail Voltage: 12.1 Volts"}
]}
end)
sensors = []
result = Powervault.post_process_sensors(sensors, @client_opts)
assert result == []
end
test "rejects current messages with invalid format" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok,
[
# Space before A
%{oid: "#{@sensor_message_oid}.1", value: "Battery Current: 0.5 A"},
# Wrong unit
%{oid: "#{@sensor_message_oid}.2", value: "Battery Current: 0.5 Amps"}
]}
end)
sensors = []
result = Powervault.post_process_sensors(sensors, @client_opts)
assert result == []
end
test "rejects charge messages with invalid format" do
expect(SnmpMock, :walk, fn _, @sensor_message_oid, _ ->
{:ok,
[
# Space before %
%{oid: "#{@sensor_message_oid}.1", value: "Battery Charge: 95 %"},
# Wrong unit
%{oid: "#{@sensor_message_oid}.2", value: "Battery Charge: 95 percent"}
]}
end)
sensors = []
result = Powervault.post_process_sensors(sensors, @client_opts)
assert result == []
end
end
end

View file

@ -73,6 +73,42 @@ defmodule ToweropsWeb.Admin.MonitoringLiveTest do
end
end
describe "handle_info events" do
setup [:register_and_log_in_superuser]
test "handles job started event", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/monitoring")
send(view.pid, %{event: :started, job_id: 1, worker: "TestWorker"})
assert render(view)
end
test "handles job completed event", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/monitoring")
send(view.pid, %{event: :completed, job_id: 1, worker: "TestWorker"})
assert render(view)
end
test "handles job failed event", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/monitoring")
send(view.pid, %{event: :failed, job_id: 1, worker: "TestWorker"})
assert render(view)
end
test "handles unknown events gracefully", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/monitoring")
send(view.pid, :some_unknown_event)
assert render(view)
end
end
defp register_and_log_in_superuser(%{conn: conn}) do
user = Towerops.AccountsFixtures.user_fixture()
user = Towerops.Repo.update!(Ecto.Changeset.change(user, is_superuser: true))

View file

@ -145,6 +145,137 @@ defmodule ToweropsWeb.DeviceLive.FormTest do
end
end
describe "new device form" do
test "renders new device page", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/devices/new")
assert html =~ "New Device"
end
test "pre-fills from query params", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/devices/new?name=Prefilled&ip_address=10.0.0.1")
assert html =~ "Prefilled"
assert html =~ "10.0.0.1"
end
test "validates device form on change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices/new")
html =
view
|> form("#device-form", device: %{name: "", ip_address: ""})
|> render_change()
assert html =~ "New Device"
end
test "submits new device form", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices/new")
result =
view
|> form("#device-form",
device: %{
name: "New Test Device",
ip_address: "10.0.0.100"
}
)
|> render_submit()
case result do
{:error, {:live_redirect, %{to: to}}} ->
assert to =~ "tab=overview"
html when is_binary(html) ->
# Form re-rendered (possibly validation), page still alive
assert html =~ "New Device" or html =~ "New Test Device"
end
end
test "switches monitoring mode", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices/new")
html = render_click(view, "switch_monitoring_mode", %{"mode" => "icmp_only"})
assert html =~ "New Device"
end
end
describe "edit device form" do
setup %{site: site, organization: organization} do
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Edit Me",
ip_address: "192.168.1.50",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true
})
%{device: device}
end
test "renders edit page with current values", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/edit")
assert html =~ "Edit Device"
assert html =~ "Edit Me"
end
test "validates edit form on change", %{conn: conn, device: device} do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit")
html =
view
|> form("#device-form", device: %{name: "Updated Name"})
|> render_change()
assert html =~ "Updated Name"
end
test "saves edited device", %{conn: conn, device: device} do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit")
assert {:error, {:live_redirect, %{to: to}}} =
view
|> form("#device-form", device: %{name: "Updated Router"})
|> render_submit()
assert to =~ device.id
end
test "deletes device", %{conn: conn, device: device} do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit")
result = render_click(view, "delete")
case result do
{:error, {:live_redirect, %{to: "/devices" <> _}}} ->
assert true
html when is_binary(html) ->
# If it redirects via push_navigate, the view dies
refute Process.alive?(view.pid)
end
end
test "trigger_discovery with SNMP disabled shows error", %{conn: conn, site: site, organization: organization} do
{:ok, device} =
Towerops.Devices.create_device(%{
name: "No SNMP",
ip_address: "192.168.1.99",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: false
})
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit")
html = render_click(view, "trigger_discovery")
assert html =~ "SNMP is not enabled"
end
end
describe "non_routable_ip?/1" do
# Access private function via Module.get_attribute or test via public interface
# Since these are private functions, we test through the validation behavior

View file

@ -151,4 +151,90 @@ defmodule ToweropsWeb.DeviceLive.IndexTest do
assert html =~ "1/10 devices"
end
end
describe "discovered tab" do
test "renders discovered devices tab", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/devices?tab=discovered")
assert html =~ "Discovered"
end
test "renders discovered tab with pagination", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/devices?tab=discovered&page=1")
assert html =~ "Discovered"
end
end
describe "device management events" do
test "toggles reorder mode", %{conn: conn, site: site, organization: organization} do
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
html = render_click(view, "toggle_reorder_mode")
assert html =~ "Reset Order"
end
test "resets device order", %{conn: conn, site: site, organization: organization} do
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
html = render_click(view, "reset_order")
assert html =~ "Order reset to alphabetical"
end
test "force_rediscover_all with no SNMP devices shows error", %{conn: conn, site: site, organization: organization} do
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: false
})
{:ok, view, _html} = live(conn, ~p"/devices")
html = render_click(view, "force_rediscover_all")
assert html =~ "No SNMP-enabled devices found"
end
test "reorder_device with invalid device_id shows error", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices")
html =
render_click(view, "reorder_device", %{
"device_id" => Ecto.UUID.generate(),
"new_position" => "1"
})
assert html =~ "not found"
end
test "reorder_site with invalid site_id shows error", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices")
html =
render_click(view, "reorder_site", %{
"site_id" => Ecto.UUID.generate(),
"new_position" => "1"
})
assert html =~ "not found"
end
end
end

View file

@ -101,4 +101,166 @@ defmodule ToweropsWeb.DeviceLive.ShowTest do
assert flash["error"] =~ "Device not found"
end
end
describe "tab rendering" do
test "renders interfaces tab", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=interfaces")
assert html =~ "Test Router"
end
test "renders sensors tab", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=sensors")
assert html =~ "Test Router"
end
test "renders events tab", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=events")
assert html =~ "Test Router"
end
end
describe "handle_info events" do
test "handles device_status_changed", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:device_status_changed, device.id, :up, 10})
assert render(view)
end
test "handles discovery_completed", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:discovery_completed, device.id})
html = render(view)
assert html =~ "Discovery completed"
end
test "handles sensors_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:sensors_updated, device.id})
assert render(view)
end
test "handles interfaces_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:interfaces_updated, device.id})
assert render(view)
end
test "handles neighbors_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:neighbors_updated, device.id})
assert render(view)
end
test "handles arp_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:arp_updated, device.id})
assert render(view)
end
test "handles mac_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:mac_updated, device.id})
assert render(view)
end
test "handles state_sensors_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:state_sensors_updated, device.id})
assert render(view)
end
test "handles processors_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:processors_updated, device.id})
assert render(view)
end
test "handles storage_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:storage_updated, device.id})
assert render(view)
end
test "handles monitoring_check_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:monitoring_check_updated, device.id})
assert render(view)
end
test "handles device_event", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:device_event, %{device_id: device.id, type: "test"}})
assert render(view)
end
test "handles refresh_data", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, :refresh_data)
assert render(view)
end
test "handles agents_stale with unrelated agent", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:agents_stale, []})
assert render(view)
end
test "handles agent_connected with unrelated agent", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:agent_connected, Ecto.UUID.generate(), Ecto.UUID.generate()})
assert render(view)
end
test "handles agent_disconnected with unrelated agent", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:agent_disconnected, Ecto.UUID.generate(), Ecto.UUID.generate()})
assert render(view)
end
test "handles agent_heartbeat with unrelated agent", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:agent_heartbeat, Ecto.UUID.generate(), Ecto.UUID.generate()})
assert render(view)
end
end
end

View file

@ -437,30 +437,122 @@ defmodule ToweropsWeb.GraphLive.ShowTest do
end
end
describe "Page rendering" do
test "renders latency page with organization context", %{
describe "Temperature graph" do
test "renders temperature graph page", %{
conn: conn,
device: device,
organization: _org
organization: _org,
snmp_device: snmp_device
} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.3.6.1.4.1.9.9.13.1.3.1.3.1",
sensor_descr: "Chassis Temp",
sensor_unit: "C"
})
|> Repo.insert!()
# Verify page renders with expected content
assert html =~ "Ping Latency"
Snmp.create_sensor_reading(%{
sensor_id: sensor.id,
value: 42.0,
checked_at: DateTime.utc_now()
})
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/temperature")
assert html =~ "Temperature"
assert html =~ device.name
end
end
test "renders with all required page elements", %{
describe "Storage graph" do
test "renders storage graph page", %{conn: conn, device: device, organization: _org} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/storage")
assert html =~ "Storage Usage"
assert html =~ device.name
end
end
describe "Sensor-specific graph" do
test "renders graph for specific sensor by ID", %{
conn: conn,
device: device,
organization: _org,
snmp_device: snmp_device
} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "voltage",
sensor_index: "1",
sensor_oid: "1.3.6.1.4.1.9.9.13.1.2.1.3.1",
sensor_descr: "PSU Voltage",
sensor_unit: "V"
})
|> Repo.insert!()
Snmp.create_sensor_reading(%{
sensor_id: sensor.id,
value: 12.1,
checked_at: DateTime.utc_now()
})
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/voltage?sensor_id=#{sensor.id}")
assert html =~ "Voltage"
assert html =~ "PSU Voltage"
end
end
describe "handle_info events" do
test "handles device_status_changed", %{conn: conn, device: device, organization: _org} do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
send(view.pid, {:device_status_changed, device.id, :up, 10})
assert render(view)
end
test "handles state_sensors_updated", %{conn: conn, device: device, organization: _org} do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
send(view.pid, {:state_sensors_updated, device.id})
assert render(view)
end
test "handles neighbors_updated", %{conn: conn, device: device, organization: _org} do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
send(view.pid, {:neighbors_updated, device.id})
assert render(view)
end
test "handles live_poll when not in live mode", %{
conn: conn,
device: device,
organization: _org
} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
# Verify key elements are present
assert html =~ "Ping Latency"
assert html =~ device.name
assert html =~ "24 Hours"
send(view.pid, :live_poll)
assert render(view)
end
end
describe "access control" do
test "redirects when device not found", %{conn: conn} do
fake_id = Ecto.UUID.generate()
assert {:error, {:live_redirect, %{to: "/devices"}}} = live(conn, ~p"/devices/#{fake_id}/graph/latency")
end
end
end

View file

@ -141,5 +141,51 @@ defmodule ToweropsWeb.Org.SettingsLiveTest do
assert {:error, {:redirect, %{to: "/users/log-in"}}} =
live(conn, ~p"/orgs/#{org.slug}/settings")
end
test "validates organization name on change", %{conn: conn, user: user, organization: org} do
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
html =
view
|> form("#organization-form", organization: %{name: ""})
|> render_change()
assert html =~ "Organization Name"
end
test "applies SNMP config to all devices", %{conn: conn, user: user, organization: org} do
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
html = render_click(view, "apply_snmp_to_all")
assert html =~ "Applied SNMP configuration to"
end
test "applies default agent to all devices", %{conn: conn, user: user, organization: org} do
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
html = render_click(view, "apply_agent_to_all")
assert html =~ "Applied default agent to"
end
test "toggles default organization", %{conn: conn, user: user, organization: org} do
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
html = render_click(view, "toggle_default_org")
# Either sets as default or shows already default message
assert html =~ "default organization" or html =~ "already your default"
end
end
end

View file

@ -199,4 +199,69 @@ defmodule ToweropsWeb.UserSettingsLiveTest do
assert html =~ "Login History"
end
end
describe "UserSettingsLive API token creation with sudo mode" do
setup :register_and_log_in_user_with_sudo
test "creates API token and shows it", %{conn: conn, user: user} do
{:ok, org} = Towerops.Organizations.create_organization(%{name: "Token Org"}, user.id)
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=api")
render_click(view, "show_add_token_modal")
html =
render_click(view, "create_api_token", %{
"token" => %{"name" => "My Token", "organization_id" => org.id}
})
assert html =~ "My Token" or html =~ "API Tokens"
end
test "create_api_token without name shows error", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=api")
html = render_click(view, "create_api_token", %{})
assert html =~ "required" or html =~ "API Tokens"
end
test "close_token_modal clears token", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=api")
html = render_click(view, "close_token_modal")
assert html =~ "API Tokens"
end
end
describe "UserSettingsLive session management with sudo mode" do
setup :register_and_log_in_user_with_sudo
test "shows notification tab with add device modal", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=notifications")
html = render_click(view, "show_add_device_modal")
assert html =~ "Mobile" or html =~ "Device"
html = render_click(view, "cancel_add_device")
assert html =~ "Mobile Devices"
end
end
describe "UserSettingsLive password breach check with sudo mode" do
setup :register_and_log_in_user_with_sudo
test "check_password_breach with empty value", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=account")
html = render_click(view, "check_password_breach", %{"value" => ""})
assert html =~ "Account Settings"
end
test "check_password_breach with no value param", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=account")
html = render_click(view, "check_password_breach", %{})
assert html =~ "Account Settings"
end
end
end