add network map

This commit is contained in:
Graham McIntire 2026-01-25 15:24:35 -06:00
parent 21802eede4
commit d2abe9a14d
No known key found for this signature in database
9 changed files with 996 additions and 2 deletions

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View 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

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

View file

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