- Create WeathermapLive module at /weathermap with 60s auto-refresh - Extend Topology.get_topology_for_weathermap with bandwidth utilization data - Add WeathermapViewer JavaScript hook with color-coded edges: * Green (0-30%) → Yellow (30-60%) → Orange (60-80%) → Red (80%+) - Display throughput/capacity labels on edges (e.g., '847 Mbps / 1 Gbps') - Add utilization stats dashboard with link count by usage level - Support full-screen mode for NOC displays - Integrate with existing dark mode and responsive design - Add navigation links in sidebar and mobile menu Technical implementation: - Leverages existing Capacity module for interface utilization calculations - Extends topology edges with utilization_level, utilization_pct, throughput_bps - Builds on NetworkMapLive foundation with enhanced edge styling - Auto-refresh via Phoenix PubSub and 60s timer - Filter support for high/critical utilization links Reviewed-on: graham/towerops-web#172
195 lines
No EOL
5.2 KiB
Elixir
195 lines
No EOL
5.2 KiB
Elixir
defmodule ToweropsWeb.WeathermapLive do
|
|
@moduledoc """
|
|
Live view for real-time network utilization weathermap.
|
|
Displays network topology with color-coded edges based on bandwidth utilization.
|
|
"""
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Topology
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
# Subscribe to topology changes and capacity updates if connected
|
|
_ =
|
|
if connected?(socket) do
|
|
Phoenix.PubSub.subscribe(Towerops.PubSub, "topology:#{organization.id}")
|
|
Phoenix.PubSub.subscribe(Towerops.PubSub, "capacity:#{organization.id}")
|
|
end
|
|
|
|
# Default empty topology
|
|
default_topology = %{
|
|
nodes: [],
|
|
edges: [],
|
|
stats: %{
|
|
total_devices: 0,
|
|
added_devices: 0,
|
|
discovered_devices: 0,
|
|
total_links: 0,
|
|
high_utilization_links: 0,
|
|
overutilized_links: 0
|
|
},
|
|
last_updated: DateTime.utc_now()
|
|
}
|
|
|
|
# Schedule auto-refresh every 60 seconds
|
|
if connected?(socket) do
|
|
Process.send_after(self(), :refresh_data, 60_000)
|
|
end
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, t("Network Weathermap"))
|
|
|> assign(:timezone, socket.assigns.current_scope.timezone)
|
|
|> assign(:loading, true)
|
|
|> assign(:topology, default_topology)
|
|
|> assign(:active_tab, "added")
|
|
|> assign(:selected_node_detail, nil)
|
|
|> assign(:filter, "all")
|
|
|> assign(:search_query, "")
|
|
|> assign(:layout_mode, "force")
|
|
|> assign(:fullscreen, false)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _url, socket) do
|
|
tab = Map.get(params, "tab", "added")
|
|
fullscreen = Map.get(params, "fullscreen") == "true"
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:active_tab, tab)
|
|
|> assign(:fullscreen, fullscreen)
|
|
|> load_weathermap_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
|
|
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_event("set_filter", %{"filter" => filter}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:filter, filter)
|
|
|> push_event("apply_filter", %{filter: filter})}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("search", %{"query" => query}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:search_query, query)
|
|
|> push_event("search_nodes", %{query: query})}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_layout", %{"mode" => mode}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:layout_mode, mode)
|
|
|> push_event("change_layout", %{mode: mode})}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_fullscreen", _params, socket) do
|
|
new_fullscreen = not socket.assigns.fullscreen
|
|
|
|
path_with_params =
|
|
if new_fullscreen do
|
|
~p"/weathermap?#{%{tab: socket.assigns.active_tab, fullscreen: true}}"
|
|
else
|
|
~p"/weathermap?#{%{tab: socket.assigns.active_tab}}"
|
|
end
|
|
|
|
{:noreply, push_navigate(socket, to: path_with_params)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:refresh_data, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
socket =
|
|
load_weathermap_data(socket, organization.id, socket.assigns.active_tab)
|
|
|
|
# Schedule next refresh
|
|
Process.send_after(self(), :refresh_data, 60_000)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:topology_updated, _organization_id}, socket) do
|
|
# Real-time topology update
|
|
{:noreply,
|
|
load_weathermap_data(
|
|
socket,
|
|
socket.assigns.current_scope.organization.id,
|
|
socket.assigns.active_tab
|
|
)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:capacity_updated, _organization_id}, socket) do
|
|
# Real-time capacity/utilization update
|
|
{:noreply,
|
|
load_weathermap_data(
|
|
socket,
|
|
socket.assigns.current_scope.organization.id,
|
|
socket.assigns.active_tab
|
|
)}
|
|
end
|
|
|
|
defp load_weathermap_data(socket, organization_id, tab) do
|
|
topology = Topology.get_topology_for_weathermap(organization_id, tab)
|
|
|
|
socket
|
|
|> assign(:topology, topology)
|
|
|> assign(:loading, false)
|
|
|> push_event("update_weathermap", %{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 |