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:
parent
f106d4c801
commit
7371ceb942
14 changed files with 3289 additions and 115 deletions
134
assets/js/app.ts
134
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<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 || ''
|
||||
})
|
||||
})
|
||||
},
|
||||
|
|
|
|||
255
docs/plans/2026-02-12-automatic-topology-inference-design.md
Normal file
255
docs/plans/2026-02-12-automatic-topology-inference-design.md
Normal 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
|
||||
2197
docs/plans/2026-02-12-automatic-topology-inference.md
Normal file
2197
docs/plans/2026-02-12-automatic-topology-inference.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue