towerops/docs/plans/2026-02-12-automatic-topology-inference.md
Graham McIntire 7371ceb942
feat: add automatic network topology inference
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
2026-02-12 13:28:01 -06:00

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.

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.

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 (add get_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 (add list_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.