Build a rich network topology from SNMP polling data using evidence-based confidence scoring. LLDP/CDP neighbors, MAC address tables, and ARP data are combined to infer device links with weighted confidence merging. - Add DeviceLink and DeviceLinkEvidence schemas for persistent topology - Implement evidence collectors: LLDP (0.95), CDP (0.95), MAC (0.7), ARP (0.6) - Add device role inference from sysObjectID/sysDescr patterns - Hook topology inference into DevicePollerWorker pipeline - Add stale link cleanup (24h mark stale, 72h delete) via NeighborCleanupWorker - Update NetworkMapLive with "Added" vs "All Devices" tabs - Add connected devices section to device detail page - Add device role selector to device edit form - Update Cytoscape.js with role-based node shapes/colors and confidence edges
62 KiB
Automatic Topology Inference Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build a persistent topology model that infers device relationships, roles, and link types from existing SNMP polling data, replacing the current on-demand topology builder.
Architecture: New Towerops.Topology context with schemas for DeviceLink and DeviceLinkEvidence. Evidence collectors analyze LLDP/CDP neighbors, MAC tables, and ARP tables after each poll cycle. The existing DevicePollerWorker calls Topology.process_device/1 at the end of its run. The NetworkMapLive and DeviceLive.Show consume the persistent data.
Tech Stack: Elixir/Phoenix, Ecto, PostgreSQL, Cytoscape.js, Phoenix PubSub
Design doc: docs/plans/2026-02-12-automatic-topology-inference-design.md
Task 1: Database Migrations
Files:
- Create:
priv/repo/migrations/TIMESTAMP_add_device_role_to_devices.exs - Create:
priv/repo/migrations/TIMESTAMP_create_device_links.exs - Create:
priv/repo/migrations/TIMESTAMP_create_device_link_evidence.exs
Step 1: Generate the migrations
cd /Users/graham/dev/towerops/towerops-web
mix ecto.gen.migration add_device_role_to_devices
mix ecto.gen.migration create_device_links
mix ecto.gen.migration create_device_link_evidence
Step 2: Write the device role migration
defmodule Towerops.Repo.Migrations.AddDeviceRoleToDevices do
use Ecto.Migration
def change do
alter table(:devices) do
add :device_role, :string
add :device_role_source, :string, default: "inferred"
end
end
end
Step 3: Write the device_links migration
defmodule Towerops.Repo.Migrations.CreateDeviceLinks do
use Ecto.Migration
def change do
create table(:device_links, primary_key: false) do
add :id, :binary_id, primary_key: true
add :source_device_id, references(:devices, type: :binary_id, on_delete: :delete_all),
null: false
add :target_device_id, references(:devices, type: :binary_id, on_delete: :delete_all)
add :source_interface_id,
references(:snmp_interfaces, type: :binary_id, on_delete: :nilify_all)
add :target_interface_id,
references(:snmp_interfaces, type: :binary_id, on_delete: :nilify_all)
add :link_type, :string, null: false
add :confidence, :float, null: false, default: 0.5
add :metadata, :map, default: %{}
add :discovered_remote_name, :string
add :discovered_remote_ip, :string
add :discovered_remote_mac, :string
add :last_confirmed_at, :utc_datetime, null: false
timestamps(type: :utc_datetime)
end
create index(:device_links, [:source_device_id])
create index(:device_links, [:target_device_id])
create unique_index(:device_links,
[:source_device_id, :source_interface_id, :discovered_remote_mac],
name: :device_links_src_iface_remote_mac)
end
end
Step 4: Write the device_link_evidence migration
defmodule Towerops.Repo.Migrations.CreateDeviceLinkEvidence do
use Ecto.Migration
def change do
create table(:device_link_evidence, primary_key: false) do
add :id, :binary_id, primary_key: true
add :device_link_id,
references(:device_links, type: :binary_id, on_delete: :delete_all),
null: false
add :evidence_type, :string, null: false
add :evidence_data, :map, default: %{}
add :observed_at, :utc_datetime, null: false
timestamps(type: :utc_datetime)
end
create index(:device_link_evidence, [:device_link_id])
create index(:device_link_evidence, [:observed_at])
end
end
Step 5: Run migrations
mix ecto.migrate
Expected: Three migrations run successfully.
Step 6: Commit
feat: add topology inference database tables
Add device_role/device_role_source to devices table.
Create device_links and device_link_evidence tables for
persistent topology model.
Task 2: Ecto Schemas
Files:
- Modify:
lib/towerops/devices/device.ex(add role fields) - Create:
lib/towerops/topology/device_link.ex - Create:
lib/towerops/topology/device_link_evidence.ex
Step 1: Write failing test for DeviceLink schema
Create test/towerops/topology/device_link_test.exs:
defmodule Towerops.Topology.DeviceLinkTest do
use Towerops.DataCase
alias Towerops.Topology.DeviceLink
import Towerops.DevicesFixtures
describe "changeset/2" do
test "valid with required fields" do
device = device_fixture()
attrs = %{
source_device_id: device.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "aa:bb:cc:dd:ee:ff",
last_confirmed_at: DateTime.utc_now()
}
changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
assert changeset.valid?
end
test "invalid without source_device_id" do
attrs = %{link_type: "lldp", confidence: 0.95, last_confirmed_at: DateTime.utc_now()}
changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
refute changeset.valid?
end
test "validates link_type inclusion" do
device = device_fixture()
attrs = %{
source_device_id: device.id,
link_type: "invalid",
confidence: 0.95,
last_confirmed_at: DateTime.utc_now()
}
changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
refute changeset.valid?
end
test "validates confidence range 0.0 to 1.0" do
device = device_fixture()
attrs = %{
source_device_id: device.id,
link_type: "lldp",
confidence: 1.5,
last_confirmed_at: DateTime.utc_now()
}
changeset = DeviceLink.changeset(%DeviceLink{}, attrs)
refute changeset.valid?
end
end
end
Step 2: Run test to verify it fails
mix test test/towerops/topology/device_link_test.exs
Expected: Compilation error — Towerops.Topology.DeviceLink not found.
Step 3: Implement DeviceLink schema
Create lib/towerops/topology/device_link.ex:
defmodule Towerops.Topology.DeviceLink do
@moduledoc """
Persistent record of a detected connection between two devices.
Links are created by the topology inference engine based on evidence
from LLDP/CDP neighbors, MAC tables, ARP tables, etc.
When target_device_id is nil, the remote end is a discovered (unmanaged)
device identified by discovered_remote_* fields.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Devices.Device
alias Towerops.Snmp.Interface
alias Towerops.Topology.DeviceLinkEvidence
@valid_link_types ~w(lldp cdp mac_match wireless_association arp_inference manual)
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "device_links" do
field :link_type, :string
field :confidence, :float, default: 0.5
field :metadata, :map, default: %{}
field :discovered_remote_name, :string
field :discovered_remote_ip, :string
field :discovered_remote_mac, :string
field :last_confirmed_at, :utc_datetime
belongs_to :source_device, Device
belongs_to :target_device, Device
belongs_to :source_interface, Interface
belongs_to :target_interface, Interface
has_many :evidence, DeviceLinkEvidence
timestamps(type: :utc_datetime)
end
def changeset(link, attrs) do
link
|> cast(attrs, [
:source_device_id,
:target_device_id,
:source_interface_id,
:target_interface_id,
:link_type,
:confidence,
:metadata,
:discovered_remote_name,
:discovered_remote_ip,
:discovered_remote_mac,
:last_confirmed_at
])
|> validate_required([:source_device_id, :link_type, :last_confirmed_at])
|> validate_inclusion(:link_type, @valid_link_types)
|> validate_number(:confidence,
greater_than_or_equal_to: 0.0,
less_than_or_equal_to: 1.0
)
|> foreign_key_constraint(:source_device_id)
|> foreign_key_constraint(:target_device_id)
|> unique_constraint([:source_device_id, :source_interface_id, :discovered_remote_mac],
name: :device_links_src_iface_remote_mac
)
end
end
Step 4: Write failing test for DeviceLinkEvidence
Create test/towerops/topology/device_link_evidence_test.exs:
defmodule Towerops.Topology.DeviceLinkEvidenceTest do
use Towerops.DataCase
alias Towerops.Topology.DeviceLinkEvidence
describe "changeset/2" do
test "valid with required fields" do
attrs = %{
device_link_id: Ecto.UUID.generate(),
evidence_type: "lldp_neighbor",
evidence_data: %{"remote_chassis_id" => "aa:bb:cc:dd:ee:ff"},
observed_at: DateTime.utc_now()
}
changeset = DeviceLinkEvidence.changeset(%DeviceLinkEvidence{}, attrs)
assert changeset.valid?
end
test "invalid without evidence_type" do
attrs = %{device_link_id: Ecto.UUID.generate(), observed_at: DateTime.utc_now()}
changeset = DeviceLinkEvidence.changeset(%DeviceLinkEvidence{}, attrs)
refute changeset.valid?
end
test "validates evidence_type inclusion" do
attrs = %{
device_link_id: Ecto.UUID.generate(),
evidence_type: "invalid",
observed_at: DateTime.utc_now()
}
changeset = DeviceLinkEvidence.changeset(%DeviceLinkEvidence{}, attrs)
refute changeset.valid?
end
end
end
Step 5: Implement DeviceLinkEvidence schema
Create lib/towerops/topology/device_link_evidence.ex:
defmodule Towerops.Topology.DeviceLinkEvidence do
@moduledoc """
Records why a device link is believed to exist.
Enables explainability and re-evaluation of link confidence.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Topology.DeviceLink
@valid_evidence_types ~w(lldp_neighbor cdp_neighbor mac_on_interface arp_entry wireless_registration subnet_match)
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "device_link_evidence" do
field :evidence_type, :string
field :evidence_data, :map, default: %{}
field :observed_at, :utc_datetime
belongs_to :device_link, DeviceLink
timestamps(type: :utc_datetime)
end
def changeset(evidence, attrs) do
evidence
|> cast(attrs, [:device_link_id, :evidence_type, :evidence_data, :observed_at])
|> validate_required([:device_link_id, :evidence_type, :observed_at])
|> validate_inclusion(:evidence_type, @valid_evidence_types)
|> foreign_key_constraint(:device_link_id)
end
end
Step 6: Add device_role fields to Device schema
Modify lib/towerops/devices/device.ex:
Add after field :mikrotik_credential_source (line 62):
# Topology role (auto-inferred or manually set)
field :device_role, :string
field :device_role_source, :string, default: "inferred"
Add :device_role and :device_role_source to the cast/2 list in changeset/2 (around line 120).
Add to the @type t spec:
device_role: String.t() | nil,
device_role_source: String.t(),
Step 7: Run all tests
mix test test/towerops/topology/
Expected: All pass.
Step 8: Commit
feat: add DeviceLink and DeviceLinkEvidence schemas
Ecto schemas for persistent topology model. DeviceLink tracks
connections between devices with confidence scores. DeviceLinkEvidence
records why each link is believed to exist. Device schema gets
device_role and device_role_source fields.
Task 3: Topology Context — Device Matching
The matching logic is the foundation everything else builds on. It resolves evidence (MAC, IP, hostname) to managed devices.
Files:
- Create:
lib/towerops/topology.ex - Create:
test/towerops/topology_test.exs
Step 1: Write failing tests for device matching
Create test/towerops/topology_test.exs:
defmodule Towerops.TopologyTest do
use Towerops.DataCase
alias Towerops.Topology
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{name: "Test Site", organization_id: organization.id})
router = device_fixture(%{
name: "Core-Router",
ip_address: "10.0.0.1",
organization_id: organization.id,
site: %Towerops.Sites.Site{id: site.id, organization_id: organization.id, name: "Test Site"}
})
switch = device_fixture(%{
name: "Main-Switch",
ip_address: "10.0.0.2",
organization_id: organization.id,
site: %Towerops.Sites.Site{id: site.id, organization_id: organization.id, name: "Test Site"}
})
# Create SNMP device with interface that has a known MAC
snmp_device =
%Towerops.Snmp.Device{}
|> Towerops.Snmp.Device.changeset(%{device_id: router.id, sys_name: "core-router"})
|> Towerops.Repo.insert!()
interface =
%Towerops.Snmp.Interface{}
|> Towerops.Snmp.Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
if_phys_address: "aa:bb:cc:00:11:22"
})
|> Towerops.Repo.insert!()
%{
organization: organization,
router: router,
switch: switch,
snmp_device: snmp_device,
interface: interface
}
end
describe "build_device_lookup/1" do
test "matches device by IP address", %{organization: org, switch: switch} do
lookup = Topology.build_device_lookup(org.id)
assert Topology.find_device_by_ip(lookup, "10.0.0.2") == switch.id
end
test "matches device by name (case-insensitive)", %{organization: org, router: router} do
lookup = Topology.build_device_lookup(org.id)
assert Topology.find_device_by_name(lookup, "core-router") == router.id
end
test "matches device by MAC address", %{organization: org, router: router} do
lookup = Topology.build_device_lookup(org.id)
assert Topology.find_device_by_mac(lookup, "aa:bb:cc:00:11:22") == router.id
end
test "returns nil for unknown IP", %{organization: org} do
lookup = Topology.build_device_lookup(org.id)
assert Topology.find_device_by_ip(lookup, "192.168.99.99") == nil
end
test "returns nil for unknown MAC", %{organization: org} do
lookup = Topology.build_device_lookup(org.id)
assert Topology.find_device_by_mac(lookup, "ff:ff:ff:ff:ff:ff") == nil
end
end
end
Step 2: Run test to verify it fails
mix test test/towerops/topology_test.exs
Expected: Compilation error — Towerops.Topology not found.
Step 3: Implement the Topology context with device matching
Create lib/towerops/topology.ex:
defmodule Towerops.Topology do
@moduledoc """
Topology inference engine. Builds and maintains a persistent model of
network device relationships by analyzing SNMP polling data.
"""
import Ecto.Query
alias Towerops.Devices.Device
alias Towerops.Repo
alias Towerops.Snmp.Interface
@doc """
Builds lookup maps for matching evidence to managed devices in an organization.
Returns a map with :by_ip, :by_name, :by_mac keys.
"""
def build_device_lookup(organization_id) do
devices =
Repo.all(
from(d in Device,
where: d.organization_id == ^organization_id,
left_join: sd in assoc(d, :snmp_device),
left_join: i in Interface,
on: sd.id == i.snmp_device_id,
preload: [snmp_device: {sd, interfaces: i}]
)
)
by_ip = Map.new(devices, fn d -> {d.ip_address, d.id} end)
by_name =
devices
|> Enum.filter(&(&1.name != nil))
|> Map.new(&{String.downcase(&1.name), &1.id})
by_mac =
devices
|> Enum.flat_map(fn device ->
case device.snmp_device do
nil -> []
sd ->
sd.interfaces
|> Enum.filter(&(&1.if_phys_address != nil))
|> Enum.map(&{&1.if_phys_address, device.id})
end
end)
|> Map.new()
%{by_ip: by_ip, by_name: by_name, by_mac: by_mac}
end
@doc "Find a device ID by IP address."
def find_device_by_ip(lookup, ip), do: Map.get(lookup.by_ip, ip)
@doc "Find a device ID by hostname (case-insensitive)."
def find_device_by_name(lookup, name) when is_binary(name),
do: Map.get(lookup.by_name, String.downcase(name))
def find_device_by_name(_lookup, _name), do: nil
@doc "Find a device ID by MAC address."
def find_device_by_mac(lookup, mac), do: Map.get(lookup.by_mac, mac)
@doc """
Resolve a piece of evidence to a managed device ID.
Tries MAC, then IP, then hostname. Returns nil if no match.
"""
def resolve_device(lookup, %{mac: mac, ip: ip, name: name}) do
find_device_by_mac(lookup, mac) ||
find_device_by_ip(lookup, ip) ||
find_device_by_name(lookup, name)
end
def resolve_device(lookup, attrs) do
find_device_by_mac(lookup, Map.get(attrs, :mac)) ||
find_device_by_ip(lookup, Map.get(attrs, :ip)) ||
find_device_by_name(lookup, Map.get(attrs, :name))
end
end
Step 4: Run tests
mix test test/towerops/topology_test.exs
Expected: All pass.
Step 5: Commit
feat: add Topology context with device matching
Build lookup maps to resolve evidence (MAC, IP, hostname) to
managed device IDs. Foundation for evidence collectors.
Task 4: Evidence Collectors (LLDP/CDP)
Files:
- Modify:
lib/towerops/topology.ex - Modify:
test/towerops/topology_test.exs
Step 1: Write failing tests for LLDP/CDP evidence collection
Add to test/towerops/topology_test.exs:
describe "collect_lldp_evidence/1" do
test "returns evidence from LLDP neighbors", %{router: router, interface: interface} do
# Create an LLDP neighbor on the router's interface
{:ok, _neighbor} =
Towerops.Snmp.upsert_neighbor(%{
device_id: router.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "dd:ee:ff:00:11:22",
remote_system_name: "remote-switch",
remote_address: "10.0.0.5",
remote_port_id: "ge-0/0/1",
remote_capabilities: ["switch", "bridge"],
last_discovered_at: DateTime.utc_now()
})
evidence = Topology.collect_lldp_evidence(router.id)
assert length(evidence) == 1
[ev] = evidence
assert ev.evidence_type == "lldp_neighbor"
assert ev.confidence == 0.95
assert ev.remote_mac == "dd:ee:ff:00:11:22"
assert ev.remote_ip == "10.0.0.5"
assert ev.remote_name == "remote-switch"
assert ev.source_interface_id == interface.id
end
test "returns empty list when no LLDP neighbors", %{router: router} do
assert Topology.collect_lldp_evidence(router.id) == []
end
end
describe "collect_cdp_evidence/1" do
test "returns evidence from CDP neighbors", %{router: router, interface: interface} do
{:ok, _neighbor} =
Towerops.Snmp.upsert_neighbor(%{
device_id: router.id,
interface_id: interface.id,
protocol: "cdp",
remote_chassis_id: "11:22:33:44:55:66",
remote_system_name: "cisco-switch",
remote_address: "10.0.0.6",
remote_port_id: "Gi0/1",
remote_capabilities: ["router"],
last_discovered_at: DateTime.utc_now()
})
evidence = Topology.collect_cdp_evidence(router.id)
assert length(evidence) == 1
[ev] = evidence
assert ev.evidence_type == "cdp_neighbor"
assert ev.confidence == 0.95
end
end
Step 2: Run test to verify it fails
mix test test/towerops/topology_test.exs --trace
Expected: Fails — collect_lldp_evidence/1 undefined.
Step 3: Implement LLDP/CDP evidence collectors
Add to lib/towerops/topology.ex:
alias Towerops.Snmp.Neighbor
@doc "Collect link evidence from LLDP neighbors for a device."
def collect_lldp_evidence(device_id) do
collect_neighbor_evidence(device_id, "lldp", "lldp_neighbor", 0.95)
end
@doc "Collect link evidence from CDP neighbors for a device."
def collect_cdp_evidence(device_id) do
collect_neighbor_evidence(device_id, "cdp", "cdp_neighbor", 0.95)
end
defp collect_neighbor_evidence(device_id, protocol, evidence_type, confidence) do
Repo.all(
from(n in Neighbor,
join: i in Interface,
on: n.interface_id == i.id,
where: n.device_id == ^device_id and n.protocol == ^protocol,
select: %{
neighbor_id: n.id,
source_interface_id: i.id,
remote_chassis_id: n.remote_chassis_id,
remote_system_name: n.remote_system_name,
remote_address: n.remote_address,
remote_port_id: n.remote_port_id,
remote_capabilities: n.remote_capabilities,
last_discovered_at: n.last_discovered_at
}
)
)
|> Enum.map(fn row ->
%{
evidence_type: evidence_type,
confidence: confidence,
source_interface_id: row.source_interface_id,
remote_mac: row.remote_chassis_id,
remote_ip: row.remote_address,
remote_name: row.remote_system_name,
remote_port: row.remote_port_id,
remote_capabilities: row.remote_capabilities,
evidence_data: %{
"neighbor_id" => row.neighbor_id,
"remote_chassis_id" => row.remote_chassis_id,
"remote_port_id" => row.remote_port_id,
"last_discovered_at" => row.last_discovered_at
}
}
end)
end
Step 4: Run tests
mix test test/towerops/topology_test.exs
Expected: All pass.
Step 5: Commit
feat: add LLDP/CDP evidence collectors
Collect link evidence from SNMP neighbor data with 0.95 confidence.
Returns structured evidence maps ready for link upsert.
Task 5: Evidence Collectors (MAC + ARP)
Files:
- Modify:
lib/towerops/topology.ex - Modify:
test/towerops/topology_test.exs
Step 1: Write failing tests for MAC and ARP evidence
Add to test/towerops/topology_test.exs:
describe "collect_mac_evidence/2" do
test "detects link when device MAC appears in another device's MAC table",
%{organization: org, router: router, switch: switch} do
# Create SNMP device + interface for the switch
switch_snmp =
%Towerops.Snmp.Device{}
|> Towerops.Snmp.Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
|> Repo.insert!()
switch_iface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: switch_snmp.id,
if_index: 1,
if_name: "ge-0/0/1",
if_phys_address: "bb:cc:dd:00:11:22"
})
|> Repo.insert!()
# The switch's MAC table shows the router's MAC on ge-0/0/1
Towerops.Snmp.upsert_mac_address(%{
device_id: switch.id,
interface_id: switch_iface.id,
mac_address: "aa:bb:cc:00:11:22",
vlan_id: 1,
entry_status: "learned",
last_seen_at: DateTime.utc_now()
})
lookup = Topology.build_device_lookup(org.id)
evidence = Topology.collect_mac_evidence(switch.id, lookup)
assert length(evidence) == 1
[ev] = evidence
assert ev.evidence_type == "mac_on_interface"
assert ev.confidence == 0.7
assert ev.remote_mac == "aa:bb:cc:00:11:22"
end
test "ignores MAC addresses that don't match any device",
%{organization: org, switch: switch} do
switch_snmp =
%Towerops.Snmp.Device{}
|> Towerops.Snmp.Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
|> Repo.insert!()
switch_iface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: switch_snmp.id,
if_index: 1,
if_name: "ge-0/0/1"
})
|> Repo.insert!()
Towerops.Snmp.upsert_mac_address(%{
device_id: switch.id,
interface_id: switch_iface.id,
mac_address: "ff:ff:ff:00:00:01",
vlan_id: 1,
entry_status: "learned",
last_seen_at: DateTime.utc_now()
})
lookup = Topology.build_device_lookup(org.id)
evidence = Topology.collect_mac_evidence(switch.id, lookup)
# Unmatched MACs still produce evidence but with target_device_id = nil
# They become discovered nodes
assert length(evidence) == 1
[ev] = evidence
assert ev.remote_mac == "ff:ff:ff:00:00:01"
assert ev.matched_device_id == nil
end
end
describe "collect_arp_evidence/2" do
test "detects link when ARP entry IP matches a device",
%{organization: org, router: router, switch: switch} do
Towerops.Snmp.upsert_arp_entry(%{
device_id: router.id,
ip_address: "10.0.0.2",
mac_address: "bb:cc:dd:00:11:22",
entry_type: "dynamic",
last_seen_at: DateTime.utc_now()
})
lookup = Topology.build_device_lookup(org.id)
evidence = Topology.collect_arp_evidence(router.id, lookup)
matched = Enum.find(evidence, &(&1.matched_device_id == switch.id))
assert matched != nil
assert matched.evidence_type == "arp_entry"
assert matched.confidence == 0.6
end
end
Step 2: Run tests to verify failure
mix test test/towerops/topology_test.exs
Step 3: Implement MAC and ARP evidence collectors
Add to lib/towerops/topology.ex:
alias Towerops.Snmp.ArpEntry
alias Towerops.Snmp.MacAddress
@doc "Collect link evidence from MAC address table entries."
def collect_mac_evidence(device_id, lookup) do
Repo.all(
from(m in MacAddress,
where: m.device_id == ^device_id,
select: %{
interface_id: m.interface_id,
mac_address: m.mac_address,
vlan_id: m.vlan_id,
last_seen_at: m.last_seen_at
}
)
)
|> Enum.map(fn row ->
matched_id = find_device_by_mac(lookup, row.mac_address)
%{
evidence_type: "mac_on_interface",
confidence: 0.7,
source_interface_id: row.interface_id,
remote_mac: row.mac_address,
remote_ip: nil,
remote_name: nil,
remote_port: nil,
remote_capabilities: nil,
matched_device_id: matched_id,
evidence_data: %{
"mac_address" => row.mac_address,
"vlan_id" => row.vlan_id,
"last_seen_at" => row.last_seen_at
}
}
end)
# Exclude the device's own MACs
|> Enum.reject(fn ev -> ev.matched_device_id == device_id end)
end
@doc "Collect link evidence from ARP table entries."
def collect_arp_evidence(device_id, lookup) do
Repo.all(
from(a in ArpEntry,
where: a.device_id == ^device_id,
select: %{
interface_id: a.interface_id,
ip_address: a.ip_address,
mac_address: a.mac_address,
last_seen_at: a.last_seen_at
}
)
)
|> Enum.map(fn row ->
matched_id =
find_device_by_mac(lookup, row.mac_address) ||
find_device_by_ip(lookup, row.ip_address)
%{
evidence_type: "arp_entry",
confidence: 0.6,
source_interface_id: row.interface_id,
remote_mac: row.mac_address,
remote_ip: row.ip_address,
remote_name: nil,
remote_port: nil,
remote_capabilities: nil,
matched_device_id: matched_id,
evidence_data: %{
"ip_address" => row.ip_address,
"mac_address" => row.mac_address,
"last_seen_at" => row.last_seen_at
}
}
end)
|> Enum.reject(fn ev -> ev.matched_device_id == device_id end)
end
Step 4: Run tests
mix test test/towerops/topology_test.exs
Expected: All pass.
Step 5: Commit
feat: add MAC and ARP evidence collectors
MAC evidence (0.7 confidence) cross-references MAC table entries
with known device interface MACs. ARP evidence (0.6 confidence)
matches ARP entries by IP and MAC. Both track unmatched entries
as potential discovered devices.
Task 6: Confidence Merging and Link Upsert
Files:
- Modify:
lib/towerops/topology.ex - Modify:
test/towerops/topology_test.exs
Step 1: Write failing tests for confidence merging and link upsert
describe "merge_confidence/2" do
test "combines two independent confidence scores" do
assert_in_delta Topology.merge_confidence(0.95, 0.7), 0.985, 0.001
end
test "combining with 0 returns the other score" do
assert Topology.merge_confidence(0.8, 0.0) == 0.8
end
test "combining two high scores approaches 1.0" do
assert_in_delta Topology.merge_confidence(0.95, 0.95), 0.9975, 0.001
end
end
describe "upsert_link/1" do
test "creates a new link from evidence", %{router: router, interface: interface} do
now = DateTime.utc_now()
{:ok, link} =
Topology.upsert_link(%{
source_device_id: router.id,
target_device_id: nil,
source_interface_id: interface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "dd:ee:ff:00:11:22",
discovered_remote_ip: "10.0.0.5",
discovered_remote_name: "remote-switch",
last_confirmed_at: now,
evidence: [%{
evidence_type: "lldp_neighbor",
evidence_data: %{"remote_chassis_id" => "dd:ee:ff:00:11:22"},
observed_at: now
}]
})
assert link.source_device_id == router.id
assert link.confidence == 0.95
assert link.link_type == "lldp"
assert length(Repo.preload(link, :evidence).evidence) == 1
end
test "updates existing link and merges confidence", %{router: router, interface: interface} do
now = DateTime.utc_now()
# Create initial link
{:ok, _link} =
Topology.upsert_link(%{
source_device_id: router.id,
source_interface_id: interface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "dd:ee:ff:00:11:22",
last_confirmed_at: now,
evidence: [%{
evidence_type: "lldp_neighbor",
evidence_data: %{},
observed_at: now
}]
})
# Upsert again with MAC evidence
{:ok, updated} =
Topology.upsert_link(%{
source_device_id: router.id,
source_interface_id: interface.id,
link_type: "mac_match",
confidence: 0.7,
discovered_remote_mac: "dd:ee:ff:00:11:22",
last_confirmed_at: now,
evidence: [%{
evidence_type: "mac_on_interface",
evidence_data: %{},
observed_at: now
}]
})
# Confidence should be merged, not replaced
assert_in_delta updated.confidence, 0.985, 0.001
end
end
Step 2: Run tests to verify failure
mix test test/towerops/topology_test.exs
Step 3: Implement confidence merging and link upsert
Add to lib/towerops/topology.ex:
alias Towerops.Topology.DeviceLink
alias Towerops.Topology.DeviceLinkEvidence
@doc "Merge two independent confidence scores: 1 - (1-a)(1-b)"
def merge_confidence(a, b) do
1.0 - (1.0 - a) * (1.0 - b)
end
@doc """
Create or update a device link. If a link already exists for the same
source_device + source_interface + remote_mac, updates confidence
(merged) and last_confirmed_at. Always appends new evidence.
"""
def upsert_link(attrs) do
evidence_attrs = Map.get(attrs, :evidence, [])
link_attrs = Map.drop(attrs, [:evidence])
existing =
Repo.one(
from(l in DeviceLink,
where:
l.source_device_id == ^link_attrs.source_device_id and
l.source_interface_id == ^link_attrs[:source_interface_id] and
l.discovered_remote_mac == ^link_attrs[:discovered_remote_mac]
)
)
result =
case existing do
nil ->
%DeviceLink{}
|> DeviceLink.changeset(link_attrs)
|> Repo.insert()
link ->
merged_confidence = merge_confidence(link.confidence, link_attrs.confidence)
link
|> DeviceLink.changeset(%{
confidence: merged_confidence,
last_confirmed_at: link_attrs.last_confirmed_at,
target_device_id: link_attrs[:target_device_id] || link.target_device_id,
metadata: Map.merge(link.metadata || %{}, link_attrs[:metadata] || %{})
})
|> Repo.update()
end
with {:ok, link} <- result do
# Insert evidence records
Enum.each(evidence_attrs, fn ev_attrs ->
%DeviceLinkEvidence{}
|> DeviceLinkEvidence.changeset(Map.put(ev_attrs, :device_link_id, link.id))
|> Repo.insert!()
end)
{:ok, Repo.reload!(link)}
end
end
Step 4: Run tests
mix test test/towerops/topology_test.exs
Expected: All pass.
Step 5: Commit
feat: add confidence merging and link upsert
Links are created or updated via upsert keyed on source device +
interface + remote MAC. Confidence merges using 1-(1-a)(1-b).
Evidence records are always appended.
Task 7: process_device/1 — Main Pipeline
Orchestrates evidence collection, link upsert, and role inference for a single device.
Files:
- Modify:
lib/towerops/topology.ex - Modify:
test/towerops/topology_test.exs
Step 1: Write failing test for process_device
describe "process_device/1" do
test "creates links from LLDP neighbors", %{router: router, switch: switch, interface: interface, organization: org} do
# Create SNMP device for switch so its MAC is in the lookup
switch_snmp =
%Towerops.Snmp.Device{}
|> Towerops.Snmp.Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
|> Repo.insert!()
_switch_iface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: switch_snmp.id,
if_index: 1,
if_name: "ge-0/0/1",
if_phys_address: "bb:cc:dd:00:11:22"
})
|> Repo.insert!()
# Create LLDP neighbor on router pointing to the switch's MAC
{:ok, _} =
Towerops.Snmp.upsert_neighbor(%{
device_id: router.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "bb:cc:dd:00:11:22",
remote_system_name: "Main-Switch",
remote_address: "10.0.0.2",
remote_port_id: "ge-0/0/1",
remote_capabilities: ["switch", "bridge"],
last_discovered_at: DateTime.utc_now()
})
assert {:ok, :changed} = Topology.process_device(router, org.id)
# Verify link was created
links = Topology.list_device_links(router.id)
assert length(links) == 1
[link] = links
assert link.source_device_id == router.id
assert link.target_device_id == switch.id
assert link.link_type == "lldp"
assert_in_delta link.confidence, 0.95, 0.01
end
test "creates discovered link when remote device is not managed",
%{router: router, interface: interface, organization: org} do
{:ok, _} =
Towerops.Snmp.upsert_neighbor(%{
device_id: router.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "ff:ff:ff:00:00:01",
remote_system_name: "unknown-device",
remote_address: "10.99.99.99",
remote_port_id: "eth0",
remote_capabilities: [],
last_discovered_at: DateTime.utc_now()
})
assert {:ok, :changed} = Topology.process_device(router, org.id)
links = Topology.list_device_links(router.id)
assert length(links) == 1
[link] = links
assert link.target_device_id == nil
assert link.discovered_remote_mac == "ff:ff:ff:00:00:01"
assert link.discovered_remote_name == "unknown-device"
end
test "returns :unchanged when no new evidence", %{router: router, organization: org} do
assert {:ok, :unchanged} = Topology.process_device(router, org.id)
end
end
Step 2: Run tests to verify failure
mix test test/towerops/topology_test.exs
Step 3: Implement process_device/1 and list_device_links/1
Add to lib/towerops/topology.ex:
require Logger
@doc """
Main topology inference pipeline for a device. Called after each poll cycle.
Collects evidence, upserts links, infers role. Returns {:ok, :changed} or {:ok, :unchanged}.
"""
def process_device(device, organization_id) do
lookup = build_device_lookup(organization_id)
now = DateTime.utc_now()
# Collect all evidence
lldp = collect_lldp_evidence(device.id)
cdp = collect_cdp_evidence(device.id)
mac = collect_mac_evidence(device.id, lookup)
arp = collect_arp_evidence(device.id, lookup)
all_evidence = lldp ++ cdp ++ mac ++ arp
if Enum.empty?(all_evidence) do
{:ok, :unchanged}
else
# Group evidence by remote identifier (MAC preferred, then IP)
grouped = group_evidence_by_remote(all_evidence)
# Upsert links for each remote device
changes =
Enum.map(grouped, fn {_key, evidence_list} ->
best = Enum.max_by(evidence_list, & &1.confidence)
matched_id = resolve_device(lookup, %{
mac: best.remote_mac,
ip: best.remote_ip,
name: best.remote_name
})
# Don't create self-links
if matched_id == device.id do
:skip
else
upsert_link(%{
source_device_id: device.id,
target_device_id: matched_id,
source_interface_id: best.source_interface_id,
link_type: best.evidence_type |> evidence_type_to_link_type(),
confidence: combine_evidence_confidence(evidence_list),
discovered_remote_mac: best.remote_mac,
discovered_remote_ip: best.remote_ip,
discovered_remote_name: best.remote_name,
metadata: %{},
last_confirmed_at: now,
evidence: Enum.map(evidence_list, fn ev ->
%{
evidence_type: ev.evidence_type,
evidence_data: ev.evidence_data,
observed_at: now
}
end)
})
end
end)
|> Enum.reject(&(&1 == :skip))
has_changes = Enum.any?(changes, fn
{:ok, _} -> true
_ -> false
end)
if has_changes do
# Broadcast topology change
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"topology:#{organization_id}",
{:topology_updated, organization_id}
)
{:ok, :changed}
else
{:ok, :unchanged}
end
end
end
@doc "List all device links where this device is the source."
def list_device_links(device_id) do
Repo.all(
from(l in DeviceLink,
where: l.source_device_id == ^device_id,
order_by: [desc: l.confidence]
)
)
end
# Group evidence by remote identifier (prefer MAC, fallback to IP)
defp group_evidence_by_remote(evidence_list) do
Enum.group_by(evidence_list, fn ev ->
ev.remote_mac || ev.remote_ip || ev.remote_name || "unknown"
end)
end
# Combine confidence scores from multiple evidence items
defp combine_evidence_confidence(evidence_list) do
evidence_list
|> Enum.map(& &1.confidence)
|> Enum.reduce(0.0, &merge_confidence/2)
end
defp evidence_type_to_link_type("lldp_neighbor"), do: "lldp"
defp evidence_type_to_link_type("cdp_neighbor"), do: "cdp"
defp evidence_type_to_link_type("mac_on_interface"), do: "mac_match"
defp evidence_type_to_link_type("arp_entry"), do: "arp_inference"
defp evidence_type_to_link_type("wireless_registration"), do: "wireless_association"
defp evidence_type_to_link_type(_), do: "mac_match"
Step 4: Run tests
mix test test/towerops/topology_test.exs
Expected: All pass.
Step 5: Commit
feat: add process_device/1 topology pipeline
Orchestrates evidence collection from LLDP, CDP, MAC, and ARP data.
Groups evidence by remote device, resolves to managed devices,
upserts links with merged confidence. Broadcasts topology changes
via PubSub.
Task 8: Device Role Inference
Files:
- Modify:
lib/towerops/topology.ex - Modify:
test/towerops/topology_test.exs
Step 1: Write failing tests for role inference
describe "infer_device_role/2" do
test "infers access_point from LLDP wlan-ap capability",
%{router: router, interface: interface} do
{:ok, _} =
Towerops.Snmp.upsert_neighbor(%{
device_id: router.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "aa:bb:cc:00:11:22",
remote_system_name: "core-router",
remote_capabilities: ["wlan-ap"],
last_discovered_at: DateTime.utc_now()
})
role = Topology.infer_device_role(router)
assert role == "access_point"
end
test "infers core_router from router capability + many interfaces",
%{router: router, snmp_device: snmp_device, interface: _interface} do
# Add 10+ interfaces
for i <- 2..12 do
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: i,
if_name: "eth#{i}"
})
|> Repo.insert!()
end
{:ok, _} =
Towerops.Snmp.upsert_neighbor(%{
device_id: router.id,
interface_id: Repo.one(from(i in Interface, where: i.snmp_device_id == ^snmp_device.id, limit: 1)).id,
protocol: "lldp",
remote_chassis_id: "11:22:33:44:55:66",
remote_capabilities: ["router"],
last_discovered_at: DateTime.utc_now()
})
role = Topology.infer_device_role(router)
assert role == "core_router"
end
test "returns unknown when no evidence", %{switch: switch} do
role = Topology.infer_device_role(switch)
assert role == "unknown"
end
test "infers ups from manufacturer", %{router: router, snmp_device: snmp_device} do
snmp_device
|> Towerops.Snmp.Device.changeset(%{manufacturer: "APC"})
|> Repo.update!()
role = Topology.infer_device_role(router)
assert role == "ups"
end
end
Step 2: Run tests to verify failure
mix test test/towerops/topology_test.exs
Step 3: Implement role inference
Add to lib/towerops/topology.ex:
alias Towerops.Snmp.Device, as: SnmpDevice
@ups_vendors ~w(apc cyberpower eaton tripp\ lite tripplite)
@firewall_vendors ~w(fortinet fortigate palo\ alto paloalto pfsense sonicwall sophos)
@server_platforms ~w(linux windows vmware esxi proxmox)
@doc """
Infer the role of a device from its SNMP data, neighbors, and links.
Returns a role string. Does not write to the database.
"""
def infer_device_role(device) do
device = Repo.preload(device, snmp_device: :interfaces)
snmp_device = device.snmp_device
interface_count = if snmp_device, do: length(snmp_device.interfaces), else: 0
manufacturer = if snmp_device, do: String.downcase(snmp_device.manufacturer || ""), else: ""
sys_descr = if snmp_device, do: String.downcase(snmp_device.sys_descr || ""), else: ""
# Collect LLDP/CDP capabilities reported BY other devices about this one
own_capabilities = get_device_reported_capabilities(device.id)
cond do
"wlan-ap" in own_capabilities ->
"access_point"
"router" in own_capabilities and interface_count >= 10 ->
"core_router"
"router" in own_capabilities ->
"distribution_switch"
"bridge" in own_capabilities and interface_count >= 10 ->
"access_switch"
"bridge" in own_capabilities ->
"access_switch"
vendor_match?(manufacturer, @ups_vendors) ->
"ups"
vendor_match?(manufacturer, @firewall_vendors) or vendor_match?(sys_descr, @firewall_vendors) ->
"firewall"
platform_match?(sys_descr, @server_platforms) ->
"server"
true ->
"unknown"
end
end
@doc """
Apply inferred role to a device, only if device_role_source is "inferred".
"""
def maybe_update_device_role(device) do
if device.device_role_source != "manual" do
role = infer_device_role(device)
if role != device.device_role do
device
|> Ecto.Changeset.change(%{device_role: role, device_role_source: "inferred"})
|> Repo.update()
else
{:ok, device}
end
else
{:ok, device}
end
end
# Get capabilities that OTHER devices report about this device via LLDP/CDP
defp get_device_reported_capabilities(device_id) do
device = Repo.preload(%Towerops.Devices.Device{id: device_id}, snmp_device: :interfaces)
case device.snmp_device do
nil ->
[]
sd ->
mac_addresses =
sd.interfaces
|> Enum.map(& &1.if_phys_address)
|> Enum.reject(&is_nil/1)
if Enum.empty?(mac_addresses) do
[]
else
Repo.all(
from(n in Neighbor,
where: n.remote_chassis_id in ^mac_addresses,
select: n.remote_capabilities
)
)
|> List.flatten()
|> Enum.uniq()
end
end
end
defp vendor_match?(value, vendors) do
Enum.any?(vendors, &String.contains?(value, &1))
end
defp platform_match?(description, platforms) do
Enum.any?(platforms, &String.contains?(description, &1))
end
Step 4: Run tests
mix test test/towerops/topology_test.exs
Expected: All pass.
Step 5: Wire role inference into process_device
Add maybe_update_device_role(device) call at the end of process_device/1, before the return.
Step 6: Commit
feat: add device role inference
Infers device roles from LLDP/CDP capabilities, interface count,
vendor/manufacturer, and system description. Only overwrites
inferred roles, never manual overrides.
Task 9: Hook into DevicePollerWorker
Files:
- Modify:
lib/towerops/workers/device_poller_worker.ex
Step 1: Add topology processing call
In lib/towerops/workers/device_poller_worker.ex, modify poll_device_neighbors/3 (line 315-334). After the existing PubSub broadcast, add:
# Run topology inference after neighbor data is saved
case Towerops.Topology.process_device(device, device.organization_id) do
{:ok, :changed} ->
Logger.debug("Topology updated for #{device.name}")
{:ok, :unchanged} ->
:ok
{:error, reason} ->
Logger.warning("Topology inference failed for #{device.name}: #{inspect(reason)}")
end
Also add alias Towerops.Topology to the module aliases at the top.
Step 2: Verify existing tests still pass
mix test test/towerops/workers/device_poller_worker_test.exs
Expected: All existing tests pass (topology is additive, doesn't break anything).
Step 3: Commit
feat: hook topology inference into polling pipeline
DevicePollerWorker calls Topology.process_device/1 after saving
neighbor data. Topology updates happen incrementally every poll
cycle with no new workers needed.
Task 10: Stale Link Cleanup
Files:
- Modify:
lib/towerops/topology.ex - Modify:
test/towerops/topology_test.exs
Step 1: Write failing test for stale link cleanup
describe "cleanup_stale_links/0" do
test "marks old links as stale", %{router: router, interface: interface} do
old_time = DateTime.add(DateTime.utc_now(), -25, :hour)
{:ok, link} =
%DeviceLink{}
|> DeviceLink.changeset(%{
source_device_id: router.id,
source_interface_id: interface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "aa:bb:cc:dd:ee:ff",
last_confirmed_at: old_time
})
|> Repo.insert()
{stale_count, deleted_count} = Topology.cleanup_stale_links()
updated = Repo.get!(DeviceLink, link.id)
assert_in_delta updated.confidence, 0.1, 0.01
assert stale_count >= 1
end
test "deletes very old links", %{router: router, interface: interface} do
very_old = DateTime.add(DateTime.utc_now(), -73, :hour)
{:ok, link} =
%DeviceLink{}
|> DeviceLink.changeset(%{
source_device_id: router.id,
source_interface_id: interface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "aa:bb:cc:dd:ee:ff",
last_confirmed_at: very_old
})
|> Repo.insert()
{_stale, deleted_count} = Topology.cleanup_stale_links()
assert deleted_count >= 1
assert Repo.get(DeviceLink, link.id) == nil
end
end
Step 2: Run tests to verify failure
Step 3: Implement cleanup
Add to lib/towerops/topology.ex:
@doc """
Clean up stale links. Called periodically (e.g. hourly).
- Links not confirmed in 24h: set confidence to 0.1
- Links not confirmed in 72h: delete entirely
Returns {stale_count, deleted_count}.
"""
def cleanup_stale_links do
now = DateTime.utc_now()
stale_cutoff = DateTime.add(now, -24, :hour)
delete_cutoff = DateTime.add(now, -72, :hour)
# Delete very old links
{deleted_count, _} =
Repo.delete_all(
from(l in DeviceLink, where: l.last_confirmed_at < ^delete_cutoff)
)
# Mark stale links
{stale_count, _} =
Repo.update_all(
from(l in DeviceLink,
where: l.last_confirmed_at < ^stale_cutoff and l.confidence > 0.1
),
set: [confidence: 0.1]
)
{stale_count, deleted_count}
end
Step 4: Hook into NeighborCleanupWorker
Find the existing NeighborCleanupWorker and add a call to Towerops.Topology.cleanup_stale_links() in its perform/1.
Step 5: Run tests
mix test test/towerops/topology_test.exs
Step 6: Commit
feat: add stale link cleanup
Links unconfirmed for 24h get confidence reduced to 0.1.
Links unconfirmed for 72h are deleted. Runs via existing
NeighborCleanupWorker hourly cron.
Task 11: Update NetworkMapLive to Use Persistent Topology
Files:
- Modify:
lib/towerops/topology.ex(addget_topology_for_map/2) - Modify:
lib/towerops_web/live/network_map_live.ex - Modify:
test/towerops/topology_test.exs
Step 1: Write failing test for map topology query
describe "get_topology_for_map/2" do
test "returns nodes and edges for added-only tab",
%{router: router, switch: switch, interface: interface, organization: org} do
# Set up a link between router and switch
{:ok, _} =
Topology.upsert_link(%{
source_device_id: router.id,
target_device_id: switch.id,
source_interface_id: interface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "bb:cc:dd:00:11:22",
last_confirmed_at: DateTime.utc_now(),
evidence: []
})
result = Topology.get_topology_for_map(org.id, "added")
assert length(result.nodes) == 2
assert length(result.edges) == 1
assert Enum.all?(result.nodes, &(!&1.discovered))
end
test "includes discovered nodes on all tab",
%{router: router, interface: interface, organization: org} do
{:ok, _} =
Topology.upsert_link(%{
source_device_id: router.id,
target_device_id: nil,
source_interface_id: interface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "ff:ff:ff:00:00:01",
discovered_remote_name: "unknown-cpe",
discovered_remote_ip: "10.99.99.1",
last_confirmed_at: DateTime.utc_now(),
evidence: []
})
result = Topology.get_topology_for_map(org.id, "all")
discovered = Enum.filter(result.nodes, & &1.discovered)
assert length(discovered) == 1
end
end
Step 2: Implement get_topology_for_map
Add to lib/towerops/topology.ex:
@doc """
Build topology data for the network map. Returns %{nodes, edges, stats}.
Tab "added" = managed devices only. Tab "all" = managed + discovered.
"""
def get_topology_for_map(organization_id, tab \\ "added") do
# Get managed devices
devices =
Repo.all(
from(d in Device,
where: d.organization_id == ^organization_id,
left_join: s in assoc(d, :site),
left_join: sd in assoc(d, :snmp_device),
preload: [site: s, snmp_device: sd]
)
)
device_ids = Enum.map(devices, & &1.id)
# Get all links from these devices
links =
Repo.all(
from(l in DeviceLink,
where: l.source_device_id in ^device_ids,
preload: [:source_interface, :target_interface]
)
)
# Build managed device nodes
managed_nodes =
Enum.map(devices, fn d ->
%{
id: d.id,
label: d.name,
type: role_to_type_atom(d.device_role),
device_role: d.device_role,
status: d.status,
site_id: d.site_id,
site_name: if(d.site, do: d.site.name),
ip_address: d.ip_address,
discovered: false,
manufacturer: if(d.snmp_device, do: d.snmp_device.manufacturer)
}
end)
# Build discovered nodes from links with nil target_device_id
discovered_nodes =
if tab == "all" do
links
|> Enum.filter(&is_nil(&1.target_device_id))
|> Enum.uniq_by(& &1.discovered_remote_mac)
|> Enum.map(fn link ->
%{
id: "discovered_#{link.discovered_remote_mac}",
label: link.discovered_remote_name || link.discovered_remote_mac,
type: :unknown,
device_role: nil,
status: :unknown,
site_id: nil,
site_name: nil,
ip_address: link.discovered_remote_ip,
discovered: true,
manufacturer: nil,
parent_device_id: link.source_device_id
}
end)
else
[]
end
all_nodes = managed_nodes ++ discovered_nodes
node_ids = MapSet.new(Enum.map(all_nodes, & &1.id))
# Build edges
edges =
links
|> Enum.map(fn link ->
target_id =
if link.target_device_id do
link.target_device_id
else
"discovered_#{link.discovered_remote_mac}"
end
%{
id: link.id,
source: link.source_device_id,
target: target_id,
source_interface: if(link.source_interface, do: link.source_interface.if_name),
target_interface: if(link.target_interface, do: link.target_interface.if_name),
link_type: link.link_type,
confidence: link.confidence,
metadata: link.metadata
}
end)
|> Enum.filter(fn edge ->
MapSet.member?(node_ids, edge.source) and MapSet.member?(node_ids, edge.target)
end)
managed_count = length(managed_nodes)
discovered_count = length(discovered_nodes)
%{
nodes: all_nodes,
edges: edges,
stats: %{
total_devices: managed_count + discovered_count,
added_devices: managed_count,
discovered_devices: discovered_count,
total_links: length(edges)
},
last_updated: DateTime.utc_now()
}
end
defp role_to_type_atom("core_router"), do: :router
defp role_to_type_atom("distribution_switch"), do: :switch
defp role_to_type_atom("access_switch"), do: :switch
defp role_to_type_atom("access_point"), do: :wireless
defp role_to_type_atom("cpe"), do: :wireless
defp role_to_type_atom("backhaul_radio"), do: :wireless
defp role_to_type_atom("ups"), do: :server
defp role_to_type_atom("firewall"), do: :firewall
defp role_to_type_atom("server"), do: :server
defp role_to_type_atom(_), do: :unknown
Step 3: Update NetworkMapLive to use new function
In lib/towerops_web/live/network_map_live.ex, change load_topology_data/3:
alias Towerops.Topology
defp load_topology_data(socket, organization_id, tab) do
topology = Topology.get_topology_for_map(organization_id, tab)
socket
|> assign(:topology, topology)
|> assign(:loading, false)
|> push_event("update_topology", %{topology: topology})
end
Remove the old filter_edges_for_nodes/3 private function (no longer needed).
Step 4: Run tests
mix test test/towerops/topology_test.exs
mix test test/towerops_web/live/network_map_live_test.exs 2>/dev/null; echo "done"
Step 5: Commit
feat: switch network map to persistent topology data
NetworkMapLive now reads from device_links instead of computing
topology on-demand from raw neighbor data. Supports both
managed-only and all-devices views.
Task 12: Device Detail Page — Connected Devices Section
Files:
- Modify:
lib/towerops/topology.ex(addlist_connected_devices/1) - Modify:
lib/towerops_web/live/device_live/show.ex - Modify:
lib/towerops_web/live/device_live/show.html.heex
Step 1: Write failing test for list_connected_devices
Add to test/towerops/topology_test.exs:
describe "list_connected_devices/1" do
test "returns managed and discovered links for a device",
%{router: router, switch: switch, interface: interface} do
now = DateTime.utc_now()
# Managed link
{:ok, _} =
Topology.upsert_link(%{
source_device_id: router.id,
target_device_id: switch.id,
source_interface_id: interface.id,
link_type: "lldp",
confidence: 0.95,
discovered_remote_mac: "bb:cc:dd:00:11:22",
last_confirmed_at: now,
evidence: []
})
# Discovered link
{:ok, _} =
Topology.upsert_link(%{
source_device_id: router.id,
source_interface_id: interface.id,
link_type: "lldp",
confidence: 0.8,
discovered_remote_mac: "ff:ff:ff:00:00:01",
discovered_remote_name: "CPE-Customer",
discovered_remote_ip: "10.0.5.47",
last_confirmed_at: now,
evidence: []
})
connected = Topology.list_connected_devices(router.id)
assert length(connected) == 2
end
end
Step 2: Implement list_connected_devices
Add to lib/towerops/topology.ex:
@doc """
List all devices connected to this device via device_links.
Returns links with preloaded target device (if managed) and interfaces.
"""
def list_connected_devices(device_id) do
Repo.all(
from(l in DeviceLink,
where: l.source_device_id == ^device_id or l.target_device_id == ^device_id,
preload: [:source_device, :target_device, :source_interface, :target_interface],
order_by: [desc: l.confidence]
)
)
end
Step 3: Add connected devices to device detail page
In lib/towerops_web/live/device_live/show.ex, add connected_devices to the assigns loaded in load_equipment_data/2. Load it when the overview tab is active.
In the template (show.html.heex), add a "Connected Devices" section to the overview tab that renders the links as a table. Show device name/IP, link type, confidence badge, and "Managed"/"Discovered" indicator. Link managed devices to their detail pages.
Step 4: Run tests
mix test test/towerops/topology_test.exs
Step 5: Commit
feat: show connected devices on device detail page
Overview tab shows persistent links to other devices with
link type, confidence, and managed/discovered indicators.
Managed devices link to their detail pages.
Task 13: Device Role on Edit Page
Files:
- Modify:
lib/towerops_web/live/device_live/form_component.ex(or wherever device edit form lives)
Step 1: Add device_role dropdown to the device edit form
Add a select input with all role options plus a "Reset to auto-detect" option. When a role is manually selected, set device_role_source to "manual". When "Auto-detect" is selected, set device_role_source to "inferred" and device_role to nil (will be inferred on next poll).
Step 2: Verify it renders and saves correctly
mix test test/towerops_web/live/device_live/
Step 3: Commit
feat: add device role selector to edit page
Users can manually set device role or reset to auto-detect.
Manual overrides are preserved through topology inference cycles.
Task 14: Cytoscape.js Role-Based Styling
Files:
- Modify:
assets/js/app.ts(NetworkMap hook)
Step 1: Update node styling to use device_role
In the NetworkMap hook's Cytoscape style configuration, add role-based node shapes and colors matching the design doc table. Use the device_role field from node data. Keep backward compatibility with the type field.
Step 2: Update edge styling for confidence
- Confidence > 0.9: solid line, green
- Confidence 0.5-0.9: dashed line, yellow
- Confidence < 0.5: dotted line, gray
Add link_type to edge tooltip on hover.
Step 3: Add discovered node styling
Discovered nodes (on "all" tab): dashed border, muted opacity, smaller size.
Step 4: Visual verification
Start the dev server, navigate to /network-map, verify nodes show proper shapes and edges show confidence styling.
Step 5: Commit
feat: role-based node styling on network map
Nodes display shapes and colors based on device_role. Edges
show confidence via line style (solid/dashed/dotted). Discovered
nodes appear muted on the all-devices tab.
Task 15: Run Full Test Suite and Precommit
Step 1: Run precommit
mix precommit
Expected: All checks pass (format, compile warnings-as-errors, tests).
Step 2: Run dialyzer
mix dialyzer
Expected: No new warnings from topology code.
Step 3: Fix any issues found
Step 4: Final commit if any fixes needed
Task Summary
| Task | Description | Dependencies |
|---|---|---|
| 1 | Database migrations | None |
| 2 | Ecto schemas (DeviceLink, DeviceLinkEvidence, Device role fields) | Task 1 |
| 3 | Topology context — device matching | Task 2 |
| 4 | LLDP/CDP evidence collectors | Task 3 |
| 5 | MAC + ARP evidence collectors | Task 3 |
| 6 | Confidence merging and link upsert | Tasks 4, 5 |
| 7 | process_device/1 pipeline | Task 6 |
| 8 | Device role inference | Task 7 |
| 9 | Hook into DevicePollerWorker | Task 7 |
| 10 | Stale link cleanup | Task 2 |
| 11 | Update NetworkMapLive | Task 7 |
| 12 | Device detail — connected devices | Task 7 |
| 13 | Device role on edit page | Task 2 |
| 14 | Cytoscape.js role-based styling | Task 11 |
| 15 | Full test suite + precommit | All |
Tasks 4+5 can run in parallel. Tasks 10, 11, 12, 13 can run in parallel after Task 7.