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: graham/towerops-web#113
This commit is contained in:
Graham McIntire 2026-03-22 13:54:37 -05:00 committed by graham
parent 35b1c9e753
commit 2bb040154a
3 changed files with 204 additions and 5 deletions

View file

@ -302,7 +302,7 @@ defmodule Towerops.Snmp do
def get_device_with_associations(device_id) do def get_device_with_associations(device_id) do
Device Device
|> where([d], d.device_id == ^device_id) |> where([d], d.device_id == ^device_id)
|> preload([:interfaces, :sensors, :state_sensors, :processors, :storage]) |> preload([:device, :interfaces, :sensors, :state_sensors, :processors, :storage])
|> Repo.one() |> Repo.one()
end end

View file

@ -1167,7 +1167,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
if Enum.empty?(all_stats) do if Enum.empty?(all_stats) do
nil nil
else else
build_traffic_json_datasets(all_stats) build_traffic_json_datasets(all_stats, device)
end end
end end
end end
@ -1220,7 +1220,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
end) end)
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) 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) 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} %{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}) Jason.encode!(%{datasets: datasets})
end 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 # Group interfaces by type for organized display
defp group_interfaces_by_type(interfaces) do defp group_interfaces_by_type(interfaces) do
interfaces interfaces

View file

@ -631,11 +631,21 @@ defmodule ToweropsWeb.GraphLive.Show do
limit = get_limit_for_range(range) limit = get_limit_for_range(range)
active_interfaces = Enum.filter(device.interfaces, &(&1.if_oper_status == "up")) 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 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) 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}) if Enum.empty?(datasets), do: nil, else: Jason.encode!(%{datasets: datasets})
end end
@ -661,6 +671,102 @@ defmodule ToweropsWeb.GraphLive.Show do
end) end)
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 defp build_interface_traffic_chart_json(interface, range) do
since = get_datetime_from_range(range) since = get_datetime_from_range(range)
limit = get_limit_for_range(range) limit = get_limit_for_range(range)