improve network map topology visualization
- fix bidirectional link queries to find links where device is source or target - merge A→B and B→A edges into single link with combined confidence - include interface speed in edge data for variable edge width - infer discovered node roles from LLDP/CDP capabilities (router, switch, wireless, phone) - store remote_capabilities in link metadata during evidence processing - replace Cytoscape destroy-and-recreate with element diffing to preserve zoom/pan/positions - add node detail slide-out panel with device info, connections, and confidence - support ?node= URL param for deep-linking to selected nodes - add site-based compound node grouping in Cytoscape
This commit is contained in:
parent
72f81c572d
commit
80304329bd
6 changed files with 1139 additions and 51 deletions
214
assets/js/app.ts
214
assets/js/app.ts
|
|
@ -650,20 +650,22 @@ const NetworkMap = {
|
|||
// Build Cytoscape elements from topology data
|
||||
const elements: any[] = []
|
||||
|
||||
// Add nodes
|
||||
// Add nodes (site compound nodes first, then devices)
|
||||
topology.nodes?.forEach((node: any) => {
|
||||
const data: any = {
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
status: node.status,
|
||||
discovered: node.discovered,
|
||||
ip_address: node.ip_address,
|
||||
site_name: node.site_name,
|
||||
manufacturer: node.manufacturer
|
||||
}
|
||||
if (node.parent) data.parent = node.parent
|
||||
elements.push({
|
||||
data: {
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
status: node.status,
|
||||
discovered: node.discovered,
|
||||
ip_address: node.ip_address,
|
||||
site_name: node.site_name,
|
||||
manufacturer: node.manufacturer
|
||||
},
|
||||
classes: node.discovered ? 'discovered' : node.status
|
||||
data: data,
|
||||
classes: node.type === 'site' ? 'site-group' : (node.discovered ? 'discovered' : node.status)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -683,11 +685,20 @@ const NetworkMap = {
|
|||
target_interface: edge.target_interface,
|
||||
link_type: edge.link_type,
|
||||
confidence: edge.confidence ?? 0.5,
|
||||
if_speed: edge.if_speed,
|
||||
label: label
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Map interface speed to edge width
|
||||
const getEdgeWidth = (speed: number | null | undefined): number => {
|
||||
if (!speed) return 1
|
||||
if (speed >= 10_000_000_000) return 4 // 10Gbps+
|
||||
if (speed >= 1_000_000_000) return 2 // 1Gbps
|
||||
return 1 // < 1Gbps or unknown
|
||||
}
|
||||
|
||||
// Initialize Cytoscape
|
||||
this.cy = cytoscape({
|
||||
container: this.el,
|
||||
|
|
@ -767,10 +778,40 @@ const NetworkMap = {
|
|||
'border-color': '#fbbf24'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'node.highlighted',
|
||||
style: {
|
||||
'border-width': 4,
|
||||
'border-color': '#3b82f6',
|
||||
'overlay-color': '#3b82f6',
|
||||
'overlay-opacity': 0.1
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: '$node > node', // compound parent selector
|
||||
style: {
|
||||
'background-color': isDark ? '#1e293b' : '#f8fafc',
|
||||
'background-opacity': 0.6,
|
||||
'border-width': 1,
|
||||
'border-color': isDark ? '#334155' : '#e2e8f0',
|
||||
'border-style': 'solid',
|
||||
'shape': 'round-rectangle',
|
||||
'padding': '20px',
|
||||
'text-valign': 'top',
|
||||
'text-halign': 'center',
|
||||
'text-margin-y': -5,
|
||||
'label': 'data(label)',
|
||||
'font-size': '12px',
|
||||
'font-weight': '600',
|
||||
'color': isDark ? '#94a3b8' : '#64748b'
|
||||
} as any
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'width': 2,
|
||||
'width': function(ele: any) {
|
||||
return getEdgeWidth(ele.data('if_speed'))
|
||||
},
|
||||
'line-color': function(ele: any) {
|
||||
return getEdgeStyle(ele.data('confidence')).color
|
||||
},
|
||||
|
|
@ -831,16 +872,31 @@ const NetworkMap = {
|
|||
const node = evt.target
|
||||
const nodeData = node.data()
|
||||
|
||||
// Push event to LiveView with node info
|
||||
this.pushEvent("node_clicked", { node_id: nodeData.id })
|
||||
// Push event to LiveView to open detail panel
|
||||
this.pushEvent("node_clicked", {
|
||||
node_id: nodeData.id,
|
||||
node_type: nodeData.discovered ? 'discovered' : 'managed'
|
||||
})
|
||||
})
|
||||
|
||||
// Show tooltip or navigate
|
||||
if (!nodeData.discovered) {
|
||||
// Navigate to device detail page
|
||||
window.location.href = `/devices/${nodeData.id}`
|
||||
// Handle highlight_node event from LiveView
|
||||
this.handleEvent("highlight_node", (payload: any) => {
|
||||
if (!this.cy) return
|
||||
// Remove highlight from all nodes
|
||||
this.cy.nodes().removeClass('highlighted')
|
||||
// Add highlight to selected node
|
||||
const node = this.cy.getElementById(payload.node_id)
|
||||
if (node.length > 0) {
|
||||
node.addClass('highlighted')
|
||||
}
|
||||
})
|
||||
|
||||
// Handle clear_highlight event from LiveView
|
||||
this.handleEvent("clear_highlight", () => {
|
||||
if (!this.cy) return
|
||||
this.cy.nodes().removeClass('highlighted')
|
||||
})
|
||||
|
||||
// Add hover tooltips
|
||||
this.cy.on('mouseover', 'node', (evt: any) => {
|
||||
const node = evt.target
|
||||
|
|
@ -885,7 +941,7 @@ const NetworkMap = {
|
|||
const edgeStyle = getEdgeStyle(data.confidence ?? 0.5)
|
||||
edge.style({
|
||||
'line-color': edgeStyle.color,
|
||||
'width': 2,
|
||||
'width': getEdgeWidth(data.if_speed),
|
||||
'label': data.label || ''
|
||||
})
|
||||
})
|
||||
|
|
@ -894,9 +950,121 @@ const NetworkMap = {
|
|||
updateTopology(this: any, topology: any) {
|
||||
if (!this.cy) return
|
||||
|
||||
// Rebuild the graph with new data
|
||||
this.cy.destroy()
|
||||
this.initializeCytoscape(topology)
|
||||
// Build incoming element maps
|
||||
const incomingNodes = new Map<string, any>()
|
||||
topology.nodes?.forEach((node: any) => {
|
||||
incomingNodes.set(node.id, node)
|
||||
})
|
||||
|
||||
const incomingEdges = new Map<string, any>()
|
||||
topology.edges?.forEach((edge: any) => {
|
||||
incomingEdges.set(edge.id, edge)
|
||||
})
|
||||
|
||||
// Track which nodes already exist
|
||||
const existingNodeIds = new Set<string>()
|
||||
const existingEdgeIds = new Set<string>()
|
||||
|
||||
// Update or remove existing nodes
|
||||
this.cy.nodes().forEach((node: any) => {
|
||||
const id = node.id()
|
||||
if (incomingNodes.has(id)) {
|
||||
// Update data
|
||||
const incoming = incomingNodes.get(id)
|
||||
node.data('label', incoming.label)
|
||||
node.data('type', incoming.type)
|
||||
node.data('status', incoming.status)
|
||||
node.data('discovered', incoming.discovered)
|
||||
node.data('ip_address', incoming.ip_address)
|
||||
node.data('site_name', incoming.site_name)
|
||||
node.data('manufacturer', incoming.manufacturer)
|
||||
if (incoming.parent) node.data('parent', incoming.parent)
|
||||
existingNodeIds.add(id)
|
||||
} else {
|
||||
node.remove()
|
||||
}
|
||||
})
|
||||
|
||||
// Update or remove existing edges
|
||||
this.cy.edges().forEach((edge: any) => {
|
||||
const id = edge.id()
|
||||
if (incomingEdges.has(id)) {
|
||||
const incoming = incomingEdges.get(id)
|
||||
edge.data('confidence', incoming.confidence ?? 0.5)
|
||||
edge.data('link_type', incoming.link_type)
|
||||
edge.data('source_interface', incoming.source_interface)
|
||||
edge.data('target_interface', incoming.target_interface)
|
||||
const sourceLabel = incoming.source_interface || ''
|
||||
const targetLabel = incoming.target_interface || ''
|
||||
edge.data('label', [sourceLabel, targetLabel].filter(Boolean).join(' \u2194 '))
|
||||
existingEdgeIds.add(id)
|
||||
} else {
|
||||
edge.remove()
|
||||
}
|
||||
})
|
||||
|
||||
// Add new nodes
|
||||
const newElements: any[] = []
|
||||
incomingNodes.forEach((node, id) => {
|
||||
if (!existingNodeIds.has(id)) {
|
||||
newElements.push({
|
||||
group: 'nodes',
|
||||
data: {
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
status: node.status,
|
||||
discovered: node.discovered,
|
||||
ip_address: node.ip_address,
|
||||
site_name: node.site_name,
|
||||
manufacturer: node.manufacturer,
|
||||
parent: node.parent
|
||||
},
|
||||
classes: node.discovered ? 'discovered' : node.status
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Add new edges
|
||||
incomingEdges.forEach((edge, id) => {
|
||||
if (!existingEdgeIds.has(id)) {
|
||||
const sourceLabel = edge.source_interface || ''
|
||||
const targetLabel = edge.target_interface || ''
|
||||
const label = [sourceLabel, targetLabel].filter(Boolean).join(' \u2194 ')
|
||||
|
||||
newElements.push({
|
||||
group: 'edges',
|
||||
data: {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
source_interface: edge.source_interface,
|
||||
target_interface: edge.target_interface,
|
||||
link_type: edge.link_type,
|
||||
confidence: edge.confidence ?? 0.5,
|
||||
if_speed: edge.if_speed,
|
||||
label: label
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Only add and layout new elements (preserves existing positions)
|
||||
if (newElements.length > 0) {
|
||||
const added = this.cy.add(newElements)
|
||||
// Run layout only on new elements
|
||||
if (added.nonempty()) {
|
||||
added.layout({
|
||||
name: 'cose',
|
||||
animate: true,
|
||||
animationDuration: 300,
|
||||
fit: false,
|
||||
randomize: false,
|
||||
nodeRepulsion: 30000,
|
||||
idealEdgeLength: 250
|
||||
}).run()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -257,6 +257,24 @@ defmodule Towerops.Topology do
|
|||
@doc "Merge two independent confidence scores: 1 - (1-a)(1-b)"
|
||||
def merge_confidence(a, b), do: 1.0 - (1.0 - a) * (1.0 - b)
|
||||
|
||||
@doc """
|
||||
Infer the type of a discovered device from LLDP/CDP capability data.
|
||||
Returns an atom (:router, :switch, :wireless, :phone, :unknown).
|
||||
"""
|
||||
def infer_role_from_capabilities(nil), do: :unknown
|
||||
def infer_role_from_capabilities([]), do: :unknown
|
||||
|
||||
def infer_role_from_capabilities(capabilities) when is_list(capabilities) do
|
||||
cond do
|
||||
"router" in capabilities -> :router
|
||||
"bridge" in capabilities and "wlan-ap" in capabilities -> :wireless
|
||||
"wlan-ap" in capabilities -> :wireless
|
||||
"bridge" in capabilities -> :switch
|
||||
"telephone" in capabilities -> :phone
|
||||
true -> :unknown
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Create or update a device link. Keyed on source_device_id + source_interface_id +
|
||||
discovered_remote_mac. If existing, merges confidence and appends evidence.
|
||||
|
|
@ -408,16 +426,41 @@ defmodule Towerops.Topology do
|
|||
|
||||
managed_nodes = Enum.map(devices, &device_to_node/1)
|
||||
discovered_nodes = build_discovered_nodes(links, tab)
|
||||
all_nodes = managed_nodes ++ discovered_nodes
|
||||
|
||||
# Build site compound nodes from devices that have sites
|
||||
site_nodes =
|
||||
devices
|
||||
|> Enum.filter(& &1.site)
|
||||
|> Enum.uniq_by(& &1.site_id)
|
||||
|> Enum.map(fn device ->
|
||||
%{
|
||||
id: "site_#{device.site_id}",
|
||||
label: device.site.name,
|
||||
type: :site,
|
||||
device_role: nil,
|
||||
status: nil,
|
||||
site_id: device.site_id,
|
||||
site_name: device.site.name,
|
||||
ip_address: nil,
|
||||
discovered: false,
|
||||
manufacturer: nil,
|
||||
parent: nil
|
||||
}
|
||||
end)
|
||||
|
||||
all_nodes = site_nodes ++ managed_nodes ++ discovered_nodes
|
||||
node_ids = MapSet.new(Enum.map(all_nodes, & &1.id))
|
||||
|
||||
edges = build_edges_from_links(links, node_ids)
|
||||
edges =
|
||||
links
|
||||
|> build_edges_from_links(node_ids)
|
||||
|> merge_bidirectional_edges()
|
||||
|
||||
%{
|
||||
nodes: all_nodes,
|
||||
edges: edges,
|
||||
stats: %{
|
||||
total_devices: length(all_nodes),
|
||||
total_devices: length(managed_nodes) + length(discovered_nodes),
|
||||
added_devices: length(managed_nodes),
|
||||
discovered_devices: length(discovered_nodes),
|
||||
total_links: length(edges)
|
||||
|
|
@ -426,6 +469,124 @@ defmodule Towerops.Topology do
|
|||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get detail information for a node (managed or discovered) for the detail panel.
|
||||
|
||||
For managed devices: returns device with site, connections.
|
||||
For discovered nodes (id starts with "discovered_"): returns info from link metadata.
|
||||
Returns nil if node not found or not in organization.
|
||||
"""
|
||||
def get_node_detail("discovered_" <> _suffix = node_id, organization_id) do
|
||||
# Find the link that created this discovered node
|
||||
device_ids =
|
||||
Repo.all(
|
||||
from(d in Device,
|
||||
where: d.organization_id == ^organization_id,
|
||||
select: d.id
|
||||
)
|
||||
)
|
||||
|
||||
links =
|
||||
Repo.all(
|
||||
from(l in DeviceLink,
|
||||
where: l.source_device_id in ^device_ids and is_nil(l.target_device_id),
|
||||
preload: [:source_device, :source_interface, :target_interface]
|
||||
)
|
||||
)
|
||||
|
||||
# Find the link matching this discovered node ID
|
||||
link =
|
||||
Enum.find(links, fn l ->
|
||||
discovered_node_id(l) == node_id
|
||||
end)
|
||||
|
||||
case link do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
link ->
|
||||
capabilities = get_in(link.metadata, ["remote_capabilities"])
|
||||
|
||||
%{
|
||||
id: node_id,
|
||||
name: link.discovered_remote_name || link.discovered_remote_ip || link.discovered_remote_mac,
|
||||
ip_address: link.discovered_remote_ip,
|
||||
mac_address: link.discovered_remote_mac,
|
||||
type: :discovered,
|
||||
role: infer_role_from_capabilities(capabilities),
|
||||
status: :unknown,
|
||||
site_name: nil,
|
||||
manufacturer: nil,
|
||||
connections: [
|
||||
%{
|
||||
device_id: link.source_device_id,
|
||||
device_name: link.source_device.name,
|
||||
interface: if(link.source_interface, do: link.source_interface.if_name),
|
||||
confidence: link.confidence,
|
||||
link_type: link.link_type
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def get_node_detail(device_id, organization_id) do
|
||||
device =
|
||||
Repo.one(
|
||||
from(d in Device,
|
||||
where: d.id == ^device_id and 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]
|
||||
)
|
||||
)
|
||||
|
||||
case device do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
device ->
|
||||
connections =
|
||||
device_id
|
||||
|> list_connected_devices()
|
||||
|> Enum.map(&link_to_connection(&1, device_id))
|
||||
|
||||
%{
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
ip_address: device.ip_address,
|
||||
type: :managed,
|
||||
role: role_to_type_atom(device.device_role),
|
||||
status: device.status,
|
||||
site_name: if(device.site, do: device.site.name),
|
||||
manufacturer: if(device.snmp_device, do: device.snmp_device.manufacturer),
|
||||
connections: connections
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp link_to_connection(link, device_id) do
|
||||
{connected_device, interface} =
|
||||
if link.source_device_id == device_id do
|
||||
{link.target_device, link.target_interface}
|
||||
else
|
||||
{link.source_device, link.source_interface}
|
||||
end
|
||||
|
||||
%{
|
||||
device_id: if(connected_device, do: connected_device.id),
|
||||
device_name: connected_device_name(connected_device, link),
|
||||
interface: if(interface, do: interface.if_name),
|
||||
confidence: link.confidence,
|
||||
link_type: link.link_type
|
||||
}
|
||||
end
|
||||
|
||||
defp connected_device_name(device, _link) when not is_nil(device), do: device.name
|
||||
|
||||
defp connected_device_name(nil, link),
|
||||
do: link.discovered_remote_name || link.discovered_remote_ip || link.discovered_remote_mac
|
||||
|
||||
@doc "List all device links where this device is the source, ordered by confidence."
|
||||
def list_device_links(device_id) do
|
||||
Repo.all(
|
||||
|
|
@ -467,7 +628,7 @@ defmodule Towerops.Topology do
|
|||
defp list_links_for_devices(device_ids) do
|
||||
Repo.all(
|
||||
from(l in DeviceLink,
|
||||
where: l.source_device_id in ^device_ids,
|
||||
where: l.source_device_id in ^device_ids or l.target_device_id in ^device_ids,
|
||||
preload: [:source_interface, :target_interface]
|
||||
)
|
||||
)
|
||||
|
|
@ -484,7 +645,8 @@ defmodule Towerops.Topology do
|
|||
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)
|
||||
manufacturer: if(device.snmp_device, do: device.snmp_device.manufacturer),
|
||||
parent: if(device.site_id, do: "site_#{device.site_id}")
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -493,10 +655,13 @@ defmodule Towerops.Topology do
|
|||
|> Enum.filter(&is_nil(&1.target_device_id))
|
||||
|> Enum.uniq_by(&discovered_node_id/1)
|
||||
|> Enum.map(fn link ->
|
||||
capabilities = get_in(link.metadata, ["remote_capabilities"])
|
||||
inferred_type = infer_role_from_capabilities(capabilities)
|
||||
|
||||
%{
|
||||
id: discovered_node_id(link),
|
||||
label: link.discovered_remote_name || link.discovered_remote_ip || link.discovered_remote_mac,
|
||||
type: :unknown,
|
||||
type: inferred_type,
|
||||
device_role: nil,
|
||||
status: :unknown,
|
||||
site_id: nil,
|
||||
|
|
@ -511,6 +676,55 @@ defmodule Towerops.Topology do
|
|||
|
||||
defp build_discovered_nodes(_links, _tab), do: []
|
||||
|
||||
defp merge_bidirectional_edges(edges) do
|
||||
edges
|
||||
|> Enum.group_by(fn edge ->
|
||||
{min(edge.source, edge.target), max(edge.source, edge.target)}
|
||||
end)
|
||||
|> Enum.map(fn {_key, group} -> merge_edge_group(group) end)
|
||||
end
|
||||
|
||||
defp merge_edge_group([single]), do: single
|
||||
|
||||
defp merge_edge_group([first | rest] = group) do
|
||||
merged_confidence =
|
||||
Enum.reduce(rest, first.confidence, fn edge, acc ->
|
||||
merge_confidence(acc, edge.confidence)
|
||||
end)
|
||||
|
||||
all_interfaces =
|
||||
group
|
||||
|> Enum.flat_map(&[&1.source_interface, &1.target_interface])
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.uniq()
|
||||
|
||||
best_link_type =
|
||||
group
|
||||
|> Enum.map(& &1.link_type)
|
||||
|> Enum.min_by(&link_type_priority/1)
|
||||
|
||||
if_speed =
|
||||
group
|
||||
|> Enum.map(&Map.get(&1, :if_speed))
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.max(fn -> nil end)
|
||||
|
||||
%{
|
||||
first
|
||||
| confidence: merged_confidence,
|
||||
link_type: best_link_type,
|
||||
source_interface: Enum.at(all_interfaces, 0),
|
||||
target_interface: Enum.at(all_interfaces, 1),
|
||||
if_speed: if_speed
|
||||
}
|
||||
end
|
||||
|
||||
defp link_type_priority("lldp"), do: 0
|
||||
defp link_type_priority("cdp"), do: 1
|
||||
defp link_type_priority("mac_match"), do: 2
|
||||
defp link_type_priority("arp_inference"), do: 3
|
||||
defp link_type_priority(_), do: 4
|
||||
|
||||
defp build_edges_from_links(links, node_ids) do
|
||||
links
|
||||
|> Enum.map(fn link ->
|
||||
|
|
@ -527,7 +741,8 @@ defmodule Towerops.Topology do
|
|||
target_interface: if(link.target_interface, do: link.target_interface.if_name),
|
||||
link_type: link.link_type,
|
||||
confidence: link.confidence,
|
||||
metadata: link.metadata
|
||||
metadata: link.metadata,
|
||||
if_speed: if(link.source_interface, do: link.source_interface.if_speed)
|
||||
}
|
||||
end)
|
||||
|> Enum.filter(fn edge ->
|
||||
|
|
@ -571,7 +786,7 @@ defmodule Towerops.Topology do
|
|||
discovered_remote_mac: remote_identity.remote_mac,
|
||||
discovered_remote_ip: remote_identity.remote_ip,
|
||||
discovered_remote_name: remote_identity.remote_name,
|
||||
metadata: %{},
|
||||
metadata: build_link_metadata(evidence_list),
|
||||
last_confirmed_at: now,
|
||||
evidence:
|
||||
Enum.map(evidence_list, fn ev ->
|
||||
|
|
@ -585,6 +800,19 @@ defmodule Towerops.Topology do
|
|||
end
|
||||
end
|
||||
|
||||
defp build_link_metadata(evidence_list) do
|
||||
capabilities =
|
||||
evidence_list
|
||||
|> Enum.flat_map(fn ev -> ev[:remote_capabilities] || [] end)
|
||||
|> Enum.uniq()
|
||||
|
||||
if Enum.empty?(capabilities) do
|
||||
%{}
|
||||
else
|
||||
%{"remote_capabilities" => capabilities}
|
||||
end
|
||||
end
|
||||
|
||||
defp group_evidence_by_remote(evidence_list) do
|
||||
Enum.group_by(evidence_list, &evidence_group_key/1)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ defmodule ToweropsWeb.NetworkMapLive do
|
|||
|> assign(:timezone, socket.assigns.current_scope.timezone)
|
||||
|> assign(:loading, true)
|
||||
|> assign(:topology, default_topology)
|
||||
|> assign(:active_tab, "added")}
|
||||
|> assign(:active_tab, "added")
|
||||
|> assign(:selected_node_detail, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -41,10 +42,22 @@ defmodule ToweropsWeb.NetworkMapLive do
|
|||
tab = Map.get(params, "tab", "added")
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:active_tab, tab)
|
||||
|> load_topology_data(organization.id, tab)}
|
||||
socket =
|
||||
socket
|
||||
|> assign(:active_tab, tab)
|
||||
|> load_topology_data(organization.id, tab)
|
||||
|
||||
# Handle ?node= param for deep-linking to a selected node
|
||||
socket =
|
||||
case Map.get(params, "node") do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
node_id ->
|
||||
load_node_detail(socket, node_id, organization.id)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -59,15 +72,41 @@ defmodule ToweropsWeb.NetworkMapLive do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("node_clicked", %{"node_id" => node_id}, socket) do
|
||||
# Handle node click - could navigate to device detail page
|
||||
{:noreply, assign(socket, :selected_node, node_id)}
|
||||
def handle_event("node_clicked", %{"node_id" => node_id} = params, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
node_type = Map.get(params, "node_type", "managed")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> load_node_detail(node_id, organization.id)
|
||||
|> then(fn s ->
|
||||
if s.assigns.selected_node_detail do
|
||||
push_event(s, "highlight_node", %{node_id: node_id, node_type: node_type})
|
||||
else
|
||||
s
|
||||
end
|
||||
end)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("close_detail_panel", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:selected_node_detail, nil)
|
||||
|> push_event("clear_highlight", %{})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:topology_updated, _organization_id}, socket) do
|
||||
# Real-time topology update
|
||||
{:noreply, load_topology_data(socket, socket.assigns.current_scope.organization.id, socket.assigns.active_tab)}
|
||||
{:noreply,
|
||||
load_topology_data(
|
||||
socket,
|
||||
socket.assigns.current_scope.organization.id,
|
||||
socket.assigns.active_tab
|
||||
)}
|
||||
end
|
||||
|
||||
defp load_topology_data(socket, organization_id, tab) do
|
||||
|
|
@ -78,4 +117,11 @@ defmodule ToweropsWeb.NetworkMapLive do
|
|||
|> assign(:loading, false)
|
||||
|> push_event("update_topology", %{topology: topology})
|
||||
end
|
||||
|
||||
defp load_node_detail(socket, node_id, organization_id) do
|
||||
case Topology.get_node_detail(node_id, organization_id) do
|
||||
nil -> assign(socket, :selected_node_detail, nil)
|
||||
detail -> assign(socket, :selected_node_detail, detail)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -165,14 +165,159 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cytoscape Container -->
|
||||
<div
|
||||
id="cy-container"
|
||||
phx-hook="NetworkMap"
|
||||
class="w-full"
|
||||
style="height: 600px;"
|
||||
data-topology={Jason.encode!(@topology)}
|
||||
>
|
||||
<!-- Cytoscape Container with Detail Panel -->
|
||||
<div class="relative">
|
||||
<div
|
||||
id="cy-container"
|
||||
phx-hook="NetworkMap"
|
||||
class="w-full"
|
||||
style="height: 600px;"
|
||||
data-topology={Jason.encode!(@topology)}
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Node Detail Panel (slide-out) -->
|
||||
<%= if @selected_node_detail do %>
|
||||
<div
|
||||
id="node-detail-panel"
|
||||
class="absolute top-0 right-0 h-full w-80 bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-white/10 shadow-lg overflow-y-auto transition-transform duration-200"
|
||||
>
|
||||
<div class="p-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Node Details")}
|
||||
</h3>
|
||||
<button
|
||||
phx-click="close_detail_panel"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Device Info -->
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<h4 class="text-base font-medium text-gray-900 dark:text-white">
|
||||
{@selected_node_detail.name}
|
||||
</h4>
|
||||
<%= if @selected_node_detail.type == :discovered do %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300 mt-1">
|
||||
{t("Discovered")}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @selected_node_detail.ip_address do %>
|
||||
<div class="flex items-center text-sm">
|
||||
<.icon name="hero-globe-alt" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" />
|
||||
<span class="text-gray-700 dark:text-gray-300">
|
||||
{@selected_node_detail.ip_address}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @selected_node_detail.site_name do %>
|
||||
<div class="flex items-center text-sm">
|
||||
<.icon name="hero-map-pin" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" />
|
||||
<span class="text-gray-700 dark:text-gray-300">
|
||||
{@selected_node_detail.site_name}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @selected_node_detail.manufacturer do %>
|
||||
<div class="flex items-center text-sm">
|
||||
<.icon
|
||||
name="hero-building-office"
|
||||
class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-gray-700 dark:text-gray-300">
|
||||
{@selected_node_detail.manufacturer}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center text-sm">
|
||||
<.icon name="hero-tag" class="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" />
|
||||
<span class="text-gray-700 dark:text-gray-300 capitalize">
|
||||
{@selected_node_detail.role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<%= if @selected_node_detail.status && @selected_node_detail.status != :unknown do %>
|
||||
<div class="flex items-center text-sm">
|
||||
<span class={[
|
||||
"inline-block h-2 w-2 rounded-full mr-2",
|
||||
if(@selected_node_detail.status == :up,
|
||||
do: "bg-green-400",
|
||||
else: "bg-red-400"
|
||||
)
|
||||
]}>
|
||||
</span>
|
||||
<span class="text-gray-700 dark:text-gray-300 capitalize">
|
||||
{@selected_node_detail.status}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Connections -->
|
||||
<%= if length(@selected_node_detail.connections) > 0 do %>
|
||||
<div class="mt-5 pt-4 border-t border-gray-200 dark:border-white/10">
|
||||
<h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||
{t("Connections")} ({length(@selected_node_detail.connections)})
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<%= for conn <- @selected_node_detail.connections do %>
|
||||
<div class="flex items-center justify-between py-1.5 px-2 rounded bg-gray-50 dark:bg-gray-700/50 text-sm">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-gray-900 dark:text-white truncate font-medium text-xs">
|
||||
{conn.device_name}
|
||||
</p>
|
||||
<%= if conn.interface do %>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-xs">
|
||||
{conn.interface}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 ml-2 flex-shrink-0">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase">
|
||||
{conn.link_type}
|
||||
</span>
|
||||
<span class={[
|
||||
"text-xs font-medium",
|
||||
cond do
|
||||
conn.confidence > 0.9 -> "text-green-600 dark:text-green-400"
|
||||
conn.confidence >= 0.5 -> "text-yellow-600 dark:text-yellow-400"
|
||||
true -> "text-gray-500 dark:text-gray-400"
|
||||
end
|
||||
]}>
|
||||
{trunc(conn.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- View Device Link (managed only) -->
|
||||
<%= if @selected_node_detail.type == :managed do %>
|
||||
<div class="mt-4">
|
||||
<.link
|
||||
navigate={~p"/devices/#{@selected_node_detail.id}"}
|
||||
class="flex items-center justify-center w-full px-3 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-400/10 rounded-md hover:bg-blue-100 dark:hover:bg-blue-400/20"
|
||||
>
|
||||
<.icon name="hero-arrow-top-right-on-square" class="h-4 w-4 mr-1.5" />
|
||||
{t("View Device")}
|
||||
</.link>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
|
|
|
|||
|
|
@ -822,9 +822,10 @@ defmodule Towerops.TopologyTest do
|
|||
%{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)
|
||||
device_nodes = Enum.reject(result.nodes, &(&1.type == :site))
|
||||
assert length(device_nodes) == 2
|
||||
assert Enum.all?(device_nodes, &(!&1.discovered))
|
||||
node_ids = Enum.map(device_nodes, & &1.id)
|
||||
assert router.id in node_ids
|
||||
assert switch.id in node_ids
|
||||
end
|
||||
|
|
@ -912,6 +913,311 @@ defmodule Towerops.TopologyTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "list_links_for_devices/1 (bidirectional)" do
|
||||
test "returns links where device is target (not just source)",
|
||||
%{router: router, switch: switch, organization: org} do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
# Create a switch SNMP device and interface
|
||||
switch_snmp =
|
||||
%Device{}
|
||||
|> Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
|
||||
|> Repo.insert!()
|
||||
|
||||
switch_iface =
|
||||
%Interface{}
|
||||
|> Interface.changeset(%{
|
||||
snmp_device_id: switch_snmp.id,
|
||||
if_index: 1,
|
||||
if_name: "ge-0/0/1",
|
||||
if_phys_address: "bb:cc:dd:00:11:22"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
# Create link FROM switch TO router (router is the target)
|
||||
{:ok, _} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: switch.id,
|
||||
target_device_id: router.id,
|
||||
source_interface_id: switch_iface.id,
|
||||
link_type: "lldp",
|
||||
confidence: 0.95,
|
||||
discovered_remote_mac: "aa:bb:cc:00:11:22",
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
# Both router and switch should be in the device_ids
|
||||
result = Topology.get_topology_for_map(org.id, "added")
|
||||
# The link should appear because both source and target are in our org's devices
|
||||
assert length(result.edges) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "merge_bidirectional_edges/1" do
|
||||
test "merges A→B and B→A into one edge with combined confidence",
|
||||
%{router: router, switch: switch, router_iface: iface, organization: org} do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
switch_snmp =
|
||||
%Device{}
|
||||
|> Device.changeset(%{device_id: switch.id, sys_name: "main-switch"})
|
||||
|> Repo.insert!()
|
||||
|
||||
switch_iface =
|
||||
%Interface{}
|
||||
|> Interface.changeset(%{
|
||||
snmp_device_id: switch_snmp.id,
|
||||
if_index: 1,
|
||||
if_name: "ge-0/0/1",
|
||||
if_phys_address: "bb:cc:dd:00:11:22"
|
||||
})
|
||||
|> Repo.insert!()
|
||||
|
||||
# Create A→B link (router → switch)
|
||||
{: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: []
|
||||
})
|
||||
|
||||
# Create B→A link (switch → router)
|
||||
{:ok, _} =
|
||||
Topology.upsert_link(%{
|
||||
source_device_id: switch.id,
|
||||
target_device_id: router.id,
|
||||
source_interface_id: switch_iface.id,
|
||||
link_type: "lldp",
|
||||
confidence: 0.95,
|
||||
discovered_remote_mac: "aa:bb:cc:00:11:22",
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
result = Topology.get_topology_for_map(org.id, "added")
|
||||
|
||||
# Should have exactly 1 merged edge, not 2
|
||||
assert length(result.edges) == 1
|
||||
[edge] = result.edges
|
||||
|
||||
# Both devices should be represented
|
||||
assert MapSet.new([edge.source, edge.target]) == MapSet.new([router.id, switch.id])
|
||||
|
||||
# Merged confidence: 1 - (1-0.95)(1-0.95) = 0.9975
|
||||
assert_in_delta edge.confidence, 0.9975, 0.001
|
||||
end
|
||||
|
||||
test "preserves single-direction links unchanged",
|
||||
%{router: router, switch: switch, router_iface: iface, organization: org} do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
# Create only A→B link (router → switch)
|
||||
{: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_in_delta edge.confidence, 0.95, 0.001
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge data includes if_speed" do
|
||||
test "edge data includes if_speed when available",
|
||||
%{router: router, switch: switch, router_iface: iface, organization: org} do
|
||||
# Update router interface with speed
|
||||
iface
|
||||
|> Interface.changeset(%{if_speed: 1_000_000_000})
|
||||
|> Repo.update!()
|
||||
|
||||
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.if_speed == 1_000_000_000
|
||||
end
|
||||
end
|
||||
|
||||
describe "site-based visual grouping" do
|
||||
test "topology data includes site compound nodes",
|
||||
%{organization: org, site: site} do
|
||||
result = Topology.get_topology_for_map(org.id, "added")
|
||||
|
||||
site_nodes = Enum.filter(result.nodes, &(&1.type == :site))
|
||||
assert length(site_nodes) == 1
|
||||
[site_node] = site_nodes
|
||||
assert site_node.id == "site_#{site.id}"
|
||||
assert site_node.label == site.name
|
||||
end
|
||||
|
||||
test "device nodes have parent field set to site ID",
|
||||
%{router: router, organization: org, site: site} do
|
||||
result = Topology.get_topology_for_map(org.id, "added")
|
||||
|
||||
router_node = Enum.find(result.nodes, &(&1.id == router.id))
|
||||
assert router_node.parent == "site_#{site.id}"
|
||||
end
|
||||
|
||||
test "devices without a site have no parent",
|
||||
%{organization: org} do
|
||||
{:ok, orphan} =
|
||||
Towerops.Devices.create_device(
|
||||
%{
|
||||
name: "Orphan-Device",
|
||||
ip_address: "10.0.0.99",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: nil,
|
||||
organization_id: org.id
|
||||
},
|
||||
bypass_limits: true
|
||||
)
|
||||
|
||||
result = Topology.get_topology_for_map(org.id, "added")
|
||||
|
||||
orphan_node = Enum.find(result.nodes, &(&1.id == orphan.id))
|
||||
assert orphan_node.parent == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "infer_role_from_capabilities/1" do
|
||||
test "returns :router for router capability" do
|
||||
assert Topology.infer_role_from_capabilities(["router"]) == :router
|
||||
end
|
||||
|
||||
test "returns :wireless for WLAN AP capability" do
|
||||
assert Topology.infer_role_from_capabilities(["wlan-ap"]) == :wireless
|
||||
end
|
||||
|
||||
test "returns :wireless for bridge + wlan-ap" do
|
||||
assert Topology.infer_role_from_capabilities(["bridge", "wlan-ap"]) == :wireless
|
||||
end
|
||||
|
||||
test "returns :switch for bridge-only" do
|
||||
assert Topology.infer_role_from_capabilities(["bridge"]) == :switch
|
||||
end
|
||||
|
||||
test "returns :phone for telephone capability" do
|
||||
assert Topology.infer_role_from_capabilities(["telephone"]) == :phone
|
||||
end
|
||||
|
||||
test "returns :unknown for empty list" do
|
||||
assert Topology.infer_role_from_capabilities([]) == :unknown
|
||||
end
|
||||
|
||||
test "returns :unknown for nil" do
|
||||
assert Topology.infer_role_from_capabilities(nil) == :unknown
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_discovered_nodes role inference" do
|
||||
test "uses capabilities from link metadata to set node type",
|
||||
%{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.95,
|
||||
discovered_remote_mac: "ff:ff:ff:00:00:01",
|
||||
discovered_remote_name: "remote-router",
|
||||
discovered_remote_ip: "10.99.99.1",
|
||||
metadata: %{"remote_capabilities" => ["router"]},
|
||||
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.type == :router
|
||||
end
|
||||
|
||||
test "discovered node with bridge capability shows as switch",
|
||||
%{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.95,
|
||||
discovered_remote_mac: "ff:ff:ff:00:00:02",
|
||||
discovered_remote_name: "remote-switch",
|
||||
metadata: %{"remote_capabilities" => ["bridge"]},
|
||||
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.type == :switch
|
||||
end
|
||||
|
||||
test "discovered node with no capabilities shows as unknown",
|
||||
%{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.95,
|
||||
discovered_remote_mac: "ff:ff:ff:00:00:03",
|
||||
discovered_remote_name: "mystery-box",
|
||||
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.type == :unknown
|
||||
end
|
||||
end
|
||||
|
||||
describe "collect_arp_evidence/2" do
|
||||
test "returns evidence for ARP entries matching known devices", %{
|
||||
organization: org,
|
||||
|
|
@ -985,6 +1291,79 @@ defmodule Towerops.TopologyTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "get_node_detail/2" do
|
||||
test "returns device info for managed device",
|
||||
%{router: router, organization: org} do
|
||||
result = Topology.get_node_detail(router.id, org.id)
|
||||
|
||||
assert result
|
||||
assert result.id == router.id
|
||||
assert result.name == "Core-Router"
|
||||
assert result.ip_address == "10.0.0.1"
|
||||
assert result.type == :managed
|
||||
assert is_list(result.connections)
|
||||
end
|
||||
|
||||
test "returns connections list for managed device",
|
||||
%{router: router, switch: switch, router_iface: iface, organization: org} do
|
||||
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_node_detail(router.id, org.id)
|
||||
assert length(result.connections) == 1
|
||||
[conn] = result.connections
|
||||
assert conn.device_name == "Main-Switch"
|
||||
assert_in_delta conn.confidence, 0.95, 0.01
|
||||
end
|
||||
|
||||
test "returns discovered node info from link metadata",
|
||||
%{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.95,
|
||||
discovered_remote_mac: "ff:ff:ff:00:00:01",
|
||||
discovered_remote_name: "remote-switch",
|
||||
discovered_remote_ip: "10.99.99.1",
|
||||
metadata: %{"remote_capabilities" => ["bridge"]},
|
||||
last_confirmed_at: now,
|
||||
evidence: []
|
||||
})
|
||||
|
||||
node_id = "discovered_ff:ff:ff:00:00:01"
|
||||
result = Topology.get_node_detail(node_id, org.id)
|
||||
|
||||
assert result
|
||||
assert result.type == :discovered
|
||||
assert result.name == "remote-switch"
|
||||
assert result.ip_address == "10.99.99.1"
|
||||
assert result.role == :switch
|
||||
end
|
||||
|
||||
test "scoped to organization",
|
||||
%{router: _router, organization: org} do
|
||||
# Non-existent device ID
|
||||
result = Topology.get_node_detail(Ecto.UUID.generate(), org.id)
|
||||
assert result == 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
|
||||
|
|
|
|||
|
|
@ -94,6 +94,128 @@ defmodule ToweropsWeb.NetworkMapLiveTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "topology update events" do
|
||||
test "pushes update_topology event on mount", %{conn: conn, organization: organization} do
|
||||
{:ok, site} = Towerops.Sites.create_site(%{name: "Test Site", organization_id: organization.id})
|
||||
|
||||
{:ok, _device} =
|
||||
Towerops.Devices.create_device(
|
||||
%{
|
||||
name: "Test-Router",
|
||||
ip_address: "10.0.0.1",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
},
|
||||
bypass_limits: true
|
||||
)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/network-map")
|
||||
|
||||
# Verify the topology data is loaded (not empty)
|
||||
topology = view |> element("#cy-container") |> render()
|
||||
assert topology =~ "data-topology"
|
||||
end
|
||||
|
||||
test "refreshes topology on PubSub message", %{conn: conn, organization: organization} do
|
||||
{:ok, view, _html} = live(conn, ~p"/network-map")
|
||||
|
||||
# Broadcast a topology update
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"topology:#{organization.id}",
|
||||
{:topology_updated, organization.id}
|
||||
)
|
||||
|
||||
# Give LiveView time to process
|
||||
_ = render(view)
|
||||
|
||||
# Verify it still renders without error
|
||||
html = render(view)
|
||||
assert html =~ "Topology Visualization"
|
||||
end
|
||||
end
|
||||
|
||||
describe "node detail panel" do
|
||||
setup %{organization: organization} do
|
||||
{:ok, site} = Towerops.Sites.create_site(%{name: "Test Site", organization_id: organization.id})
|
||||
|
||||
{:ok, device} =
|
||||
Towerops.Devices.create_device(
|
||||
%{
|
||||
name: "Test-Router",
|
||||
ip_address: "10.0.0.1",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
},
|
||||
bypass_limits: true
|
||||
)
|
||||
|
||||
%{site: site, device: device}
|
||||
end
|
||||
|
||||
test "node_clicked event assigns selected node detail", %{conn: conn, device: device} do
|
||||
{:ok, view, _html} = live(conn, ~p"/network-map")
|
||||
|
||||
html = render_click(view, "node_clicked", %{"node_id" => device.id, "node_type" => "managed"})
|
||||
|
||||
assert html =~ "Test-Router"
|
||||
assert html =~ "10.0.0.1"
|
||||
end
|
||||
|
||||
test "close button clears selection", %{conn: conn, device: device} do
|
||||
{:ok, view, _html} = live(conn, ~p"/network-map")
|
||||
|
||||
# Open panel
|
||||
render_click(view, "node_clicked", %{"node_id" => device.id, "node_type" => "managed"})
|
||||
|
||||
# Close panel
|
||||
html = render_click(view, "close_detail_panel")
|
||||
|
||||
refute html =~ "Node Details"
|
||||
end
|
||||
|
||||
test "?node=<id> param opens detail panel on mount", %{conn: conn, device: device} do
|
||||
{:ok, _view, html} = live(conn, ~p"/network-map?node=#{device.id}")
|
||||
|
||||
assert html =~ "Test-Router"
|
||||
assert html =~ "10.0.0.1"
|
||||
end
|
||||
end
|
||||
|
||||
describe "site grouping" do
|
||||
test "network map renders site groupings in topology data", %{conn: conn, organization: organization} do
|
||||
{:ok, site} = Towerops.Sites.create_site(%{name: "Main Office", organization_id: organization.id})
|
||||
|
||||
{:ok, _device} =
|
||||
Towerops.Devices.create_device(
|
||||
%{
|
||||
name: "Office-Router",
|
||||
ip_address: "10.0.0.1",
|
||||
snmp_enabled: true,
|
||||
snmp_version: "2c",
|
||||
snmp_community: "public",
|
||||
snmp_port: 161,
|
||||
site_id: site.id,
|
||||
organization_id: organization.id
|
||||
},
|
||||
bypass_limits: true
|
||||
)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/network-map")
|
||||
|
||||
# The topology data should include site info in JSON
|
||||
assert html =~ "Main Office"
|
||||
end
|
||||
end
|
||||
|
||||
describe "unauthenticated access" do
|
||||
test "requires authentication" do
|
||||
conn = build_conn()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue