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:
parent
35b1c9e753
commit
2bb040154a
3 changed files with 204 additions and 5 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue