From 7371ceb942d7dd2349ee8e8b3e9efdb82840227a Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 13:28:01 -0600 Subject: [PATCH] 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 --- assets/js/app.ts | 134 +- ...-12-automatic-topology-inference-design.md | 255 ++ ...2026-02-12-automatic-topology-inference.md | 2197 +++++++++++++++++ lib/towerops/snmp/neighbor_cleanup_worker.ex | 10 + lib/towerops/topology.ex | 168 ++ lib/towerops_web/live/device_live/form.ex | 22 +- .../live/device_live/form.html.heex | 24 + lib/towerops_web/live/device_live/show.ex | 1 + .../live/device_live/show.html.heex | 129 + lib/towerops_web/live/network_map_live.ex | 37 +- .../live/network_map_live.html.heex | 89 +- test/towerops/topology_test.exs | 224 ++ .../live/device_live/form_test.exs | 102 + .../live/network_map_live_test.exs | 12 +- 14 files changed, 3289 insertions(+), 115 deletions(-) create mode 100644 docs/plans/2026-02-12-automatic-topology-inference-design.md create mode 100644 docs/plans/2026-02-12-automatic-topology-inference.md diff --git a/assets/js/app.ts b/assets/js/app.ts index 9dfcb56b..fe72ce52 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -596,14 +596,35 @@ const NetworkMap = { initializeCytoscape(this: any, topology: any) { const isDark = document.documentElement.getAttribute('data-theme') === 'dark' - // Define color palette for device types - const deviceColors: Record = { - router: '#3b82f6', // blue - switch: '#a855f7', // purple - wireless: '#22c55e', // green - server: '#f97316', // orange - workstation: '#6b7280', // gray - unknown: '#9ca3af' // light gray + // Role-based node colors (keyed by the `type` field from backend) + const roleColors: Record = { + router: '#3B82F6', // blue + switch: '#10B981', // green + wireless: '#8B5CF6', // purple + firewall: '#EF4444', // red + server: '#F59E0B', // amber + unknown: '#9CA3AF' // light gray + } + + // Role-based node shapes + const roleShapes: Record = { + router: 'round-rectangle', + switch: 'diamond', + wireless: 'triangle', + firewall: 'hexagon', + server: 'rectangle', + unknown: 'ellipse' + } + + // Confidence-based edge styling helper + const getEdgeStyle = (confidence: number) => { + if (confidence > 0.9) { + return { color: '#10B981', style: 'solid', dashPattern: [] } + } else if (confidence >= 0.5) { + return { color: '#F59E0B', style: 'dashed', dashPattern: [6, 3] } + } else { + return { color: '#9CA3AF', style: 'dashed', dashPattern: [2, 3] } + } } // Build Cytoscape elements from topology data @@ -611,8 +632,6 @@ const NetworkMap = { // Add nodes topology.nodes?.forEach((node: any) => { - const color = deviceColors[node.type] || deviceColors.unknown - elements.push({ data: { id: node.id, @@ -630,9 +649,10 @@ const NetworkMap = { // Add edges topology.edges?.forEach((edge: any) => { - // Build edge label with IP addresses - const sourceLabel = edge.source_ips?.length > 0 ? edge.source_ips.join(', ') : '' - const targetLabel = edge.target_ip || '' + // Build edge label with interface names + const sourceLabel = edge.source_interface || '' + const targetLabel = edge.target_interface || '' + const label = [sourceLabel, targetLabel].filter(Boolean).join(' \u2194 ') elements.push({ data: { @@ -641,10 +661,9 @@ const NetworkMap = { target: edge.target, source_interface: edge.source_interface, target_interface: edge.target_interface, - source_ips: edge.source_ips, - target_ip: edge.target_ip, - protocol: edge.protocol, - label: `${sourceLabel} ↔ ${targetLabel}`.trim() + link_type: edge.link_type, + confidence: edge.confidence ?? 0.5, + label: label } }) }) @@ -658,10 +677,10 @@ const NetworkMap = { selector: 'node', style: { 'background-color': function(ele: any) { - const status = ele.data('status') - if (status === 'down') return '#ef4444' // red - if (status === 'unknown') return '#eab308' // yellow - return '#22c55e' // green (up/online) + return roleColors[ele.data('type')] || roleColors.unknown + }, + 'shape': function(ele: any) { + return roleShapes[ele.data('type')] || roleShapes.unknown }, 'label': 'data(label)', 'color': isDark ? '#fff' : '#000', @@ -677,23 +696,48 @@ const NetworkMap = { const status = ele.data('status') if (status === 'down') return '#dc2626' // darker red if (status === 'unknown') return '#ca8a04' // darker yellow - return '#16a34a' // darker green + // Darken the role color for the border + const type = ele.data('type') + const darkerColors: Record = { + router: '#2563EB', + switch: '#059669', + wireless: '#7C3AED', + firewall: '#DC2626', + server: '#D97706', + unknown: '#6B7280' + } + return darkerColors[type] || darkerColors.unknown + }, + 'overlay-color': function(ele: any) { + const status = ele.data('status') + if (status === 'down') return '#ef4444' + return '#000' + }, + 'overlay-opacity': function(ele: any) { + const status = ele.data('status') + if (status === 'down') return 0.15 + return 0 } + } as any + }, + { + selector: 'node[status = "down"]', + style: { + 'border-width': 3, + 'border-color': '#dc2626', + 'background-blacken': -0.3 } }, { selector: 'node.discovered', style: { 'border-style': 'dashed', - 'border-width': 3, + 'border-width': 2, 'border-color': '#9ca3af', - 'background-opacity': 0.6 - } - }, - { - selector: 'node.down', - style: { - 'border-width': 3 + 'opacity': 0.6, + 'width': 30, + 'height': 30, + 'font-size': '9px' } }, { @@ -707,8 +751,19 @@ const NetworkMap = { selector: 'edge', style: { 'width': 2, - 'line-color': '#22c55e', - 'target-arrow-color': '#22c55e', + 'line-color': function(ele: any) { + return getEdgeStyle(ele.data('confidence')).color + }, + 'line-style': function(ele: any) { + return getEdgeStyle(ele.data('confidence')).style + }, + 'line-dash-pattern': function(ele: any) { + const pattern = getEdgeStyle(ele.data('confidence')).dashPattern + return pattern.length > 0 ? pattern : [1, 0] + }, + 'target-arrow-color': function(ele: any) { + return getEdgeStyle(ele.data('confidence')).color + }, 'curve-style': 'bezier', 'label': 'data(label)', 'font-size': '8px', @@ -720,7 +775,7 @@ const NetworkMap = { 'text-margin-y': -8, 'text-wrap': 'wrap', 'text-max-width': '120px' - } + } as any }, { selector: 'edge:selected', @@ -789,11 +844,13 @@ const NetworkMap = { const edge = evt.target const data = edge.data() - // Build detailed tooltip with interfaces + // Build detailed tooltip with interfaces and link type let tooltip = '' if (data.source_interface) tooltip += `${data.source_interface}` - if (data.target_interface) tooltip += ` ↔ ${data.target_interface}` - if (data.protocol) tooltip += `\n${data.protocol.toUpperCase()}` + if (data.target_interface) tooltip += ` \u2194 ${data.target_interface}` + if (data.link_type) tooltip += `\n${data.link_type.toUpperCase()}` + const confidence = data.confidence + if (confidence != null) tooltip += ` (${Math.round(confidence * 100)}%)` edge.style({ 'line-color': '#fbbf24', @@ -805,10 +862,11 @@ const NetworkMap = { this.cy.on('mouseout', 'edge', (evt: any) => { const edge = evt.target const data = edge.data() + const edgeStyle = getEdgeStyle(data.confidence ?? 0.5) edge.style({ - 'line-color': '#22c55e', + 'line-color': edgeStyle.color, 'width': 2, - 'label': data.label || '' // Restore IP address label + 'label': data.label || '' }) }) }, diff --git a/docs/plans/2026-02-12-automatic-topology-inference-design.md b/docs/plans/2026-02-12-automatic-topology-inference-design.md new file mode 100644 index 00000000..139b0289 --- /dev/null +++ b/docs/plans/2026-02-12-automatic-topology-inference-design.md @@ -0,0 +1,255 @@ +# Automatic Network Topology Inference + +**Date**: 2026-02-12 +**Status**: Draft + +## Goal + +Replace the current on-demand, LLDP-only topology builder with a persistent topology model that automatically infers device relationships, roles, and link types from polling data. The topology updates incrementally as data arrives, supports both managed and discovered devices, and provides the foundation for future AI analysis and network optimization features. + +## Target Users + +ISP/WISP operators managing mixed-vendor networks with hundreds to thousands of devices. These operators add core infrastructure (routers, switches, APs, backhaul radios) as managed devices but typically do not add every CPE/subscriber module. + +## Data Model + +### Migration 1: Add device role fields to `devices` + +```elixir +alter table(:devices) do + add :device_role, :string # enum stored as string + add :device_role_source, :string, default: "inferred" +end +``` + +**`device_role` values**: `core_router`, `distribution_switch`, `access_switch`, `access_point`, `cpe`, `backhaul_radio`, `ups`, `server`, `firewall`, `unknown` + +**`device_role_source` values**: `inferred` (auto-detected, can be overwritten by inference), `manual` (user-set, never overwritten by inference) + +### Migration 2: `device_links` table + +Persistent record of a connection between two devices. + +```elixir +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), null: true + 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_source_interface_remote_mac) +``` + +**`link_type` values**: `lldp`, `cdp`, `mac_match`, `wireless_association`, `arp_inference`, `manual` + +**`target_device_id`**: Nullable. When the remote end is a discovered (unmanaged) device, this is nil and the `discovered_remote_*` fields identify it. When the remote end is a managed device, this FK is populated. + +### Migration 3: `device_link_evidence` table + +Records why a link is believed to exist. Enables explainability and re-evaluation. + +```elixir +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]) +``` + +**`evidence_type` values**: `lldp_neighbor`, `cdp_neighbor`, `mac_on_interface`, `arp_entry`, `wireless_registration`, `subnet_match` + +## Topology Inference Engine + +### Module: `Towerops.Topology` + +Primary public function: `process_device(device)` — called after each poll cycle. + +### Pipeline + +``` +1. Collect evidence from this device's latest SNMP data +2. Match evidence to known managed devices (by MAC, IP, hostname) +3. Upsert device_links with merged confidence scores +4. Record evidence in device_link_evidence +5. Expire links with no recent evidence +6. Re-evaluate device_role if device_role_source == :inferred +7. Broadcast change on PubSub if anything changed +``` + +### Evidence Collectors + +Each collector examines one data source and returns link candidates: + +| Collector | Source | Base Confidence | Notes | +|-----------|--------|-----------------|-------| +| `collect_lldp_evidence/1` | `snmp_neighbors` (protocol=lldp) | 0.95 | Highest — explicit L2 protocol | +| `collect_cdp_evidence/1` | `snmp_neighbors` (protocol=cdp) | 0.95 | Same quality as LLDP | +| `collect_mac_evidence/1` | `snmp_mac_addresses` cross-referenced with device interface MACs | 0.7 | Good but can be indirect through unmanaged switches | +| `collect_arp_evidence/1` | `snmp_arp_entries` cross-referenced with device IPs | 0.6 | Strong for /30-/31 point-to-point, weaker for shared subnets | +| `collect_wireless_evidence/1` | Vendor-specific registration tables (phase 2) | 0.9 | Very reliable when available | + +### Confidence Merging + +When multiple evidence types support the same link, confidence is combined: + +``` +merged = 1 - ((1 - a) * (1 - b)) +``` + +Examples: +- LLDP (0.95) alone → 0.95 +- LLDP (0.95) + MAC match (0.7) → 0.985 +- MAC match (0.7) + ARP (0.6) → 0.88 + +### Device Matching + +Evidence must be matched to devices. Matching priority: + +1. **MAC address** → `snmp_interfaces.if_phys_address` on any managed device +2. **IP address** → `devices.ip_address` on any managed device +3. **Hostname** → `devices.name` (case-insensitive) on any managed device + +If no managed device matches, the link is stored with `target_device_id: nil` and the `discovered_remote_*` fields populated. This creates a discovered (unmanaged) node in the topology. + +### Device Role Inference + +Rules evaluated in priority order. Only applied when `device_role_source == :inferred`. + +| Priority | Condition | Role | +|----------|-----------|------| +| 1 | LLDP capabilities include "router" + device has 10+ interfaces | `core_router` | +| 2 | LLDP capabilities include "router" | `distribution_switch` | +| 3 | LLDP capabilities include "wlan-ap" | `access_point` | +| 4 | Device has outbound wireless links to many devices (>2) | `access_point` | +| 5 | Device connects to exactly one AP via wireless | `cpe` | +| 6 | Device has wireless backhaul interface + no wireless clients | `backhaul_radio` | +| 7 | LLDP capabilities include "bridge" or device has 10+ switchports | `access_switch` | +| 8 | Known UPS vendor (APC, CyberPower, Eaton, Tripp Lite) | `ups` | +| 9 | Known firewall vendor (Fortinet, Palo Alto, pfSense) | `firewall` | +| 10 | Known server platform (Linux, Windows, VMware) | `server` | +| 11 | Fallback | `unknown` | + +Vendor detection uses `snmp_devices.manufacturer` (already collected). + +## Integration Points + +### Polling Integration + +`DevicePollerWorker` calls `Topology.process_device(device)` after writing SNMP data. This is a function call, not a new worker. Topology inference runs in the same Oban job, adding minimal overhead per poll. + +### Stale Link Cleanup + +Added to the existing `NeighborCleanupWorker` (hourly Oban cron): + +- Links with `last_confirmed_at` older than 24 hours → set confidence to 0.1 (stale) +- Links with `last_confirmed_at` older than 72 hours → delete link and evidence + +### PubSub + +After `process_device/1` detects a change (new link, removed link, role change, confidence change >0.1), broadcast: + +```elixir +Phoenix.PubSub.broadcast(Towerops.PubSub, "topology:#{org_id}", {:topology_updated, org_id}) +``` + +The `NetworkMapLive` already subscribes to this topic and has a handler — it just never received messages before. Now it does. + +## UI Changes + +### Network Map + +**"Added Devices" tab (default):** +- Only managed devices from `devices` table +- Only links where both `source_device_id` and `target_device_id` are non-nil (both sides managed) +- Role-based node shapes/icons instead of generic circles +- Link confidence indicated by edge style: solid (>0.9), dashed (0.5-0.9), dotted (<0.5) +- Link type shown on hover (LLDP, MAC match, etc.) +- Clean operational view — no clutter from subscriber devices + +**"All Devices" tab:** +- Everything from Added tab, plus discovered nodes from links with `target_device_id: nil` +- Discovered nodes rendered with muted colors and dashed borders +- AP nodes show discovered CPEs clustered around them +- When an AP has >10 discovered devices, collapse into a count badge ("47 clients"). Click to expand. +- Discovered nodes show info popover on click (MAC, IP, signal if available, "Add device" action) + +**Node visual mapping by role:** + +| Role | Shape | Color accent | +|------|-------|-------------| +| `core_router` | Rounded rectangle | Blue | +| `distribution_switch` | Rectangle | Teal | +| `access_switch` | Rectangle (smaller) | Teal (lighter) | +| `access_point` | Triangle | Green | +| `cpe` | Small circle | Gray | +| `backhaul_radio` | Diamond | Orange | +| `ups` | Square | Yellow | +| `firewall` | Hexagon | Red | +| `server` | Rectangle | Purple | +| `unknown` | Circle | Gray | + +### Device Detail Page + +**On AP devices (`device_role == :access_point`):** + +New "Connected Devices" section in overview tab. Compact table: + +| Name | IP | Signal | Link Type | Managed | +|------|-----|--------|-----------|---------| +| CPE-Customer-Smith | 10.0.5.47 | -62 dBm | Wireless | No | +| BACKHAUL-TOWER2 | 10.0.1.2 | -44 dBm | LLDP | Yes (link) | + +Discovered devices show connection data but no status. Managed devices link to their detail page. + +**On CPE devices (`device_role == :cpe`):** + +New "Uplink" section in overview tab showing the AP this CPE connects to, with signal/link type and a link to the AP's detail page. + +**On all devices:** + +The existing Neighbors tab is enhanced to show persistent links from `device_links` instead of raw `snmp_neighbors` data. Shows link type, confidence, evidence count, and last confirmed time. Links to the remote device's detail page when managed. + +### Device Edit Page + +New `device_role` dropdown with all role options. When the user manually selects a role, `device_role_source` is set to `manual`, preventing future inference from overriding it. A "Reset to auto-detect" option sets it back to `inferred`. + +## Phase 2: Wireless Registration Tables + +Not in initial scope but designed for. After the core topology model is working: + +1. Add vendor-specific wireless registration table polling to `DevicePollerWorker` +2. New schema `snmp_wireless_registrations` (MAC, signal, CCQ, TX/RX rate, uptime, last seen) +3. New evidence collector `collect_wireless_evidence/1` feeds into the same topology pipeline +4. Signal/CCQ data stored in `device_links.metadata` for display + +Vendor coverage priority: MikroTik (registration-table), Ubiquiti (dot11StaTable), Cambium (PMP/ePMP registration). + +## Testing Strategy + +- Unit tests for each evidence collector with mock SNMP data +- Unit tests for confidence merging +- Unit tests for device role inference rules +- Integration test: poll a device, verify links created +- Integration test: remove a neighbor, verify link expires after cleanup +- Integration test: manual role override preserved through re-inference +- LiveView test: map renders with role-based styling +- LiveView test: AP detail page shows connected devices section diff --git a/docs/plans/2026-02-12-automatic-topology-inference.md b/docs/plans/2026-02-12-automatic-topology-inference.md new file mode 100644 index 00000000..3885233f --- /dev/null +++ b/docs/plans/2026-02-12-automatic-topology-inference.md @@ -0,0 +1,2197 @@ +# 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** + +```bash +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** + +```elixir +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** + +```elixir +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** + +```elixir +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** + +```bash +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`: + +```elixir +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** + +```bash +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`: + +```elixir +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`: + +```elixir +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`: + +```elixir +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): +```elixir + # 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: +```elixir +device_role: String.t() | nil, +device_role_source: String.t(), +``` + +**Step 7: Run all tests** + +```bash +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`: + +```elixir +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** + +```bash +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`: + +```elixir +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** + +```bash +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`: + +```elixir + 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** + +```bash +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`: + +```elixir + 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** + +```bash +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`: + +```elixir + 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** + +```bash +mix test test/towerops/topology_test.exs +``` + +**Step 3: Implement MAC and ARP evidence collectors** + +Add to `lib/towerops/topology.ex`: + +```elixir + 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** + +```bash +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** + +```elixir + 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** + +```bash +mix test test/towerops/topology_test.exs +``` + +**Step 3: Implement confidence merging and link upsert** + +Add to `lib/towerops/topology.ex`: + +```elixir + 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** + +```bash +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** + +```elixir + 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** + +```bash +mix test test/towerops/topology_test.exs +``` + +**Step 3: Implement process_device/1 and list_device_links/1** + +Add to `lib/towerops/topology.ex`: + +```elixir + 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** + +```bash +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** + +```elixir + 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** + +```bash +mix test test/towerops/topology_test.exs +``` + +**Step 3: Implement role inference** + +Add to `lib/towerops/topology.ex`: + +```elixir + 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** + +```bash +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: + +```elixir + # 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** + +```bash +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** + +```elixir + 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`: + +```elixir + @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** + +```bash +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** + +```elixir + 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`: + +```elixir + @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`: + +```elixir + 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** + +```bash +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`: + +```elixir + 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`: + +```elixir + @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** + +```bash +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** + +```bash +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** + +```bash +mix precommit +``` + +Expected: All checks pass (format, compile warnings-as-errors, tests). + +**Step 2: Run dialyzer** + +```bash +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. diff --git a/lib/towerops/snmp/neighbor_cleanup_worker.ex b/lib/towerops/snmp/neighbor_cleanup_worker.ex index 7ddfa487..39e3cc07 100644 --- a/lib/towerops/snmp/neighbor_cleanup_worker.ex +++ b/lib/towerops/snmp/neighbor_cleanup_worker.ex @@ -12,6 +12,7 @@ defmodule Towerops.Snmp.NeighborCleanupWorker do alias Towerops.Devices alias Towerops.Snmp + alias Towerops.Topology require Logger @@ -22,6 +23,7 @@ defmodule Towerops.Snmp.NeighborCleanupWorker do @spec perform(Oban.Job.t()) :: :ok def perform(%Oban.Job{}) do cleanup_stale_records() + cleanup_stale_topology_links() :ok end @@ -56,6 +58,14 @@ defmodule Towerops.Snmp.NeighborCleanupWorker do end end + defp cleanup_stale_topology_links do + {stale_count, deleted_count} = Topology.cleanup_stale_links() + + if stale_count > 0 or deleted_count > 0 do + Logger.info("Topology link cleanup: marked #{stale_count} stale, deleted #{deleted_count}") + end + end + defp log_cleanup_results(total_neighbors, total_arp, total_mac) do if total_neighbors > 0 or total_arp > 0 or total_mac > 0 do Logger.info( diff --git a/lib/towerops/topology.ex b/lib/towerops/topology.ex index 0d34c493..aa987a6a 100644 --- a/lib/towerops/topology.ex +++ b/lib/towerops/topology.ex @@ -353,6 +353,62 @@ defmodule Towerops.Topology do end end + @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 first + {deleted_count, _} = + Repo.delete_all(from(l in DeviceLink, where: l.last_confirmed_at < ^delete_cutoff)) + + # Mark stale links (24h+ but not yet 72h) + {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 + + @doc """ + Build topology data for the network map. Returns %{nodes, edges, stats, last_updated}. + Tab "added" = managed devices only. Tab "all" = managed + discovered. + """ + def get_topology_for_map(organization_id, tab \\ "added") do + devices = list_managed_devices(organization_id) + device_ids = Enum.map(devices, & &1.id) + links = list_links_for_devices(device_ids) + + managed_nodes = Enum.map(devices, &device_to_node/1) + discovered_nodes = build_discovered_nodes(links, tab) + all_nodes = managed_nodes ++ discovered_nodes + node_ids = MapSet.new(Enum.map(all_nodes, & &1.id)) + + edges = build_edges_from_links(links, node_ids) + + %{ + nodes: all_nodes, + edges: edges, + stats: %{ + total_devices: length(all_nodes), + added_devices: length(managed_nodes), + discovered_devices: length(discovered_nodes), + total_links: length(edges) + }, + last_updated: DateTime.utc_now() + } + end + @doc "List all device links where this device is the source, ordered by confidence." def list_device_links(device_id) do Repo.all( @@ -363,6 +419,118 @@ defmodule Towerops.Topology do ) end + @doc """ + List all devices connected to this device via device_links. + Returns links where the device is either the source or target, + with preloaded associations for display. + """ + 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 + + # --- Network map helpers --- + + defp list_managed_devices(organization_id) do + 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] + ) + ) + end + + defp list_links_for_devices(device_ids) do + Repo.all( + from(l in DeviceLink, + where: l.source_device_id in ^device_ids, + preload: [:source_interface, :target_interface] + ) + ) + end + + defp device_to_node(device) do + %{ + id: device.id, + label: device.name, + type: role_to_type_atom(device.device_role), + device_role: device.device_role, + status: device.status, + site_id: device.site_id, + site_name: if(device.site, do: device.site.name), + ip_address: device.ip_address, + discovered: false, + manufacturer: if(device.snmp_device, do: device.snmp_device.manufacturer) + } + end + + defp build_discovered_nodes(links, "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) + end + + defp build_discovered_nodes(_links, _tab), do: [] + + defp build_edges_from_links(links, node_ids) do + links + |> Enum.map(fn link -> + target_id = + if link.target_device_id, + do: link.target_device_id, + else: "discovered_#{link.discovered_remote_mac}" + + %{ + 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) + 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 + + # --- Evidence processing helpers --- + defp upsert_grouped_evidence({_key, evidence_list}, device_id, lookup, now) do best = Enum.max_by(evidence_list, & &1.confidence) diff --git a/lib/towerops_web/live/device_live/form.ex b/lib/towerops_web/live/device_live/form.ex index 060833d8..174a3fc8 100644 --- a/lib/towerops_web/live/device_live/form.ex +++ b/lib/towerops_web/live/device_live/form.ex @@ -630,8 +630,14 @@ defmodule ToweropsWeb.DeviceLive.Form do defp handle_agent_assignment(_device_id, _), do: :ok - # Sanitize device params - trim whitespace from IP address + # Sanitize device params - trim whitespace from IP address and handle device role source defp sanitize_device_params(params) do + params + |> sanitize_ip_address() + |> sanitize_device_role() + end + + defp sanitize_ip_address(params) do case Map.get(params, "ip_address") do ip when is_binary(ip) -> Map.put(params, "ip_address", String.trim(ip)) @@ -641,6 +647,20 @@ defmodule ToweropsWeb.DeviceLive.Form do end end + # Set device_role_source based on whether a role was manually selected + defp sanitize_device_role(params) do + case Map.get(params, "device_role") do + role when is_binary(role) and role != "" -> + Map.put(params, "device_role_source", "manual") + + _ -> + # Auto-detect selected (empty string or nil) - clear role, set source to inferred + params + |> Map.put("device_role", nil) + |> Map.put("device_role_source", "inferred") + end + end + @impl true def handle_info({:credential_test_result, result}, socket) do require Logger diff --git a/lib/towerops_web/live/device_live/form.html.heex b/lib/towerops_web/live/device_live/form.html.heex index 4e0a88c8..fb3cbed8 100644 --- a/lib/towerops_web/live/device_live/form.html.heex +++ b/lib/towerops_web/live/device_live/form.html.heex @@ -181,6 +181,30 @@
<.input field={@form[:description]} type="textarea" label="Description" />
+ +
+ <.input + field={@form[:device_role]} + type="select" + label="Device Role" + prompt="Auto-detect" + options={[ + {"Router", "router"}, + {"Switch", "switch"}, + {"Access Point", "access_point"}, + {"CPE", "cpe"}, + {"Firewall", "firewall"}, + {"Server", "server"}, + {"PDU", "pdu"}, + {"UPS", "ups"}, + {"Camera", "camera"}, + {"Printer", "printer"} + ]} + /> +

+ Select a role manually or leave as auto-detect to infer from SNMP data. +

+
diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index 7d96201f..132f5c54 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -397,6 +397,7 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:signal_sensors, signal_sensors) |> assign(:grouped_transceivers, grouped_transceivers) |> assign(:available_firmware, available_firmware) + |> assign(:connected_devices, Towerops.Topology.list_connected_devices(device_id)) end # Ports tab: interfaces grouped by type. diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index fca0c2b3..269f6863 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -980,6 +980,135 @@ <% end %> + <%!-- Connected Devices --%> + <%= if @connected_devices != [] do %> +
+
+

+ Connected Devices + + ({length(@connected_devices)} links) + +

+
+
+ + + + + + + + + + + + + <%= for link <- @connected_devices do %> + <% peer_device = + cond do + link.target_device && link.target_device.id != @device.id -> + link.target_device + + link.source_device && link.source_device.id != @device.id -> + link.source_device + + link.target_device -> + link.target_device + + true -> + nil + end %> + + + + + + + + + <% end %> + +
DeviceIP AddressInterfaceLink TypeConfidenceStatus
+ <%= if peer_device do %> + <.link + navigate={~p"/devices/#{peer_device.id}"} + class="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" + > + {peer_device.name} + + <% else %> +
+ {link.discovered_remote_name || link.discovered_remote_mac || + "Unknown"} +
+ <% end %> +
+ <%= if peer_device do %> + {peer_device.ip_address} + <% else %> + {link.discovered_remote_ip || "-"} + <% end %> + + <%= if link.source_interface do %> + {link.source_interface.if_name} + <% else %> + - + <% end %> + + + "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" + + link.link_type == "mac_match" -> + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" + + link.link_type == "arp_inference" -> + "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200" + + true -> + "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200" + end + ]}> + {link.link_type} + + +
+
+
= 0.9 -> "bg-green-500" + link.confidence >= 0.7 -> "bg-blue-500" + link.confidence >= 0.5 -> "bg-yellow-500" + true -> "bg-red-500" + end + ]} + style={"width: #{trunc(link.confidence * 100)}%"} + > +
+
+ + {trunc(link.confidence * 100)}% + +
+
+ <%= if peer_device do %> + + Managed + + <% else %> + + Discovered + + <% end %> +
+
+
+ <% end %> <% "ports" -> %> <%= if @snmp_interfaces && length(@snmp_interfaces) > 0 do %>
diff --git a/lib/towerops_web/live/network_map_live.ex b/lib/towerops_web/live/network_map_live.ex index 6d92c7c1..2f59035a 100644 --- a/lib/towerops_web/live/network_map_live.ex +++ b/lib/towerops_web/live/network_map_live.ex @@ -2,7 +2,7 @@ defmodule ToweropsWeb.NetworkMapLive do @moduledoc false use ToweropsWeb, :live_view - alias Towerops.Snmp + alias Towerops.Topology @impl true def mount(_params, _session, socket) do @@ -18,13 +18,11 @@ defmodule ToweropsWeb.NetworkMapLive do default_topology = %{ nodes: [], edges: [], - subnets: [], stats: %{ total_devices: 0, added_devices: 0, discovered_devices: 0, - total_links: 0, - total_subnets: 0 + total_links: 0 }, last_updated: DateTime.utc_now() } @@ -73,36 +71,11 @@ defmodule ToweropsWeb.NetworkMapLive do end defp load_topology_data(socket, organization_id, tab) do - topology = Snmp.get_network_topology(organization_id) - - # Filter topology based on active tab - filtered_topology = - case tab do - "all" -> - topology - - _ -> - # "added" tab - show only added devices - %{ - topology - | nodes: Enum.filter(topology.nodes, &(!&1.discovered)), - edges: filter_edges_for_nodes(topology.edges, topology.nodes, &(!&1.discovered)) - } - end + topology = Topology.get_topology_for_map(organization_id, tab) socket - |> assign(:topology, filtered_topology) + |> assign(:topology, topology) |> assign(:loading, false) - |> push_event("update_topology", %{topology: filtered_topology}) - end - - # Filter edges to only include those where both source and target are in the filtered nodes - defp filter_edges_for_nodes(edges, all_nodes, filter_fn) do - filtered_node_ids = all_nodes |> Enum.filter(filter_fn) |> MapSet.new(& &1.id) - - Enum.filter(edges, fn edge -> - MapSet.member?(filtered_node_ids, edge.source) and - MapSet.member?(filtered_node_ids, edge.target) - end) + |> push_event("update_topology", %{topology: topology}) end end diff --git a/lib/towerops_web/live/network_map_live.html.heex b/lib/towerops_web/live/network_map_live.html.heex index 6d4ff3cb..fb4c2a89 100644 --- a/lib/towerops_web/live/network_map_live.html.heex +++ b/lib/towerops_web/live/network_map_live.html.heex @@ -63,7 +63,7 @@
<% else %> -
+
@@ -143,26 +143,6 @@
- -
-
-
-
- <.icon name="hero-squares-2x2" class="h-6 w-6 text-orange-400" /> -
-
-
-
- Subnets -
-
- {@topology.stats.total_subnets} -
-
-
-
-
-
@@ -197,29 +177,60 @@
-
-
-
-
- Online +
+ <%!-- Device roles --%> +
+ Roles: +
+
+ Router
-
-
+
+
+ Switch +
+
+ + Wireless +
+
+
+ Firewall +
+
+
+ Server +
+
+
Unknown
-
-
- Offline -
-
-
- Discovered (Not Added) +
+
+
+ Discovered
-
-
-
- Active Link + <%!-- Link confidence --%> +
+ Links: +
+
+ High confidence +
+
+
+
+ Medium confidence +
+
+
+
+ Low confidence
diff --git a/test/towerops/topology_test.exs b/test/towerops/topology_test.exs index 7c8e8839..5692243c 100644 --- a/test/towerops/topology_test.exs +++ b/test/towerops/topology_test.exs @@ -6,6 +6,7 @@ defmodule Towerops.TopologyTest do alias Towerops.Snmp.Device alias Towerops.Snmp.Interface alias Towerops.Topology + alias Towerops.Topology.DeviceLink setup do user = user_fixture() @@ -640,6 +641,168 @@ defmodule Towerops.TopologyTest do end end + describe "cleanup_stale_links/0" do + test "marks links older than 24h as stale (confidence 0.1)", %{router: router, router_iface: iface} do + old_time = DateTime.add(DateTime.utc_now(), -25, :hour) + + {:ok, link} = + %DeviceLink{} + |> DeviceLink.changeset(%{ + source_device_id: router.id, + source_interface_id: iface.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 links older than 72h", %{router: router, router_iface: iface} do + very_old = DateTime.add(DateTime.utc_now(), -73, :hour) + + {:ok, link} = + %DeviceLink{} + |> DeviceLink.changeset(%{ + source_device_id: router.id, + source_interface_id: iface.id, + link_type: "lldp", + confidence: 0.95, + discovered_remote_mac: "aa:bb:cc:dd:ee:ff", + last_confirmed_at: very_old + }) + |> Repo.insert() + + {_stale_count, deleted_count} = Topology.cleanup_stale_links() + + assert deleted_count >= 1 + assert Repo.get(DeviceLink, link.id) == nil + end + + test "does not touch recent links", %{router: router, router_iface: iface} do + recent = DateTime.utc_now() + + {:ok, link} = + %DeviceLink{} + |> DeviceLink.changeset(%{ + source_device_id: router.id, + source_interface_id: iface.id, + link_type: "lldp", + confidence: 0.95, + discovered_remote_mac: "aa:bb:cc:dd:ee:ff", + last_confirmed_at: recent + }) + |> Repo.insert() + + {stale_count, deleted_count} = Topology.cleanup_stale_links() + + assert stale_count == 0 + assert deleted_count == 0 + assert Repo.get(DeviceLink, link.id).confidence == 0.95 + end + end + + describe "get_topology_for_map/2" do + test "returns managed device nodes on 'added' tab", + %{router: router, switch: switch, organization: org} do + result = Topology.get_topology_for_map(org.id, "added") + + assert length(result.nodes) == 2 + assert Enum.all?(result.nodes, &(!&1.discovered)) + node_ids = Enum.map(result.nodes, & &1.id) + assert router.id in node_ids + assert switch.id in node_ids + end + + test "returns edges between managed devices on 'added' tab", + %{router: router, switch: switch, router_iface: iface, organization: org} do + # Create a link between router and switch + now = DateTime.utc_now() + + {:ok, _} = + Topology.upsert_link(%{ + source_device_id: router.id, + target_device_id: switch.id, + source_interface_id: iface.id, + link_type: "lldp", + confidence: 0.95, + discovered_remote_mac: "bb:cc:dd:00:11:22", + last_confirmed_at: now, + evidence: [] + }) + + result = Topology.get_topology_for_map(org.id, "added") + assert length(result.edges) == 1 + [edge] = result.edges + assert edge.source == router.id + assert edge.target == switch.id + end + + test "excludes discovered nodes on 'added' tab", + %{router: router, router_iface: iface, organization: org} do + now = DateTime.utc_now() + + {:ok, _} = + Topology.upsert_link(%{ + source_device_id: router.id, + target_device_id: nil, + source_interface_id: iface.id, + link_type: "lldp", + confidence: 0.9, + discovered_remote_mac: "ff:ff:ff:00:00:01", + discovered_remote_name: "unknown-cpe", + discovered_remote_ip: "10.99.99.1", + last_confirmed_at: now, + evidence: [] + }) + + result = Topology.get_topology_for_map(org.id, "added") + # Should not include discovered nodes or edges to them + assert Enum.all?(result.nodes, &(!&1.discovered)) + assert result.edges == [] + end + + test "includes discovered nodes on 'all' tab", + %{router: router, router_iface: iface, organization: org} do + now = DateTime.utc_now() + + {:ok, _} = + Topology.upsert_link(%{ + source_device_id: router.id, + target_device_id: nil, + source_interface_id: iface.id, + link_type: "lldp", + confidence: 0.9, + discovered_remote_mac: "ff:ff:ff:00:00:01", + discovered_remote_name: "unknown-cpe", + discovered_remote_ip: "10.99.99.1", + last_confirmed_at: now, + evidence: [] + }) + + result = Topology.get_topology_for_map(org.id, "all") + discovered = Enum.filter(result.nodes, & &1.discovered) + assert length(discovered) == 1 + [disc] = discovered + assert disc.label == "unknown-cpe" + assert disc.ip_address == "10.99.99.1" + end + + test "includes stats", %{organization: org} do + result = Topology.get_topology_for_map(org.id, "added") + assert Map.has_key?(result, :stats) + assert Map.has_key?(result.stats, :total_devices) + assert Map.has_key?(result.stats, :added_devices) + assert Map.has_key?(result.stats, :total_links) + end + end + describe "collect_arp_evidence/2" do test "returns evidence for ARP entries matching known devices", %{ organization: org, @@ -712,4 +875,65 @@ defmodule Towerops.TopologyTest do assert ev.matched_device_id == nil end end + + describe "list_connected_devices/1" do + test "returns managed and discovered links for a device", + %{router: router, switch: switch, router_iface: iface} do + now = DateTime.utc_now() + + # Managed link + {:ok, _} = + Topology.upsert_link(%{ + source_device_id: router.id, + target_device_id: switch.id, + source_interface_id: iface.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: iface.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 + + test "returns links where device is the target too", + %{router: router, switch: switch, router_iface: iface} do + now = DateTime.utc_now() + + {:ok, _} = + Topology.upsert_link(%{ + source_device_id: switch.id, + target_device_id: router.id, + source_interface_id: iface.id, + link_type: "lldp", + confidence: 0.95, + discovered_remote_mac: "aa:bb:cc:dd:ee:ff", + last_confirmed_at: now, + evidence: [] + }) + + connected = Topology.list_connected_devices(router.id) + assert length(connected) == 1 + end + + test "returns empty list when no links exist", %{router: router} do + assert Topology.list_connected_devices(router.id) == [] + end + end end diff --git a/test/towerops_web/live/device_live/form_test.exs b/test/towerops_web/live/device_live/form_test.exs index 2a7b4d6c..3d78978b 100644 --- a/test/towerops_web/live/device_live/form_test.exs +++ b/test/towerops_web/live/device_live/form_test.exs @@ -276,6 +276,108 @@ defmodule ToweropsWeb.DeviceLive.FormTest do end end + describe "device role selector" do + setup %{site: site, organization: organization} do + {:ok, device} = + Towerops.Devices.create_device(%{ + name: "Role Test Device", + ip_address: "192.168.1.60", + site_id: site.id, + organization_id: organization.id, + snmp_enabled: true + }) + + %{device: device} + end + + test "new device form shows device role dropdown", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/devices/new") + + assert html =~ "Device Role" + assert html =~ "Auto-detect" + assert html =~ "Router" + assert html =~ "Switch" + assert html =~ "Firewall" + end + + test "edit device form shows device role dropdown", %{conn: conn, device: device} do + {:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/edit") + + assert html =~ "Device Role" + assert html =~ "Auto-detect" + end + + test "selecting a role sets device_role_source to manual on save", %{conn: conn, device: device} do + {:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit") + + assert {:error, {:live_redirect, %{to: to}}} = + view + |> form("#device-form", device: %{device_role: "router"}) + |> render_submit() + + assert to =~ device.id + + updated_device = Towerops.Devices.get_device!(device.id) + assert updated_device.device_role == "router" + assert updated_device.device_role_source == "manual" + end + + test "selecting auto-detect clears device_role and sets source to inferred", %{ + conn: conn, + site: site, + organization: organization + } do + # Create device with a manually set role + {:ok, device} = + Towerops.Devices.create_device(%{ + name: "Manual Role Device", + ip_address: "192.168.1.61", + site_id: site.id, + organization_id: organization.id, + snmp_enabled: true, + device_role: "switch", + device_role_source: "manual" + }) + + {:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit") + + # Select empty string (auto-detect) + assert {:error, {:live_redirect, %{to: to}}} = + view + |> form("#device-form", device: %{device_role: ""}) + |> render_submit() + + assert to =~ device.id + + updated_device = Towerops.Devices.get_device!(device.id) + assert is_nil(updated_device.device_role) + assert updated_device.device_role_source == "inferred" + end + + test "edit form shows current role as selected", %{ + conn: conn, + site: site, + organization: organization + } do + {:ok, device} = + Towerops.Devices.create_device(%{ + name: "Firewall Device", + ip_address: "192.168.1.62", + site_id: site.id, + organization_id: organization.id, + snmp_enabled: true, + device_role: "firewall", + device_role_source: "manual" + }) + + {:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/edit") + + # The select should show the current role selected + assert html =~ "device_role" + assert html =~ "firewall" + end + end + describe "non_routable_ip?/1" do # Access private function via Module.get_attribute or test via public interface # Since these are private functions, we test through the validation behavior diff --git a/test/towerops_web/live/network_map_live_test.exs b/test/towerops_web/live/network_map_live_test.exs index d03776b4..3687ce5e 100644 --- a/test/towerops_web/live/network_map_live_test.exs +++ b/test/towerops_web/live/network_map_live_test.exs @@ -27,7 +27,6 @@ defmodule ToweropsWeb.NetworkMapLiveTest do assert html =~ "Added Devices" assert html =~ "Discovered" assert html =~ "Connections" - assert html =~ "Subnets" end test "shows tab navigation with added and all tabs", %{conn: conn} do @@ -50,13 +49,16 @@ defmodule ToweropsWeb.NetworkMapLiveTest do assert html =~ "Refresh" end - test "shows legend with status indicators", %{conn: conn} do + test "shows legend with role and confidence indicators", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/network-map") - assert html =~ "Online" - assert html =~ "Unknown" - assert html =~ "Offline" + assert html =~ "Roles:" + assert html =~ "Router" + assert html =~ "Switch" + assert html =~ "Firewall" assert html =~ "Discovered" + assert html =~ "Links:" + assert html =~ "High confidence" end test "shows cytoscape container with network map hook", %{conn: conn} do