towerops/lib/towerops_web/live/network_map_live.html.heex
Graham McIntire 7371ceb942
feat: add automatic network topology inference
Build a rich network topology from SNMP polling data using evidence-based
confidence scoring. LLDP/CDP neighbors, MAC address tables, and ARP data
are combined to infer device links with weighted confidence merging.

- Add DeviceLink and DeviceLinkEvidence schemas for persistent topology
- Implement evidence collectors: LLDP (0.95), CDP (0.95), MAC (0.7), ARP (0.6)
- Add device role inference from sysObjectID/sysDescr patterns
- Hook topology inference into DevicePollerWorker pipeline
- Add stale link cleanup (24h mark stale, 72h delete) via NeighborCleanupWorker
- Update NetworkMapLive with "Added" vs "All Devices" tabs
- Add connected devices section to device detail page
- Add device role selector to device edit form
- Update Cytoscape.js with role-based node shapes/colors and confidence edges
2026-02-12 13:28:01 -06:00

261 lines
10 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="network-map"
>
<.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-4 mb-6">
<div class="bg-white dark:bg-gray-800 overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<div class="p-5">
<div class="flex items-center">
<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>
<!-- 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 flex-col gap-3">
<%!-- Device roles --%>
<div class="flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
<span class="font-medium text-gray-600 dark:text-gray-400">Roles:</span>
<div class="flex items-center space-x-1.5">
<div class="w-3.5 h-3 rounded-sm" style="background-color: #3B82F6;"></div>
<span class="text-gray-700 dark:text-gray-300">Router</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-3 h-3 rotate-45" style="background-color: #10B981;"></div>
<span class="text-gray-700 dark:text-gray-300">Switch</span>
</div>
<div class="flex items-center space-x-1.5">
<div
class="w-0 h-0 border-l-[6px] border-r-[6px] border-b-[10px] border-l-transparent border-r-transparent"
style="border-bottom-color: #8B5CF6;"
>
</div>
<span class="text-gray-700 dark:text-gray-300">Wireless</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-3 h-3 rounded-full" style="background-color: #EF4444;"></div>
<span class="text-gray-700 dark:text-gray-300">Firewall</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-3 h-3" style="background-color: #F59E0B;"></div>
<span class="text-gray-700 dark:text-gray-300">Server</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-3 h-3 rounded-full bg-gray-400"></div>
<span class="text-gray-700 dark:text-gray-300">Unknown</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-3 h-3 rounded-full border-2 border-dashed border-gray-400 opacity-60">
</div>
<span class="text-gray-700 dark:text-gray-300">Discovered</span>
</div>
</div>
<%!-- Link confidence --%>
<div class="flex flex-wrap items-center gap-x-5 gap-y-1 text-xs">
<span class="font-medium text-gray-600 dark:text-gray-400">Links:</span>
<div class="flex items-center space-x-1.5">
<div class="w-6 h-0.5" style="background-color: #10B981;"></div>
<span class="text-gray-700 dark:text-gray-300">High confidence</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-6 h-0.5 border-t-2 border-dashed" style="border-color: #F59E0B;">
</div>
<span class="text-gray-700 dark:text-gray-300">Medium confidence</span>
</div>
<div class="flex items-center space-x-1.5">
<div class="w-6 h-0.5 border-t-2 border-dotted" style="border-color: #9CA3AF;">
</div>
<span class="text-gray-700 dark:text-gray-300">Low confidence</span>
</div>
</div>
</div>
</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>