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
This commit is contained in:
Graham McIntire 2026-02-12 13:28:01 -06:00
parent f106d4c801
commit 7371ceb942
No known key found for this signature in database
14 changed files with 3289 additions and 115 deletions

View file

@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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 || ''
})
})
},

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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(

View file

@ -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)

View file

@ -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

View file

@ -181,6 +181,30 @@
<div class="col-span-full">
<.input field={@form[:description]} type="textarea" label="Description" />
</div>
<div class="col-span-full">
<.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"}
]}
/>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Select a role manually or leave as auto-detect to infer from SNMP data.
</p>
</div>
</div>
</div>
</div>

View file

@ -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.

View file

@ -980,6 +980,135 @@
<% end %>
</div>
</div>
<%!-- Connected Devices --%>
<%= if @connected_devices != [] do %>
<div class="mt-6 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
Connected Devices
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
({length(@connected_devices)} links)
</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr class="text-xs text-gray-600 dark:text-gray-400">
<th class="px-4 py-3 text-left font-medium">Device</th>
<th class="px-4 py-3 text-left font-medium">IP Address</th>
<th class="px-4 py-3 text-left font-medium">Interface</th>
<th class="px-4 py-3 text-left font-medium">Link Type</th>
<th class="px-4 py-3 text-left font-medium">Confidence</th>
<th class="px-4 py-3 text-left font-medium">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= 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 %>
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3">
<%= 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}
</.link>
<% else %>
<div class="text-gray-900 dark:text-white">
{link.discovered_remote_name || link.discovered_remote_mac ||
"Unknown"}
</div>
<% end %>
</td>
<td class="px-4 py-3 font-mono text-sm text-gray-900 dark:text-white">
<%= if peer_device do %>
{peer_device.ip_address}
<% else %>
{link.discovered_remote_ip || "-"}
<% end %>
</td>
<td class="px-4 py-3 text-gray-900 dark:text-white">
<%= if link.source_interface do %>
{link.source_interface.if_name}
<% else %>
-
<% end %>
</td>
<td class="px-4 py-3">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium uppercase",
cond do
link.link_type in ["lldp", "cdp"] ->
"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}
</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="w-16 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
class={[
"h-1.5 rounded-full",
cond do
link.confidence >= 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)}%"}
>
</div>
</div>
<span class="text-xs text-gray-600 dark:text-gray-400">
{trunc(link.confidence * 100)}%
</span>
</div>
</td>
<td class="px-4 py-3">
<%= if peer_device do %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Managed
</span>
<% else %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
Discovered
</span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
<% "ports" -> %>
<%= if @snmp_interfaces && length(@snmp_interfaces) > 0 do %>
<div class="space-y-6">

View file

@ -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

View file

@ -63,7 +63,7 @@
</div>
<% else %>
<!-- Stats Bar -->
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-5 mb-6">
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-6">
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<div class="p-5">
<div class="flex items-center">
@ -143,26 +143,6 @@
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<.icon name="hero-squares-2x2" class="h-6 w-6 text-orange-400" />
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
Subnets
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{@topology.stats.total_subnets}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- Network Map Container -->
@ -197,29 +177,60 @@
<!-- Legend -->
<div class="p-4 border-t border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-gray-900">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-6 text-xs">
<div class="flex items-center space-x-2">
<div class="w-3 h-3 rounded-full bg-green-500"></div>
<span class="text-gray-700 dark:text-gray-300">Online</span>
<div class="flex flex-col gap-3">
<%!-- Device roles --%>
<div class="flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
<span class="font-medium text-gray-600 dark:text-gray-400">Roles:</span>
<div class="flex items-center space-x-1.5">
<div class="w-3.5 h-3 rounded-sm" style="background-color: #3B82F6;"></div>
<span class="text-gray-700 dark:text-gray-300">Router</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="flex items-center space-x-1.5">
<div class="w-3 h-3 rotate-45" style="background-color: #10B981;"></div>
<span class="text-gray-700 dark:text-gray-300">Switch</span>
</div>
<div class="flex items-center space-x-1.5">
<div
class="w-0 h-0 border-l-[6px] border-r-[6px] border-b-[10px] border-l-transparent border-r-transparent"
style="border-bottom-color: #8B5CF6;"
>
</div>
<span class="text-gray-700 dark:text-gray-300">Wireless</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-3 h-3 rounded-full" style="background-color: #EF4444;"></div>
<span class="text-gray-700 dark:text-gray-300">Firewall</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-3 h-3" style="background-color: #F59E0B;"></div>
<span class="text-gray-700 dark:text-gray-300">Server</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-3 h-3 rounded-full bg-gray-400"></div>
<span class="text-gray-700 dark:text-gray-300">Unknown</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<span class="text-gray-700 dark:text-gray-300">Offline</span>
</div>
<div class="flex items-center space-x-2">
<div class="w-3 h-3 rounded-full border-2 border-dashed border-gray-400"></div>
<span class="text-gray-700 dark:text-gray-300">Discovered (Not Added)</span>
<div class="flex items-center space-x-1.5">
<div class="w-3 h-3 rounded-full border-2 border-dashed border-gray-400 opacity-60">
</div>
<span class="text-gray-700 dark:text-gray-300">Discovered</span>
</div>
</div>
<div class="flex items-center space-x-4 text-xs">
<div class="flex items-center space-x-2">
<div class="w-8 h-0.5 bg-green-600"></div>
<span class="text-gray-700 dark:text-gray-300">Active Link</span>
<%!-- Link confidence --%>
<div class="flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
<span class="font-medium text-gray-600 dark:text-gray-400">Links:</span>
<div class="flex items-center space-x-1.5">
<div class="w-6 h-0.5" style="background-color: #10B981;"></div>
<span class="text-gray-700 dark:text-gray-300">High confidence</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-6 h-0.5 border-t-2 border-dashed" style="border-color: #F59E0B;">
</div>
<span class="text-gray-700 dark:text-gray-300">Medium confidence</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-6 h-0.5 border-t-2 border-dotted" style="border-color: #9CA3AF;">
</div>
<span class="text-gray-700 dark:text-gray-300">Low confidence</span>
</div>
</div>
</div>

View file

@ -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

View file

@ -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

View file

@ -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