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 test "renders backhaul traffic and capacity with consistent polarity", %{ conn: conn, device: device, organization: _org, snmp_device: snmp_device } do device = device |> Ecto.Changeset.change(device_role: "backhaul") |> Repo.update!() 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, configured_capacity_bps: 500_000_000, capacity_source: "manual" }) |> Repo.insert!() base_time = DateTime.utc_now() Snmp.create_interface_stat(%{ interface_id: interface.id, if_in_octets: 1_000, if_out_octets: 2_000, checked_at: DateTime.add(base_time, -60, :second) }) Snmp.create_interface_stat(%{ interface_id: interface.id, if_in_octets: 10_000, if_out_octets: 14_000, checked_at: base_time }) {:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/traffic") datasets = chart_datasets_from_html(html, "detail-chart") assert dataset_y_values(datasets, "eth0 Out") == [-1600.0] assert dataset_y_values(datasets, "eth0 In") == [1200.0] assert dataset_y_values(datasets, "Capacity (In)") == [500_000_000.0] assert dataset_y_values(datasets, "Capacity (Out)") == [-500_000_000.0] assert dataset_fill(datasets, "Capacity (In)") == false assert dataset_fill(datasets, "Capacity (Out)") == false end end defp chart_datasets_from_html(html, chart_id) do pattern = ~r/id="#{chart_id}"[^>]*data-chart="([^"]+)"/ [_, encoded_json] = Regex.run(pattern, html) encoded_json |> String.replace(""", "\"") |> Jason.decode!() |> Map.fetch!("datasets") end defp dataset_y_values(datasets, label) do datasets |> Enum.find(&(Map.get(&1, "label") == label)) |> Map.fetch!("data") |> Enum.map(&Map.fetch!(&1, "y")) end defp dataset_fill(datasets, label) do datasets |> Enum.find(&(Map.get(&1, "label") == label)) |> Map.get("fill") 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 "Interface errors graph" do setup %{snmp_device: snmp_device} do 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!() %{interface: interface} end test "renders interface errors graph page", %{ conn: conn, device: device, interface: interface } do Snmp.create_interface_stat(%{ interface_id: interface.id, if_in_errors: 5, if_out_errors: 2, if_in_discards: 1, if_out_discards: 0, checked_at: DateTime.utc_now() }) {:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/interface_errors?interface_id=#{interface.id}") assert html =~ "Interface Errors & Discards" assert html =~ device.name assert html =~ interface.if_name end test "renders interface errors graph without data", %{ conn: conn, device: device, interface: interface } do {:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/interface_errors?interface_id=#{interface.id}") assert html =~ "Interface Errors & Discards" 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