add network map
This commit is contained in:
parent
21802eede4
commit
d2abe9a14d
9 changed files with 996 additions and 2 deletions
|
|
@ -650,4 +650,5 @@ end)
|
|||
- Use `async: true` for tests that can run in parallel (most unit tests)
|
||||
- Use `async: false` for tests with shared state (supervisor tests, integration tests)
|
||||
- never open the test coverage html files
|
||||
- remember when working in rust to always run cargo fmt before committing
|
||||
- remember when working in rust to always run cargo fmt before committing
|
||||
- never try to use npm to install js things, use esbuild built in to phoenix
|
||||
256
assets/js/app.ts
256
assets/js/app.ts
|
|
@ -26,6 +26,7 @@ import { hooks as colocatedHooks } from "phoenix-colocated/towerops"
|
|||
import topbar from "../vendor/topbar"
|
||||
import Chart from "../vendor/chart"
|
||||
import type { ChartData, ChartDataset, ChartDataPoint } from "../vendor/chart"
|
||||
import cytoscape from "../vendor/cytoscape"
|
||||
import { WebAuthnRegister, WebAuthnLogin } from "./webauthn"
|
||||
import "./passkey_settings"
|
||||
import "./passkey_login"
|
||||
|
|
@ -372,6 +373,259 @@ const AutoDismissFlash = {
|
|||
}
|
||||
}
|
||||
|
||||
// Network Map hook for Cytoscape.js topology visualization
|
||||
const NetworkMap = {
|
||||
cy: null as any,
|
||||
mounted(this: any) {
|
||||
const topologyData = JSON.parse(this.el.dataset.topology || '{}')
|
||||
this.initializeCytoscape(topologyData)
|
||||
|
||||
// Listen for topology updates from LiveView
|
||||
this.handleEvent("update_topology", (payload: any) => {
|
||||
if (this.cy) {
|
||||
this.updateTopology(payload.topology)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
updated(this: any) {
|
||||
const topologyData = JSON.parse(this.el.dataset.topology || '{}')
|
||||
this.updateTopology(topologyData)
|
||||
},
|
||||
|
||||
destroyed(this: any) {
|
||||
if (this.cy) {
|
||||
this.cy.destroy()
|
||||
this.cy = null
|
||||
}
|
||||
},
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Build Cytoscape elements from topology data
|
||||
const elements: any[] = []
|
||||
|
||||
// Add nodes
|
||||
topology.nodes?.forEach((node: any) => {
|
||||
const color = deviceColors[node.type] || deviceColors.unknown
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
// 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 || ''
|
||||
|
||||
elements.push({
|
||||
data: {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Initialize Cytoscape
|
||||
this.cy = cytoscape({
|
||||
container: this.el,
|
||||
elements: elements,
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
'background-color': function(ele: any) {
|
||||
return deviceColors[ele.data('type')] || deviceColors.unknown
|
||||
},
|
||||
'label': 'data(label)',
|
||||
'color': isDark ? '#fff' : '#000',
|
||||
'text-valign': 'bottom',
|
||||
'text-halign': 'center',
|
||||
'text-margin-y': 5,
|
||||
'font-size': '11px',
|
||||
'font-weight': '500',
|
||||
'width': 40,
|
||||
'height': 40,
|
||||
'border-width': 2,
|
||||
'border-color': function(ele: any) {
|
||||
if (ele.data('status') === 'down') return '#ef4444'
|
||||
if (ele.data('status') === 'unknown') return '#6b7280'
|
||||
return deviceColors[ele.data('type')] || deviceColors.unknown
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'node.discovered',
|
||||
style: {
|
||||
'border-style': 'dashed',
|
||||
'border-width': 3,
|
||||
'border-color': '#9ca3af',
|
||||
'background-opacity': 0.6
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'node.down',
|
||||
style: {
|
||||
'border-color': '#ef4444',
|
||||
'border-width': 3
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'node:selected',
|
||||
style: {
|
||||
'border-width': 4,
|
||||
'border-color': '#fbbf24'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'width': 2,
|
||||
'line-color': '#22c55e',
|
||||
'target-arrow-color': '#22c55e',
|
||||
'curve-style': 'bezier',
|
||||
'label': 'data(label)',
|
||||
'font-size': '8px',
|
||||
'color': isDark ? '#d1d5db' : '#4b5563',
|
||||
'text-background-color': isDark ? '#1f2937' : '#ffffff',
|
||||
'text-background-opacity': 0.85,
|
||||
'text-background-padding': '2px',
|
||||
'text-rotation': 'autorotate',
|
||||
'text-margin-y': -8,
|
||||
'text-wrap': 'wrap',
|
||||
'text-max-width': '120px'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'edge:selected',
|
||||
style: {
|
||||
'line-color': '#fbbf24',
|
||||
'width': 3
|
||||
}
|
||||
}
|
||||
],
|
||||
layout: {
|
||||
name: 'cose',
|
||||
animate: true,
|
||||
animationDuration: 500,
|
||||
nodeRepulsion: 30000, // Even more repulsion to spread nodes apart
|
||||
idealEdgeLength: 250, // Longer edges to give more room for labels
|
||||
edgeElasticity: 200, // More flexible edges
|
||||
nestingFactor: 1, // Less nesting
|
||||
gravity: 30, // Even less gravity to allow more spread
|
||||
numIter: 2000, // More iterations for better layout
|
||||
initialTemp: 500, // Higher initial temperature
|
||||
coolingFactor: 0.95,
|
||||
minTemp: 1.0,
|
||||
nodeOverlap: 50, // More overlap prevention
|
||||
avoidOverlap: true // Enable overlap avoidance
|
||||
},
|
||||
minZoom: 0.2,
|
||||
maxZoom: 3,
|
||||
wheelSensitivity: 0.2
|
||||
})
|
||||
|
||||
// Add click handler for nodes
|
||||
this.cy.on('tap', 'node', (evt: any) => {
|
||||
const node = evt.target
|
||||
const nodeData = node.data()
|
||||
|
||||
// Push event to LiveView with node info
|
||||
this.pushEvent("node_clicked", { node_id: nodeData.id })
|
||||
|
||||
// Show tooltip or navigate
|
||||
if (!nodeData.discovered) {
|
||||
// Navigate to device detail page
|
||||
window.location.href = `/devices/${nodeData.id}`
|
||||
}
|
||||
})
|
||||
|
||||
// Add hover tooltips
|
||||
this.cy.on('mouseover', 'node', (evt: any) => {
|
||||
const node = evt.target
|
||||
const nodeData = node.data()
|
||||
|
||||
let tooltip = nodeData.label
|
||||
if (nodeData.ip_address) tooltip += `\n${nodeData.ip_address}`
|
||||
if (nodeData.site_name) tooltip += `\n${nodeData.site_name}`
|
||||
if (nodeData.manufacturer) tooltip += `\n${nodeData.manufacturer}`
|
||||
|
||||
node.style('label', tooltip)
|
||||
})
|
||||
|
||||
this.cy.on('mouseout', 'node', (evt: any) => {
|
||||
const node = evt.target
|
||||
node.style('label', node.data('label'))
|
||||
})
|
||||
|
||||
// Add hover for edges
|
||||
this.cy.on('mouseover', 'edge', (evt: any) => {
|
||||
const edge = evt.target
|
||||
const data = edge.data()
|
||||
|
||||
// Build detailed tooltip with interfaces
|
||||
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()}`
|
||||
|
||||
edge.style({
|
||||
'line-color': '#fbbf24',
|
||||
'width': 3,
|
||||
'label': tooltip
|
||||
})
|
||||
})
|
||||
|
||||
this.cy.on('mouseout', 'edge', (evt: any) => {
|
||||
const edge = evt.target
|
||||
const data = edge.data()
|
||||
edge.style({
|
||||
'line-color': '#22c55e',
|
||||
'width': 2,
|
||||
'label': data.label || '' // Restore IP address label
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
updateTopology(this: any, topology: any) {
|
||||
if (!this.cy) return
|
||||
|
||||
// Rebuild the graph with new data
|
||||
this.cy.destroy()
|
||||
this.initializeCytoscape(topology)
|
||||
}
|
||||
}
|
||||
|
||||
const csrfToken = document.querySelector<HTMLMetaElement>("meta[name='csrf-token']")?.getAttribute("content")
|
||||
if (!csrfToken) {
|
||||
throw new Error('CSRF token meta tag not found')
|
||||
|
|
@ -383,7 +637,7 @@ const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
|
|||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: { _csrf_token: csrfToken, timezone: userTimezone },
|
||||
hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin, CopyToClipboard, ScrollToTop, AutoDismissFlash },
|
||||
hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin, CopyToClipboard, ScrollToTop, AutoDismissFlash, NetworkMap },
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
|
|
|||
32
assets/vendor/cytoscape.js
vendored
Normal file
32
assets/vendor/cytoscape.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1609,4 +1609,329 @@ defmodule Towerops.Snmp do
|
|||
|
||||
length(mac_entries)
|
||||
end
|
||||
|
||||
# Network Topology Functions
|
||||
|
||||
@doc """
|
||||
Builds a network topology map for an organization showing devices and their connections.
|
||||
|
||||
Returns a graph structure with:
|
||||
- nodes: All devices (both added and discovered)
|
||||
- edges: Physical connections between interfaces
|
||||
- subnets: Logical groupings of devices on same networks
|
||||
"""
|
||||
def get_network_topology(organization_id) do
|
||||
# Get all devices with their neighbors and interfaces
|
||||
devices = get_devices_with_topology(organization_id)
|
||||
|
||||
# Get discovered devices not yet added
|
||||
discovered = list_discovered_devices_for_organization(organization_id)
|
||||
|
||||
# Build topology structure
|
||||
nodes = build_topology_nodes(devices, discovered)
|
||||
edges = build_topology_edges(devices, nodes)
|
||||
subnets = build_subnet_groups(devices)
|
||||
|
||||
%{
|
||||
nodes: nodes,
|
||||
edges: edges,
|
||||
subnets: subnets,
|
||||
stats: %{
|
||||
total_devices: length(nodes),
|
||||
added_devices: Enum.count(nodes, &(!&1.discovered)),
|
||||
discovered_devices: Enum.count(nodes, & &1.discovered),
|
||||
total_links: length(edges),
|
||||
total_subnets: length(subnets)
|
||||
},
|
||||
last_updated: DateTime.utc_now()
|
||||
}
|
||||
end
|
||||
|
||||
# Get devices with all topology-related data preloaded
|
||||
defp get_devices_with_topology(organization_id) do
|
||||
Repo.all(
|
||||
from(d in DeviceSchema,
|
||||
join: s in assoc(d, :site),
|
||||
where: s.organization_id == ^organization_id,
|
||||
left_join: sd in Device,
|
||||
on: d.id == sd.device_id,
|
||||
left_join: i in Interface,
|
||||
on: sd.id == i.snmp_device_id,
|
||||
left_join: n in Neighbor,
|
||||
on: i.id == n.interface_id,
|
||||
left_join: ip in IpAddress,
|
||||
on: i.id == ip.snmp_interface_id,
|
||||
preload: [
|
||||
site: s,
|
||||
snmp_device: {sd, [interfaces: {i, [neighbors: n, ip_addresses: ip]}]}
|
||||
]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# Build node structures for the graph
|
||||
defp build_topology_nodes(devices, discovered) do
|
||||
# Nodes for added devices
|
||||
device_nodes =
|
||||
Enum.map(devices, fn device ->
|
||||
%{
|
||||
id: device.id,
|
||||
label: device.name,
|
||||
type: device_type_atom(device),
|
||||
status: device.status,
|
||||
site_id: device.site_id,
|
||||
site_name: device.site.name,
|
||||
ip_address: device.ip_address,
|
||||
discovered: false,
|
||||
snmp_enabled: device.snmp_enabled,
|
||||
monitoring_enabled: device.monitoring_enabled
|
||||
}
|
||||
end)
|
||||
|
||||
# Nodes for discovered but not added devices
|
||||
discovered_nodes =
|
||||
Enum.map(discovered, fn disc ->
|
||||
%{
|
||||
id: "discovered_#{disc.identifier.type}_#{disc.identifier.value}",
|
||||
label: disc.hostname || disc.identifier.value,
|
||||
type: disc.device_type,
|
||||
status: :unknown,
|
||||
site_id: nil,
|
||||
site_name: nil,
|
||||
ip_address: List.first(disc.ip_addresses),
|
||||
discovered: true,
|
||||
manufacturer: disc.manufacturer
|
||||
}
|
||||
end)
|
||||
|
||||
device_nodes ++ discovered_nodes
|
||||
end
|
||||
|
||||
# Build edge structures for the graph (physical connections)
|
||||
defp build_topology_edges(devices, nodes) do
|
||||
# Build lookup maps for matching neighbors to nodes
|
||||
node_lookup = build_node_lookup(devices, nodes)
|
||||
|
||||
devices
|
||||
|> Enum.flat_map(&build_device_edges(&1, node_lookup))
|
||||
|> Enum.uniq_by(& &1.id)
|
||||
end
|
||||
|
||||
# Build lookup maps to match neighbors to nodes using multiple strategies
|
||||
defp build_node_lookup(devices, nodes) do
|
||||
# Create maps for matching by different attributes
|
||||
by_id = Map.new(nodes, &{&1.id, &1})
|
||||
by_name = Map.new(nodes, &{String.downcase(&1.label), &1.id})
|
||||
by_ip = Map.new(nodes, fn node -> {node.ip_address, node.id} end)
|
||||
|
||||
# Build MAC address lookup from SNMP devices
|
||||
by_mac =
|
||||
devices
|
||||
|> Enum.flat_map(fn device ->
|
||||
case device.snmp_device do
|
||||
nil ->
|
||||
[]
|
||||
|
||||
snmp_device ->
|
||||
snmp_device.interfaces
|
||||
|> Enum.filter(&(&1.if_phys_address != nil))
|
||||
|> Enum.map(&{&1.if_phys_address, device.id})
|
||||
end
|
||||
end)
|
||||
|> Map.new()
|
||||
|
||||
%{
|
||||
by_id: by_id,
|
||||
by_name: by_name,
|
||||
by_ip: by_ip,
|
||||
by_mac: by_mac,
|
||||
node_ids: MapSet.new(Map.keys(by_id))
|
||||
}
|
||||
end
|
||||
|
||||
defp build_device_edges(device, node_lookup) do
|
||||
case device.snmp_device do
|
||||
nil -> []
|
||||
snmp_device -> Enum.flat_map(snmp_device.interfaces, &build_interface_edges(device, &1, node_lookup))
|
||||
end
|
||||
end
|
||||
|
||||
defp build_interface_edges(device, interface, node_lookup) do
|
||||
interface.neighbors
|
||||
|> Enum.map(fn neighbor ->
|
||||
# Try to match neighbor to existing node using multiple strategies
|
||||
target_id = find_target_node_id(neighbor, node_lookup)
|
||||
|
||||
# Get IP addresses for source interface
|
||||
source_ips = format_interface_ips(interface.ip_addresses)
|
||||
|
||||
# Create bidirectional edge ID (alphabetically sorted to deduplicate)
|
||||
edge_parts = Enum.sort([device.id, target_id || "unknown"])
|
||||
edge_id = Enum.join(edge_parts, "_")
|
||||
|
||||
%{
|
||||
id: edge_id,
|
||||
source: device.id,
|
||||
target: target_id,
|
||||
source_interface: interface.if_name || interface.if_descr,
|
||||
target_interface: neighbor.remote_port_id,
|
||||
source_interface_id: interface.id,
|
||||
source_ips: source_ips,
|
||||
target_ip: neighbor.remote_address,
|
||||
protocol: neighbor.protocol,
|
||||
last_discovered: neighbor.last_discovered_at
|
||||
}
|
||||
end)
|
||||
|> Enum.filter(&(&1.target != nil))
|
||||
end
|
||||
|
||||
# Format IP addresses with CIDR notation (IPv4 only)
|
||||
defp format_interface_ips(ip_addresses) do
|
||||
ip_addresses
|
||||
|> Enum.filter(&ipv4?(&1.ip_address))
|
||||
|> Enum.map(fn ip ->
|
||||
subnet = if ip.subnet_mask, do: "/#{calculate_prefix_length(ip.subnet_mask)}", else: ""
|
||||
"#{ip.ip_address}#{subnet}"
|
||||
end)
|
||||
end
|
||||
|
||||
# Check if an IP address is IPv4
|
||||
defp ipv4?(ip_address) when is_binary(ip_address) do
|
||||
case :inet.parse_address(String.to_charlist(ip_address)) do
|
||||
{:ok, {_, _, _, _}} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp ipv4?(_), do: false
|
||||
|
||||
# Calculate prefix length from subnet mask
|
||||
defp calculate_prefix_length(nil), do: ""
|
||||
|
||||
defp calculate_prefix_length(subnet_mask) do
|
||||
subnet_mask
|
||||
|> String.split(".")
|
||||
|> Enum.map(&String.to_integer/1)
|
||||
|> Enum.map(&Integer.to_string(&1, 2))
|
||||
|> Enum.map_join(&String.pad_leading(&1, 8, "0"))
|
||||
|> String.graphemes()
|
||||
|> Enum.count(&(&1 == "1"))
|
||||
end
|
||||
|
||||
# Find the node ID that matches the neighbor using multiple strategies
|
||||
defp find_target_node_id(neighbor, node_lookup) do
|
||||
# Strategy 1: Match by system name to device name
|
||||
name_match =
|
||||
if neighbor.remote_system_name do
|
||||
Map.get(node_lookup.by_name, String.downcase(neighbor.remote_system_name))
|
||||
end
|
||||
|
||||
# Strategy 2: Match by IP address
|
||||
ip_match =
|
||||
if neighbor.remote_address do
|
||||
Map.get(node_lookup.by_ip, neighbor.remote_address)
|
||||
end
|
||||
|
||||
# Strategy 3: Match by chassis ID (MAC address)
|
||||
mac_match =
|
||||
if neighbor.remote_chassis_id do
|
||||
Map.get(node_lookup.by_mac, neighbor.remote_chassis_id)
|
||||
end
|
||||
|
||||
# Strategy 4: Check if it's a discovered node
|
||||
discovered_match =
|
||||
if neighbor.remote_chassis_id do
|
||||
discovered_id = "discovered_mac_#{neighbor.remote_chassis_id}"
|
||||
if MapSet.member?(node_lookup.node_ids, discovered_id), do: discovered_id
|
||||
end
|
||||
|
||||
# Return first successful match
|
||||
name_match || ip_match || mac_match || discovered_match
|
||||
end
|
||||
|
||||
# Build subnet groupings
|
||||
defp build_subnet_groups(devices) do
|
||||
devices
|
||||
|> Enum.flat_map(fn device ->
|
||||
case device.snmp_device do
|
||||
nil ->
|
||||
[]
|
||||
|
||||
snmp_device ->
|
||||
Enum.flat_map(snmp_device.interfaces, fn interface ->
|
||||
Enum.map(interface.ip_addresses, fn ip ->
|
||||
{device.id, ip.ip_address, ip.subnet_mask}
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|> Enum.group_by(fn {_device_id, ip, mask} ->
|
||||
# Calculate CIDR notation
|
||||
case mask do
|
||||
nil -> nil
|
||||
_ -> calculate_cidr(ip, mask)
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(fn {cidr, _} -> is_nil(cidr) end)
|
||||
|> Enum.map(fn {cidr, entries} ->
|
||||
device_ids = entries |> Enum.map(fn {device_id, _, _} -> device_id end) |> Enum.uniq()
|
||||
|
||||
%{
|
||||
cidr: cidr,
|
||||
device_ids: device_ids,
|
||||
device_count: length(device_ids)
|
||||
}
|
||||
end)
|
||||
|> Enum.sort_by(& &1.device_count, :desc)
|
||||
end
|
||||
|
||||
# Convert device type to atom (handles both string and atom)
|
||||
defp device_type_atom(device) when is_atom(device), do: device
|
||||
defp device_type_atom(device) when is_binary(device), do: String.to_atom(device)
|
||||
defp device_type_atom(_), do: :unknown
|
||||
|
||||
# Calculate CIDR notation from IP and mask
|
||||
defp calculate_cidr(ip, mask) do
|
||||
case {parse_ip(ip), parse_ip(mask)} do
|
||||
{{:ok, _ip_tuple}, {:ok, mask_tuple}} ->
|
||||
prefix_length = count_mask_bits(mask_tuple)
|
||||
network = calculate_network(ip, mask)
|
||||
"#{network}/#{prefix_length}"
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_ip(ip) when is_binary(ip) do
|
||||
case :inet.parse_address(String.to_charlist(ip)) do
|
||||
{:ok, tuple} -> {:ok, tuple}
|
||||
{:error, _} -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_ip(_), do: :error
|
||||
|
||||
defp count_mask_bits({a, b, c, d}) do
|
||||
[a, b, c, d]
|
||||
|> Enum.map(&Integer.to_string(&1, 2))
|
||||
|> Enum.map_join(&String.pad_leading(&1, 8, "0"))
|
||||
|> String.graphemes()
|
||||
|> Enum.count(&(&1 == "1"))
|
||||
end
|
||||
|
||||
defp calculate_network(ip, mask) do
|
||||
{:ok, ip_tuple} = parse_ip(ip)
|
||||
{:ok, mask_tuple} = parse_ip(mask)
|
||||
|
||||
{a1, b1, c1, d1} = ip_tuple
|
||||
{a2, b2, c2, d2} = mask_tuple
|
||||
|
||||
a = Bitwise.band(a1, a2)
|
||||
b = Bitwise.band(b1, b2)
|
||||
c = Bitwise.band(c1, c2)
|
||||
d = Bitwise.band(d1, d2)
|
||||
|
||||
"#{a}.#{b}.#{c}.#{d}"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ defmodule Towerops.Snmp.Interface do
|
|||
alias Ecto.Association.NotLoaded
|
||||
alias Towerops.Snmp.Device
|
||||
alias Towerops.Snmp.InterfaceStat
|
||||
alias Towerops.Snmp.IpAddress
|
||||
alias Towerops.Snmp.Neighbor
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
|
@ -30,6 +32,8 @@ defmodule Towerops.Snmp.Interface do
|
|||
belongs_to :snmp_device, Device
|
||||
|
||||
has_many :stats, InterfaceStat, foreign_key: :interface_id
|
||||
has_many :neighbors, Neighbor, foreign_key: :interface_id
|
||||
has_many :ip_addresses, IpAddress, foreign_key: :snmp_interface_id
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -186,6 +186,15 @@ defmodule ToweropsWeb.Layouts do
|
|||
>
|
||||
Devices
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
navigate={~p"/network-map"}
|
||||
active={@active_page == "network-map"}
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
Network Map
|
||||
<.icon name="hero-beaker" class="h-3.5 w-3.5 text-blue-500 dark:text-blue-400" />
|
||||
</span>
|
||||
</.nav_link>
|
||||
<.nav_link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
|
||||
active={@active_page == "alerts"}
|
||||
|
|
|
|||
106
lib/towerops_web/live/network_map_live.ex
Normal file
106
lib/towerops_web/live/network_map_live.ex
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
defmodule ToweropsWeb.NetworkMapLive do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Snmp
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
organization = socket.assigns.current_organization
|
||||
|
||||
# Subscribe to topology changes if connected
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "topology:#{organization.id}")
|
||||
end
|
||||
|
||||
# Default empty topology
|
||||
default_topology = %{
|
||||
nodes: [],
|
||||
edges: [],
|
||||
subnets: [],
|
||||
stats: %{
|
||||
total_devices: 0,
|
||||
added_devices: 0,
|
||||
discovered_devices: 0,
|
||||
total_links: 0,
|
||||
total_subnets: 0
|
||||
},
|
||||
last_updated: DateTime.utc_now()
|
||||
}
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Network Map")
|
||||
|> assign(:loading, true)
|
||||
|> assign(:topology, default_topology)
|
||||
|> assign(:active_tab, "added")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
tab = Map.get(params, "tab", "added")
|
||||
organization = socket.assigns.current_organization
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:active_tab, tab)
|
||||
|> load_topology_data(organization.id, tab)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("refresh_topology", _params, socket) do
|
||||
organization = socket.assigns.current_organization
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:loading, true)
|
||||
|> load_topology_data(organization.id, socket.assigns.active_tab)
|
||||
|> put_flash(:info, "Network map refreshed")}
|
||||
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)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:topology_updated, _organization_id}, socket) do
|
||||
# Real-time topology update
|
||||
{:noreply, load_topology_data(socket, socket.assigns.current_organization.id, socket.assigns.active_tab)}
|
||||
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
|
||||
|
||||
socket
|
||||
|> assign(:topology, filtered_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)
|
||||
end
|
||||
end
|
||||
260
lib/towerops_web/live/network_map_live.html.heex
Normal file
260
lib/towerops_web/live/network_map_live.html.heex
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
current_organization={@current_organization}
|
||||
active_page="network-map"
|
||||
timezone={@timezone}
|
||||
>
|
||||
<.header>
|
||||
<span class="flex items-center gap-2">
|
||||
Network Map
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30">
|
||||
Experimental
|
||||
</span>
|
||||
</span>
|
||||
<:subtitle>Visual topology of your network infrastructure</:subtitle>
|
||||
<:actions>
|
||||
<.button phx-click="refresh_topology" disabled={@loading}>
|
||||
<.icon name="hero-arrow-path" class="h-5 w-5" /> Refresh
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-gray-200 dark:border-white/10 mb-6">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<.link
|
||||
patch={~p"/network-map?tab=added"}
|
||||
class={[
|
||||
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
||||
if @active_tab == "added" do
|
||||
"border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
else
|
||||
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
end
|
||||
]}
|
||||
>
|
||||
Added Devices
|
||||
</.link>
|
||||
|
||||
<.link
|
||||
patch={~p"/network-map?tab=all"}
|
||||
class={[
|
||||
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
||||
if @active_tab == "all" do
|
||||
"border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
else
|
||||
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
end
|
||||
]}
|
||||
>
|
||||
All Devices
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||
{@topology.stats.discovered_devices}
|
||||
</span>
|
||||
</.link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<%= if @loading do %>
|
||||
<div class="flex items-center justify-center h-96">
|
||||
<div class="text-center">
|
||||
<.icon name="hero-arrow-path" class="h-12 w-12 text-gray-400 animate-spin mx-auto" />
|
||||
<p class="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading network topology...</p>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Stats Bar -->
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-5 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">
|
||||
<div class="flex-shrink-0">
|
||||
<.icon name="hero-server" class="h-6 w-6 text-gray-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">
|
||||
Total Devices
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{@topology.stats.total_devices}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</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-check-circle" class="h-6 w-6 text-green-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">
|
||||
Added Devices
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{@topology.stats.added_devices}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</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-magnifying-glass" class="h-6 w-6 text-blue-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">
|
||||
Discovered
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{@topology.stats.discovered_devices}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</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-link" class="h-6 w-6 text-purple-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">
|
||||
Connections
|
||||
</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{@topology.stats.total_links}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</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 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-white/10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Topology Visualization
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Click and drag to pan, scroll to zoom, click nodes for details
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Last updated: <.timestamp datetime={@topology.last_updated} timezone={@timezone} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cytoscape Container -->
|
||||
<div
|
||||
id="cy-container"
|
||||
phx-hook="NetworkMap"
|
||||
class="w-full"
|
||||
style="height: 600px;"
|
||||
data-topology={Jason.encode!(@topology)}
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 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-blue-500"></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-purple-500"></div>
|
||||
<span class="text-gray-700 dark:text-gray-300">Switch</span>
|
||||
</div>
|
||||
<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">Wireless</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 rounded-full bg-orange-500"></div>
|
||||
<span class="text-gray-700 dark:text-gray-300">Server</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 rounded-full bg-gray-500"></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 border-2 border-dashed border-gray-400"></div>
|
||||
<span class="text-gray-700 dark:text-gray-300">Discovered (Not Added)</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<%= if @topology.stats.total_devices == 0 do %>
|
||||
<div class="text-center py-16">
|
||||
<.icon name="hero-map" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
No network topology available
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Add devices with SNMP enabled to start building your network map.
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
The topology is automatically discovered via LLDP and CDP protocols.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<.button navigate={~p"/devices/new"} variant="primary">
|
||||
<.icon name="hero-plus" class="h-5 w-5" /> Add Your First Device
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</Layouts.authenticated>
|
||||
|
|
@ -231,6 +231,9 @@ defmodule ToweropsWeb.Router do
|
|||
live "/devices/:id", DeviceLive.Show, :show
|
||||
live "/devices/:id/edit", DeviceLive.Form, :edit
|
||||
live "/devices/:id/graph/:sensor_type", GraphLive.Show, :show
|
||||
|
||||
# Network map route
|
||||
live "/network-map", NetworkMapLive, :index
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue