From 2bb040154a109a8dade65ec1eeb0d84d44cd3bce Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 22 Mar 2026 13:54:37 -0500 Subject: [PATCH] feat: add dynamic capacity tracking for backhaul devices (#113) For devices with device_role="backhaul", traffic graphs now show capacity reference lines that update over time based on which interfaces are active. Features: - Calculates total capacity per timestamp from active monitored interfaces - Uses configured_capacity_bps if set, falls back to if_speed - Capacity adjusts as interfaces go up/down throughout the time period - Shows as dashed lines at +capacity (inbound) and -capacity (outbound) - Matches the existing 0-axis graph style Implementation: - Small graph (device detail page): Updated build_traffic_json_datasets/2 and add_capacity_datasets/3 in device_live/show.ex - Full-page graph: Updated build_traffic_datasets/2 and add_capacity_to_full_graph/4 in graph_live/show.ex The capacity lines provide visual reference for backhaul link saturation, showing how close actual throughput is to maximum capacity at each point in time. Reviewed-on: https://git.mcintire.me/graham/towerops-web/pulls/113 --- lib/towerops/snmp.ex | 2 +- lib/towerops_web/live/device_live/show.ex | 97 ++++++++++++++++++- lib/towerops_web/live/graph_live/show.ex | 110 +++++++++++++++++++++- 3 files changed, 204 insertions(+), 5 deletions(-) diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index b247e350..a069ebae 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -302,7 +302,7 @@ defmodule Towerops.Snmp do def get_device_with_associations(device_id) do Device |> where([d], d.device_id == ^device_id) - |> preload([:interfaces, :sensors, :state_sensors, :processors, :storage]) + |> preload([:device, :interfaces, :sensors, :state_sensors, :processors, :storage]) |> Repo.one() end diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index 05284ec7..9dce91ba 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -1167,7 +1167,7 @@ defmodule ToweropsWeb.DeviceLive.Show do if Enum.empty?(all_stats) do nil else - build_traffic_json_datasets(all_stats) + build_traffic_json_datasets(all_stats, device) end end end @@ -1220,7 +1220,7 @@ defmodule ToweropsWeb.DeviceLive.Show do end) end - defp build_traffic_json_datasets(all_stats) do + defp build_traffic_json_datasets(all_stats, device) do in_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: Float.round(s.in_bps, 2)} end) out_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: -Float.round(s.out_bps, 2)} end) @@ -1229,9 +1229,102 @@ defmodule ToweropsWeb.DeviceLive.Show do %{label: "Inbound", data: in_data} ] + # For backhaul devices, add capacity reference lines + datasets = + if device.device.device_role == "backhaul" do + add_capacity_datasets(datasets, all_stats, device.interfaces) + else + datasets + end + Jason.encode!(%{datasets: datasets}) end + # Add capacity reference lines for backhaul devices + # Calculates capacity per timestamp based on which interfaces have data at that point + defp add_capacity_datasets(datasets, all_stats, interfaces) do + # Build a map of interface_id -> capacity for quick lookup + interface_capacity_map = + interfaces + |> Enum.filter(& &1.monitored) + |> Map.new(fn interface -> + capacity = interface.configured_capacity_bps || interface.if_speed || 0 + {interface.id, capacity} + end) + + # Get the raw interface stats to determine which interfaces were active at each timestamp + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + + # Track which interfaces have data at each timestamp + interface_activity_by_timestamp = + interfaces + |> Enum.flat_map(fn interface -> + interface.id + |> Snmp.get_interface_stats(since: twenty_four_hours_ago, limit: 1000) + |> Enum.map(fn stat -> + ts_seconds = DateTime.to_unix(stat.checked_at) + rounded_ts_ms = div(ts_seconds, 60) * 60 * 1000 + {rounded_ts_ms, interface.id} + end) + end) + |> Enum.group_by(fn {ts, _} -> ts end, fn {_, iface_id} -> iface_id end) + + # Calculate capacity at each timestamp based on active interfaces + capacity_data = + Enum.map(all_stats, fn stat -> + # Get interfaces that had data at this timestamp + active_interfaces = Map.get(interface_activity_by_timestamp, stat.timestamp_ms, []) + # Sum capacity of active interfaces + total_capacity = + active_interfaces + |> Enum.uniq() + |> Enum.reduce(0, fn iface_id, acc -> + capacity = Map.get(interface_capacity_map, iface_id, 0) + acc + capacity + end) + + {stat.timestamp_ms, total_capacity} + end) + + # Only add capacity datasets if we have at least some capacity data + max_capacity = capacity_data |> Enum.map(fn {_, cap} -> cap end) |> Enum.max(fn -> 0 end) + + if max_capacity > 0 do + capacity_in_data = + Enum.map(capacity_data, fn {ts, cap} -> + %{x: ts, y: Float.round(cap * 1.0, 2)} + end) + + capacity_out_data = + Enum.map(capacity_data, fn {ts, cap} -> + %{x: ts, y: -Float.round(cap * 1.0, 2)} + end) + + # Add capacity datasets with special styling + datasets ++ + [ + %{ + label: "Capacity (In)", + data: capacity_in_data, + borderDash: [5, 5], + borderWidth: 2, + pointRadius: 0, + fill: false + }, + %{ + label: "Capacity (Out)", + data: capacity_out_data, + borderDash: [5, 5], + borderWidth: 2, + pointRadius: 0, + fill: false + } + ] + else + datasets + end + end + # Group interfaces by type for organized display defp group_interfaces_by_type(interfaces) do interfaces diff --git a/lib/towerops_web/live/graph_live/show.ex b/lib/towerops_web/live/graph_live/show.ex index 9552e784..3c61f4a1 100644 --- a/lib/towerops_web/live/graph_live/show.ex +++ b/lib/towerops_web/live/graph_live/show.ex @@ -631,11 +631,21 @@ defmodule ToweropsWeb.GraphLive.Show do limit = get_limit_for_range(range) active_interfaces = Enum.filter(device.interfaces, &(&1.if_oper_status == "up")) - if Enum.empty?(active_interfaces), do: nil, else: encode_interface_datasets(active_interfaces, since, limit) + if Enum.empty?(active_interfaces), do: nil, else: encode_interface_datasets(active_interfaces, since, limit, device) end - defp encode_interface_datasets(interfaces, since, limit) do + # Get device for adding capacity lines to full-page traffic view + defp encode_interface_datasets(interfaces, since, limit, device) do datasets = build_per_interface_datasets(interfaces, since, limit) + + # Add capacity datasets for backhaul devices + datasets = + if device.device.device_role == "backhaul" && !Enum.empty?(datasets) do + add_capacity_to_full_graph(datasets, interfaces, since, limit) + else + datasets + end + if Enum.empty?(datasets), do: nil, else: Jason.encode!(%{datasets: datasets}) end @@ -661,6 +671,102 @@ defmodule ToweropsWeb.GraphLive.Show do end) end + # Add capacity reference lines for backhaul devices on full-page graph + # Calculates capacity per timestamp based on which interfaces have data at that point + defp add_capacity_to_full_graph(datasets, interfaces, since, limit) do + # Build a map of interface_id -> capacity for quick lookup + interface_capacity_map = + interfaces + |> Enum.filter(& &1.monitored) + |> Map.new(fn interface -> + capacity = interface.configured_capacity_bps || interface.if_speed || 0 + {interface.id, capacity} + end) + + # Track which interfaces have data at each timestamp + interface_activity_by_timestamp = + interfaces + |> Enum.flat_map(fn interface -> + interface.id + |> Snmp.get_interface_stats(since: since, limit: limit) + |> Enum.map(fn stat -> + ts_ms = DateTime.to_unix(stat.checked_at, :millisecond) + {ts_ms, interface.id} + end) + end) + |> Enum.group_by(fn {ts, _} -> ts end, fn {_, iface_id} -> iface_id end) + + # Get all unique timestamps from the datasets + all_timestamps = + datasets + |> Enum.flat_map(fn dataset -> + case dataset.data do + nil -> [] + data -> Enum.map(data, & &1.x) + end + end) + |> Enum.uniq() + |> Enum.sort() + + # Calculate capacity at each timestamp based on active interfaces + capacity_data = + Enum.map(all_timestamps, fn ts -> + # Get interfaces that had data at this timestamp (within 1 minute tolerance) + active_interfaces = + interface_activity_by_timestamp + |> Enum.filter(fn {stat_ts, _} -> abs(stat_ts - ts) < 60_000 end) + |> Enum.flat_map(fn {_, iface_ids} -> iface_ids end) + |> Enum.uniq() + + # Sum capacity of active interfaces + total_capacity = + Enum.reduce(active_interfaces, 0, fn iface_id, acc -> + capacity = Map.get(interface_capacity_map, iface_id, 0) + acc + capacity + end) + + {ts, total_capacity} + end) + + # Only add capacity datasets if we have at least some capacity data + max_capacity = capacity_data |> Enum.map(fn {_, cap} -> cap end) |> Enum.max(fn -> 0 end) + + if max_capacity > 0 do + capacity_in_data = + Enum.map(capacity_data, fn {ts, cap} -> + %{x: ts, y: Float.round(cap * 1.0, 2)} + end) + + capacity_out_data = + Enum.map(capacity_data, fn {ts, cap} -> + %{x: ts, y: -Float.round(cap * 1.0, 2)} + end) + + # Add capacity datasets with dashed styling + datasets ++ + [ + %{ + label: "Capacity (In)", + data: capacity_in_data, + borderDash: [5, 5], + borderWidth: 2, + pointRadius: 0, + fill: false + }, + %{ + label: "Capacity (Out)", + data: capacity_out_data, + borderDash: [5, 5], + borderWidth: 2, + pointRadius: 0, + fill: false + } + ] + else + datasets + end + end + defp build_interface_traffic_chart_json(interface, range) do since = get_datetime_from_range(range) limit = get_limit_for_range(range)