towerops/test/towerops_web/live/graph_live/show_test.exs
Graham McIntire 9094f42098
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>
2026-02-12 08:38:30 -06:00

558 lines
14 KiB
Elixir

defmodule ToweropsWeb.GraphLive.ShowTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
alias Towerops.Monitoring
alias Towerops.Organizations
alias Towerops.Repo
alias Towerops.Sites
alias Towerops.Snmp
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Sensor
setup :register_and_log_in_user
setup %{user: user} do
{:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true
})
# Create SNMP device for sensor tests
snmp_device =
%Device{}
|> Device.changeset(%{
device_id: device.id,
sys_name: "test-router",
sys_descr: "Test Router",
sys_object_id: "1.3.6.1.4.1.9"
})
|> Repo.insert!()
%{organization: organization, site: site, device: device, snmp_device: snmp_device}
end
describe "Latency graph" do
test "renders latency graph page with correct content", %{
conn: conn,
device: device,
organization: _org
} do
# Create monitoring checks
Monitoring.create_check(%{
device_id: device.id,
status: :success,
response_time_ms: 10.5,
checked_at: DateTime.utc_now()
})
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
# Verify page renders with correct content
assert html =~ "Ping Latency"
assert html =~ device.name
assert html =~ "24 Hours"
end
test "uses current_organization in back link", %{
conn: conn,
device: device,
organization: _org
} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
# Verify back link uses organization slug (this was the bug)
assert html =~ ~s[/devices/#{device.id}]
end
test "displays empty state when no latency data", %{
conn: conn,
device: device,
organization: _org
} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
assert html =~ "No sensor data available"
end
end
describe "Processor graph" do
test "renders processor graph page", %{
conn: conn,
device: device,
organization: _org,
snmp_device: snmp_device
} do
# Create a CPU sensor
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "cpu_load",
sensor_index: "1",
sensor_oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.8.1",
sensor_descr: "CPU 1",
sensor_unit: "%"
})
|> Repo.insert!()
# Create sensor reading
Snmp.create_sensor_reading(%{
sensor_id: sensor.id,
value: 45.2,
checked_at: DateTime.utc_now()
})
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/processors")
assert html =~ "Processor Usage"
assert html =~ device.name
end
end
describe "Memory graph" do
test "renders memory graph page", %{
conn: conn,
device: device,
organization: _org,
snmp_device: snmp_device
} do
# Create a memory sensor
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "memory_usage",
sensor_index: "1",
sensor_oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.12.1",
sensor_descr: "Memory",
sensor_unit: "%"
})
|> Repo.insert!()
# Create sensor reading
Snmp.create_sensor_reading(%{
sensor_id: sensor.id,
value: 75.5,
checked_at: DateTime.utc_now()
})
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/memory")
assert html =~ "Memory Usage"
assert html =~ device.name
end
end
describe "Traffic graph" do
test "renders overall traffic graph page", %{
conn: conn,
device: device,
organization: _org,
snmp_device: snmp_device
} do
# Create an interface
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_descr: "eth0",
if_name: "eth0",
if_oper_status: "up",
if_speed: 1_000_000_000
})
|> Repo.insert!()
# Create interface stats
Snmp.create_interface_stat(%{
interface_id: interface.id,
if_in_octets: 1000,
if_out_octets: 2000,
checked_at: DateTime.utc_now()
})
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/traffic")
assert html =~ "Overall Traffic"
assert html =~ device.name
end
test "renders specific interface traffic graph", %{
conn: conn,
device: device,
organization: _org,
snmp_device: snmp_device
} do
# Create an interface
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_descr: "eth0",
if_name: "eth0",
if_oper_status: "up",
if_speed: 1_000_000_000
})
|> Repo.insert!()
# Create interface stats
Snmp.create_interface_stat(%{
interface_id: interface.id,
if_in_octets: 1000,
if_out_octets: 2000,
checked_at: DateTime.utc_now()
})
{:ok, _view, html} =
live(
conn,
~p"/devices/#{device.id}/graph/traffic?interface_id=#{interface.id}"
)
assert html =~ "Overall Traffic"
assert html =~ device.name
end
test "handles nil if_in_octets gracefully", %{
conn: conn,
device: device,
organization: _org,
snmp_device: snmp_device
} do
# Create an interface
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_descr: "eth0",
if_name: "eth0",
if_oper_status: "up",
if_speed: 1_000_000_000
})
|> Repo.insert!()
# Create interface stats with nil if_in_octets
base_time = DateTime.utc_now()
Snmp.create_interface_stat(%{
interface_id: interface.id,
if_in_octets: nil,
if_out_octets: 1000,
checked_at: DateTime.add(base_time, -60, :second)
})
Snmp.create_interface_stat(%{
interface_id: interface.id,
if_in_octets: nil,
if_out_octets: 2000,
checked_at: base_time
})
# Should not crash
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/traffic")
assert html =~ "Overall Traffic"
assert html =~ device.name
end
test "handles nil if_out_octets gracefully", %{
conn: conn,
device: device,
organization: _org,
snmp_device: snmp_device
} do
# Create an interface
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_descr: "eth0",
if_name: "eth0",
if_oper_status: "up",
if_speed: 1_000_000_000
})
|> Repo.insert!()
# Create interface stats with nil if_out_octets
base_time = DateTime.utc_now()
Snmp.create_interface_stat(%{
interface_id: interface.id,
if_in_octets: 1000,
if_out_octets: nil,
checked_at: DateTime.add(base_time, -60, :second)
})
Snmp.create_interface_stat(%{
interface_id: interface.id,
if_in_octets: 2000,
if_out_octets: nil,
checked_at: base_time
})
# Should not crash
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/traffic")
assert html =~ "Overall Traffic"
assert html =~ device.name
end
test "handles mixed nil and valid octets values", %{
conn: conn,
device: device,
organization: _org,
snmp_device: snmp_device
} do
# Create an interface
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_descr: "eth0",
if_name: "eth0",
if_oper_status: "up",
if_speed: 1_000_000_000
})
|> Repo.insert!()
# Create interface stats with mixed nil and valid values
base_time = DateTime.utc_now()
# First stat: nil values
Snmp.create_interface_stat(%{
interface_id: interface.id,
if_in_octets: nil,
if_out_octets: nil,
checked_at: DateTime.add(base_time, -120, :second)
})
# Second stat: valid values
Snmp.create_interface_stat(%{
interface_id: interface.id,
if_in_octets: 1000,
if_out_octets: 2000,
checked_at: DateTime.add(base_time, -60, :second)
})
# Third stat: valid values
Snmp.create_interface_stat(%{
interface_id: interface.id,
if_in_octets: 2000,
if_out_octets: 4000,
checked_at: base_time
})
# Should not crash and should render valid data points
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/traffic")
assert html =~ "Overall Traffic"
assert html =~ device.name
end
end
describe "Time range selection" do
test "defaults to 24h range", %{conn: conn, device: device, organization: _org} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
assert html =~ "24 Hours"
end
test "accepts custom range parameter", %{conn: conn, device: device, organization: _org} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/latency?range=1h")
assert html =~ "1 Hour"
end
test "changes range when user clicks range button", %{
conn: conn,
device: device,
organization: _org
} do
{:ok, view, html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
# Initially 24h
assert html =~ "24 Hours"
# Change to 1h
html =
view
|> element("button", "1 Hour")
|> render_click()
assert html =~ "1 Hour"
end
test "preserves interface_id when changing range", %{
conn: conn,
device: device,
organization: _org,
snmp_device: snmp_device
} do
# Create an interface
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_descr: "eth0",
if_name: "eth0",
if_oper_status: "up",
if_speed: 1_000_000_000
})
|> Repo.insert!()
{:ok, view, _html} =
live(
conn,
~p"/devices/#{device.id}/graph/traffic?interface_id=#{interface.id}"
)
# Change range and verify page still works
html =
view
|> element("button", "1 Hour")
|> render_click()
assert html =~ "1 Hour"
assert html =~ "Overall Traffic"
end
end
describe "Temperature graph" do
test "renders temperature graph page", %{
conn: conn,
device: device,
organization: _org,
snmp_device: snmp_device
} do
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!()
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
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")
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