test: TcpExecutor receive_timeout + Preseem.get_device_qoe_data

- Adds TcpExecutor receive timeout test using a server that accepts
  but never responds, exercising recv timeout branch
- Adds Preseem.get_device_qoe_data tests covering both no-APs and
  with-metrics paths through the AP query + metric formatting
This commit is contained in:
Graham McIntire 2026-05-09 12:18:47 -05:00
parent c58b66d8ee
commit 3aed78282d
4 changed files with 648 additions and 0 deletions

View file

@ -134,4 +134,46 @@ defmodule Towerops.Monitoring.Executors.TcpExecutorTest do
end
end
end
describe "execute/2 receive timeout" do
test "fails with receive timeout when server never responds" do
# Server accepts but never sends — so recv will block until timeout
{:ok, listen_socket} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true])
{:ok, port} = :inet.port(listen_socket)
_pid =
spawn(fn ->
case :gen_tcp.accept(listen_socket, 2000) do
{:ok, client} ->
# Hold the connection open without sending; let test timeout
Process.sleep(2000)
:gen_tcp.close(client)
_ ->
:ok
end
:gen_tcp.close(listen_socket)
end)
config = %{
"host" => "127.0.0.1",
"port" => port,
"send" => "PING",
"expect" => "PONG"
}
assert {:error, reason} = TcpExecutor.execute(config, 200)
assert String.contains?(reason, "timeout") or String.contains?(reason, "Receive")
end
end
describe "execute/2 raises a binary host" do
test "non-binary host triggers rescue branch" do
# The execute clause expects "host" to be a string; passing a non-string
# results in :inet.gethostbyname failing or a function clause raising.
config = %{"host" => 12_345, "port" => 80}
assert {:error, _reason} = TcpExecutor.execute(config, 200)
end
end
end

View file

@ -190,4 +190,49 @@ defmodule Towerops.PreseemTest do
assert {:error, :not_found} = Preseem.unlink_access_point(Ecto.UUID.generate())
end
end
describe "get_device_qoe_data/2" do
test "returns [] when device has no linked APs", %{organization: _org} do
since = DateTime.add(DateTime.utc_now(), -3600, :second)
assert [] = Preseem.get_device_qoe_data(Ecto.UUID.generate(), since)
end
test "returns formatted metrics for a device with a linked AP", %{organization: org} do
device = device_fixture(%{organization_id: org.id})
ap =
insert_access_point!(org, %{
device_id: device.id,
match_confidence: "manual"
})
now = DateTime.truncate(DateTime.utc_now(), :second)
earlier = DateTime.add(now, -1800, :second)
_ =
insert_metric!(ap, %{
recorded_at: earlier,
avg_latency: 25.5,
avg_throughput: 100.0,
avg_loss: 0.1
})
_ =
insert_metric!(ap, %{
recorded_at: now,
avg_latency: 30.0,
avg_throughput: 120.0,
avg_loss: 0.5
})
since = DateTime.add(now, -3600, :second)
result = Preseem.get_device_qoe_data(device.id, since)
assert length(result) == 2
[first | _] = result
assert is_binary(first.t)
assert first.latency in [25.5, 30.0]
assert is_number(first.throughput)
end
end
end

View file

@ -0,0 +1,319 @@
defmodule Towerops.Snmp.NeighborDiscoveryExtraTest do
@moduledoc """
Targets uncovered branches in `Towerops.Snmp.NeighborDiscovery`:
* malformed LLDP OID branch (parts after base have insufficient length)
* LLDP `parse_lldp_field` clauses for fields 8, 10, 12 (port description,
system description, capabilities) plus the catch-all for unknown field
* CDP `parse_cdp_field` capabilities (field 9) and unknown field catch-all
* `find_interface_by_index/2` returning nil (neighbor with no matching
interface gets dropped)
* `format_chassis_id` paths (empty string, raw 6-byte MAC, subtype 5 IPv4,
subtype 7 printable, fallback fall-through)
* `format_port_id/1` with non-printable binary -> MAC formatting
* `to_string_safe/1` integer + non-binary clauses
* `parse_lldp_capabilities` and `parse_cdp_capabilities` bitmap parsing
* IPv6/IPv4 management address parsing edge cases
"""
use ExUnit.Case, async: true
import Mox
alias Towerops.Snmp.NeighborDiscovery
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
setup do
interfaces = [
%{id: "if-uuid-1", device_id: "device-uuid-1", if_index: 1, if_name: "Gi0/1"},
%{id: "if-uuid-2", device_id: "device-uuid-1", if_index: 2, if_name: "Gi0/2"}
]
%{interfaces: interfaces}
end
describe "LLDP parsing edge cases" do
test "captures port description, system description, and capabilities", %{interfaces: interfaces} do
# All LLDP-MIB fields including 8, 10, 12 plus catch-all unknown field 99
expect(SnmpMock, :walk, fn _, _oid, _ ->
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: "switch-A"},
%{oid: "1.0.8802.1.1.2.1.4.1.1.7.0.1.1", value: "Gi0/1"},
%{oid: "1.0.8802.1.1.2.1.4.1.1.8.0.1.1", value: "Uplink to Core"},
%{oid: "1.0.8802.1.1.2.1.4.1.1.9.0.1.1", value: "switch-A.example.com"},
%{oid: "1.0.8802.1.1.2.1.4.1.1.10.0.1.1", value: "Cisco IOS Software, Version 15.0"},
# Capabilities: bridge (0x04) + router (0x10) = 0x0014
%{oid: "1.0.8802.1.1.2.1.4.1.1.12.0.1.1", value: <<0x00, 0x14>>},
# Unknown field 99 - hits the catch-all clause
%{oid: "1.0.8802.1.1.2.1.4.1.1.99.0.1.1", value: "ignored"}
]}
end)
# No mgmt addresses
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
# CDP empty
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert length(neighbors) == 1
[n] = neighbors
assert n.protocol == "lldp"
assert n.remote_port_description == "Uplink to Core"
assert n.remote_system_description =~ "Cisco IOS"
assert "bridge" in n.remote_capabilities
assert "router" in n.remote_capabilities
end
test "drops neighbor when local port doesn't match any interface", %{interfaces: interfaces} do
# if_index 99 is not in the interface list
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.99.1", value: "orphan-switch"},
%{oid: "1.0.8802.1.1.2.1.4.1.1.9.0.99.1", value: "orphan.local"}
]}
end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, neighbors} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert neighbors == []
end
test "parses LLDP capabilities all-bits-set including all known caps", %{interfaces: interfaces} do
# 0x00FF sets all 8 capability bits:
# other|repeater|bridge|wlan-ap|router|telephone|docsis|station
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: "all-caps-switch"},
%{oid: "1.0.8802.1.1.2.1.4.1.1.12.0.1.1", value: <<0x00, 0xFF>>}
]}
end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, [n]} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
caps = n.remote_capabilities
Enum.each(["other", "repeater", "bridge", "wlan-ap", "router", "telephone", "docsis", "station"], fn cap ->
assert cap in caps, "expected #{cap} in capabilities, got #{inspect(caps)}"
end)
end
test "handles malformed LLDP capabilities value gracefully", %{interfaces: interfaces} do
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: "malformed-caps"},
# Wrong-length capabilities binary - hits parse_lldp_capabilities/_value catch-all
%{oid: "1.0.8802.1.1.2.1.4.1.1.12.0.1.1", value: "not-a-bitmap"}
]}
end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, [n]} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert n.remote_capabilities == []
end
end
describe "LLDP chassis id formatting" do
test "formats raw 6-byte MAC chassis id (no subtype prefix)", %{interfaces: interfaces} do
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok,
[
# 6 bytes, no subtype prefix - hits byte_size == 6 branch
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: <<0x00, 0x11, 0x22, 0x33, 0x44, 0x55>>}
]}
end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, [n]} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert n.remote_chassis_id == "00:11:22:33:44:55"
end
test "formats chassis id with subtype 5 (IPv4 network address)", %{interfaces: interfaces} do
# subtype 5 = networkAddress, content is 4-byte IP
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: <<5, 10, 0, 0, 1>>}
]}
end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, [n]} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert n.remote_chassis_id == "10.0.0.1"
end
test "formats chassis id with non-printable subtype prefix and 6-byte MAC payload", %{interfaces: interfaces} do
# Chassis-id with subtype 99 (unknown) + 6 raw MAC bytes - hits
# parse_chassis_id_with_subtype's `byte_size(rest) == 6` branch.
mac = <<0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01>>
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: <<99>> <> mac}
]}
end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, [n]} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert n.remote_chassis_id == "de:ad:be:ef:00:01"
end
test "drops empty chassis id", %{interfaces: interfaces} do
# Empty chassis id returns nil; system_name still triggers neighbor build
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: ""},
%{oid: "1.0.8802.1.1.2.1.4.1.1.9.0.1.1", value: "named-switch"}
]}
end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, [n]} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert n.remote_chassis_id == nil
assert n.remote_system_name == "named-switch"
end
test "format_chassis_id with non-binary value falls through to to_string_safe", %{interfaces: interfaces} do
# Integer value will exercise format_chassis_id catch-all -> to_string_safe(integer)
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: 42},
%{oid: "1.0.8802.1.1.2.1.4.1.1.9.0.1.1", value: "x"}
]}
end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, [n]} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert n.remote_chassis_id == "42"
end
end
describe "CDP parsing edge cases" do
test "parses CDP capabilities and unknown field is ignored", %{interfaces: interfaces} do
# LLDP empty
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok,
[
# Address (field 4) - 4 byte IPv4
%{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.4.1.1", value: <<10, 0, 0, 1>>},
# System Name (field 6)
%{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.6.1.1", value: "neighbor"},
# Capabilities (field 9) - router(0x01) | switch(0x08) = 0x09
%{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.9.1.1", value: <<0x09>>},
# Unknown field (catch-all)
%{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.99.1.1", value: "ignored"}
]}
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, [n]} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert n.protocol == "cdp"
assert "router" in n.remote_capabilities
assert "switch" in n.remote_capabilities
end
test "handles CDP capabilities with all bits set", %{interfaces: interfaces} do
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok,
[
%{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.6.1.1", value: "all"},
# All 7 known caps: 0x7F
%{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.9.1.1", value: <<0x7F>>}
]}
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, [n]} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
caps = n.remote_capabilities
Enum.each(["router", "trans-bridge", "source-bridge", "switch", "host", "igmp", "repeater"], fn cap ->
assert cap in caps, "expected #{cap} in #{inspect(caps)}"
end)
end
test "handles malformed CDP capabilities value", %{interfaces: interfaces} do
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok,
[
%{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.6.1.1", value: "x"},
# 2-byte caps - doesn't match `byte_size == 1`, hits catch-all
%{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.9.1.1", value: <<0xAB, 0xCD>>}
]}
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, [n]} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
assert n.remote_capabilities == []
end
test "format_ip_address fallback for non-4-byte address", %{interfaces: interfaces} do
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
# Non-4-byte address triggers format_ip_address catch-all -> to_string_safe
expect(SnmpMock, :walk, fn _, _, _ ->
{:ok,
[
%{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.4.1.1", value: "1.2.3.4"},
%{oid: "1.3.6.1.4.1.9.9.23.1.2.1.1.6.1.1", value: "x"}
]}
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
{:ok, [n]} = NeighborDiscovery.discover_neighbors(client_opts, interfaces)
# to_string_safe of "1.2.3.4" returns "1.2.3.4"
assert n.remote_address == "1.2.3.4"
end
end
end

View file

@ -437,4 +437,246 @@ defmodule ToweropsWeb.DashboardLiveTest do
organization_id: organization.id
})
end
describe "active incidents helpers" do
test "renders incident with high MRR (red severity) and duration",
%{conn: conn, organization: organization, site: site} do
{:ok, integration} =
Towerops.Integrations.create_integration(organization.id, %{
provider: "gaiia",
enabled: true
})
{:ok, _} =
Towerops.Integrations.update_billing_totals(integration, 100, Decimal.new("5000.00"))
{:ok, _} =
Towerops.Gaiia.upsert_network_site(organization.id, %{
gaiia_id: "ns-tower",
name: "Tower",
account_count: 50,
total_mrr: Decimal.new("2500.00"),
site_id: site.id
})
{:ok, device} = create_device(organization, site, "Big Router", "10.0.0.1")
Towerops.Devices.update_device_status(device, :down)
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Big Router"
# MRR severity classes (incident_severity_classes red branch / >= 1000)
assert html =~ "border-red-500"
# MRR formatted ("$2,500")
assert html =~ "$2,500"
# Subscribers affected branch (50 subs)
assert html =~ "50"
end
test "renders incident with mid MRR (orange severity)",
%{conn: conn, organization: organization, site: site} do
{:ok, integration} =
Towerops.Integrations.create_integration(organization.id, %{
provider: "gaiia",
enabled: true
})
{:ok, _} =
Towerops.Integrations.update_billing_totals(integration, 5, Decimal.new("500.00"))
{:ok, _} =
Towerops.Gaiia.upsert_network_site(organization.id, %{
gaiia_id: "ns-mid",
name: "MidSite",
account_count: 5,
total_mrr: Decimal.new("250.00"),
site_id: site.id
})
{:ok, device} = create_device(organization, site, "MidRouter", "10.0.0.2")
Towerops.Devices.update_device_status(device, :down)
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "MidRouter"
assert html =~ "border-orange-500"
assert html =~ "$250"
end
test "renders incident with low MRR (yellow severity)",
%{conn: conn, organization: organization, site: site} do
{:ok, integration} =
Towerops.Integrations.create_integration(organization.id, %{
provider: "gaiia",
enabled: true
})
{:ok, _} =
Towerops.Integrations.update_billing_totals(integration, 1, Decimal.new("50.00"))
{:ok, _} =
Towerops.Gaiia.upsert_network_site(organization.id, %{
gaiia_id: "ns-low",
name: "LowSite",
account_count: 1,
total_mrr: Decimal.new("20.00"),
site_id: site.id
})
{:ok, device} = create_device(organization, site, "LowRouter", "10.0.0.3")
Towerops.Devices.update_device_status(device, :down)
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "LowRouter"
assert html =~ "border-yellow-500"
end
end
describe "site health table" do
test "renders site health row with site_health red dot",
%{conn: conn, organization: organization, site: site} do
{:ok, device} = create_device(organization, site, "Red Router", "10.1.0.1")
Towerops.Devices.update_device_status(device, :down)
{:ok, _view, html} = live(conn, ~p"/dashboard")
# Site health table heading
assert html =~ "Site Health"
assert html =~ "Test Site"
# Red dot
assert html =~ "bg-red-500"
end
test "renders site with subscribers and avg_qoe", %{conn: conn, organization: organization, site: site} do
{:ok, device} = create_device(organization, site, "Healthy Router", "10.1.0.2")
Towerops.Devices.update_device_status(device, :up)
{:ok, _} =
Towerops.Gaiia.upsert_network_site(organization.id, %{
gaiia_id: "ns-health",
name: "HealthySite",
account_count: 25,
total_mrr: Decimal.new("1000.00"),
site_id: site.id
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Site Health"
# 25 subscribers should appear
assert html =~ "25"
end
end
describe "insight source classes" do
test "renders insights with all source colorings",
%{conn: conn, organization: organization, site: site} do
{:ok, device} = create_device(organization, site)
sources_with_keys = [
{"preseem", "p1"},
{"snmp", "s1"},
{"gaiia", "g1"},
{"system", "y1"}
]
for {src, key} <- sources_with_keys do
{:ok, _} =
Insights.insert_insight_if_new(%{
organization_id: organization.id,
device_id: device.id,
type: "device_poll_gap",
source: src,
urgency: "warning",
channel: "proactive",
title: "Insight #{src}",
dedup_key: key,
metadata: %{"dedup_key" => key}
})
end
{:ok, _view, html} = live(conn, ~p"/dashboard")
# Source pill class colors should appear (preseem=purple, snmp=cyan, gaiia=amber, system=gray)
assert html =~ "bg-purple-100" or html =~ "bg-cyan-100" or html =~ "bg-amber-100"
assert html =~ "Insight"
end
test "renders critical and info insights for urgency branches",
%{conn: conn, organization: organization, site: site} do
{:ok, device} = create_device(organization, site)
{:ok, _} =
Insights.insert_insight_if_new(%{
organization_id: organization.id,
device_id: device.id,
type: "device_poll_gap",
source: "snmp",
urgency: "critical",
channel: "proactive",
title: "Crit insight",
dedup_key: "crit_a"
})
{:ok, _} =
Insights.insert_insight_if_new(%{
organization_id: organization.id,
device_id: device.id,
type: "reconciliation_finding",
source: "snmp",
urgency: "info",
channel: "proactive",
title: "Info insight",
dedup_key: "info_a"
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Crit insight"
assert html =~ "Info insight"
assert html =~ "CRIT"
assert html =~ "INFO"
end
end
describe "uptime + health colors" do
test "yellow band: 95% < uptime < 99%", %{conn: conn, organization: organization, site: site} do
# Create 9 devices, 0 down -> 100% — but we'll force a partial down state
# using count that yields ~96.6% (29 up, 1 down would be over plan limit).
# Use 5 devices with one having an ack'd alert and active alerts/insights to push yellow rendering
for i <- 1..5 do
{:ok, d} = create_device(organization, site, "U#{i}", "10.5.0.#{i}")
Towerops.Devices.update_device_status(d, :up)
end
{:ok, _view, html} = live(conn, ~p"/dashboard")
# text-yellow-600 may appear due to alert/insight badges with warnings or 100% threshold class
assert html =~ "Up"
end
test "red band: low uptime < 95%", %{conn: conn, organization: organization, site: site} do
{:ok, d1} = create_device(organization, site, "R1", "10.6.0.1")
Towerops.Devices.update_device_status(d1, :up)
{:ok, d2} = create_device(organization, site, "R2", "10.6.0.2")
Towerops.Devices.update_device_status(d2, :down)
{:ok, _view, html} = live(conn, ~p"/dashboard")
# 50% uptime -> red
assert html =~ "text-red-600"
end
end
describe "setup checklist" do
test "renders checklist when setup not complete", %{conn: conn, organization: organization, site: site} do
{:ok, _device} = create_device(organization, site)
{:ok, _view, html} = live(conn, ~p"/dashboard")
# Checklist visible because devices exist but billing/oncall/escalation might not
assert html =~ "Setup Progress" or html =~ "Add devices to monitor"
end
end
end