221 lines
7.4 KiB
Elixir
221 lines
7.4 KiB
Elixir
defmodule Towerops.Capacity do
|
|
@moduledoc """
|
|
Context for backhaul capacity analysis.
|
|
|
|
Provides throughput calculation from interface stats, utilization percentages,
|
|
and site/organization-level capacity summaries.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
|
|
alias Towerops.Devices.Device
|
|
alias Towerops.Repo
|
|
alias Towerops.Snmp.Interface
|
|
alias Towerops.Snmp.InterfaceStat
|
|
|
|
@doc """
|
|
Calculates average throughput for an interface over a time window.
|
|
Returns `%{in_bps: float, out_bps: float, max_bps: float}`.
|
|
"""
|
|
def calculate_throughput(interface_id, since, until \\ DateTime.utc_now()) do
|
|
stats =
|
|
InterfaceStat
|
|
|> where([s], s.interface_id == ^interface_id)
|
|
|> where([s], s.checked_at >= ^since and s.checked_at <= ^until)
|
|
|> order_by([s], asc: s.checked_at)
|
|
|> Repo.all()
|
|
|
|
calculate_throughput_from_stats(stats)
|
|
end
|
|
|
|
@doc """
|
|
Calculates utilization for an interface with configured capacity.
|
|
Returns `%{utilization_pct: float, in_pct: float, out_pct: float, throughput: map}` or nil.
|
|
"""
|
|
def get_utilization(%Interface{configured_capacity_bps: nil}), do: nil
|
|
def get_utilization(%Interface{configured_capacity_bps: cap}) when cap <= 0, do: nil
|
|
|
|
def get_utilization(%Interface{id: id, configured_capacity_bps: cap}) do
|
|
since = DateTime.add(DateTime.utc_now(), -900, :second)
|
|
throughput = calculate_throughput(id, since)
|
|
|
|
%{
|
|
utilization_pct: throughput.max_bps / cap * 100,
|
|
in_pct: throughput.in_bps / cap * 100,
|
|
out_pct: throughput.out_bps / cap * 100,
|
|
throughput: throughput
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Returns a capacity summary for all interfaces at a site.
|
|
"""
|
|
def get_site_capacity_summary(site_id) do
|
|
interfaces = list_capacity_interfaces_for_site(site_id)
|
|
|
|
interface_summaries =
|
|
Enum.map(interfaces, fn {iface, device_name} ->
|
|
utilization = get_utilization(iface)
|
|
|
|
%{
|
|
id: iface.id,
|
|
name: iface.if_name,
|
|
device_name: device_name,
|
|
capacity_bps: iface.configured_capacity_bps,
|
|
capacity_source: iface.capacity_source,
|
|
throughput: if(utilization, do: utilization.throughput, else: zero_throughput()),
|
|
utilization_pct: if(utilization, do: utilization.utilization_pct, else: 0.0)
|
|
}
|
|
end)
|
|
|
|
total_capacity = Enum.sum(Enum.map(interface_summaries, & &1.capacity_bps))
|
|
total_throughput = Enum.sum(Enum.map(interface_summaries, & &1.throughput.max_bps))
|
|
|
|
utilization_pct =
|
|
if total_capacity > 0, do: total_throughput / total_capacity * 100, else: 0.0
|
|
|
|
%{
|
|
total_capacity_bps: total_capacity,
|
|
total_throughput_bps: total_throughput,
|
|
utilization_pct: utilization_pct,
|
|
interfaces: interface_summaries,
|
|
status: utilization_status(utilization_pct)
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Returns per-site capacity summaries for an organization.
|
|
Only includes sites that have at least one interface with capacity configured.
|
|
"""
|
|
def get_organization_capacity_summary(organization_id) do
|
|
site_ids =
|
|
Interface
|
|
|> join(:inner, [i], sd in Towerops.Snmp.Device, on: i.snmp_device_id == sd.id)
|
|
|> join(:inner, [_i, sd], d in Device, on: sd.device_id == d.id)
|
|
|> where([_i, _sd, d], d.organization_id == ^organization_id)
|
|
|> where([i], not is_nil(i.configured_capacity_bps))
|
|
|> select([_i, _sd, d], d.site_id)
|
|
|> distinct(true)
|
|
|> Repo.all()
|
|
|> Enum.reject(&is_nil/1)
|
|
|
|
sites =
|
|
Towerops.Sites.Site
|
|
|> where([s], s.id in ^site_ids)
|
|
|> Repo.all()
|
|
|
|
Enum.map(sites, fn site ->
|
|
summary = get_site_capacity_summary(site.id)
|
|
|
|
%{
|
|
site_id: site.id,
|
|
site_name: site.name,
|
|
total_capacity_bps: summary.total_capacity_bps,
|
|
total_throughput_bps: summary.total_throughput_bps,
|
|
utilization_pct: summary.utilization_pct,
|
|
status: summary.status,
|
|
interfaces: summary.interfaces,
|
|
headroom_bps: max(0, summary.total_capacity_bps - summary.total_throughput_bps)
|
|
}
|
|
end)
|
|
end
|
|
|
|
defp list_capacity_interfaces_for_site(site_id) do
|
|
Interface
|
|
|> join(:inner, [i], sd in Towerops.Snmp.Device, on: i.snmp_device_id == sd.id)
|
|
|> join(:inner, [_i, sd], d in Device, on: sd.device_id == d.id)
|
|
|> where([_i, _sd, d], d.site_id == ^site_id)
|
|
|> where([i], not is_nil(i.configured_capacity_bps))
|
|
|> select([i, _sd, d], {i, d.name})
|
|
|> Repo.all()
|
|
end
|
|
|
|
defp calculate_throughput_from_stats(stats) when length(stats) < 2 do
|
|
zero_throughput()
|
|
end
|
|
|
|
defp calculate_throughput_from_stats(stats) do
|
|
pairs =
|
|
stats
|
|
|> Enum.chunk_every(2, 1, :discard)
|
|
|> Enum.map(fn [s1, s2] ->
|
|
time_diff = s2.checked_at |> DateTime.diff(s1.checked_at, :second) |> max(1)
|
|
|
|
in_bps = calculate_bps(s1.if_in_octets, s2.if_in_octets, time_diff)
|
|
out_bps = calculate_bps(s1.if_out_octets, s2.if_out_octets, time_diff)
|
|
|
|
{in_bps, out_bps}
|
|
end)
|
|
|
|
# Filter out zero-bps pairs (from clamped negative deltas / anomalies)
|
|
valid_pairs = Enum.reject(pairs, fn {in_bps, out_bps} -> in_bps == 0.0 and out_bps == 0.0 end)
|
|
|
|
if Enum.empty?(valid_pairs) do
|
|
zero_throughput()
|
|
else
|
|
in_values = Enum.map(valid_pairs, &elem(&1, 0))
|
|
out_values = Enum.map(valid_pairs, &elem(&1, 1))
|
|
|
|
avg_in = Enum.sum(in_values) / length(in_values)
|
|
avg_out = Enum.sum(out_values) / length(out_values)
|
|
|
|
# Peak values for capacity planning (95th percentile approximation)
|
|
peak_in = percentile(in_values, 95)
|
|
peak_out = percentile(out_values, 95)
|
|
|
|
%{
|
|
in_bps: Float.round(avg_in, 2),
|
|
out_bps: Float.round(avg_out, 2),
|
|
max_bps: Float.round(max(avg_in, avg_out), 2),
|
|
peak_in_bps: Float.round(peak_in, 2),
|
|
peak_out_bps: Float.round(peak_out, 2),
|
|
peak_bps: Float.round(max(peak_in, peak_out), 2)
|
|
}
|
|
end
|
|
end
|
|
|
|
# 400 Gbps — values above this are counter anomalies, not real traffic
|
|
@max_reasonable_bps 400_000_000_000
|
|
|
|
@doc """
|
|
Calculates bits per second from two consecutive octet counter readings.
|
|
|
|
Negative deltas are clamped to zero (matching LibreNMS behavior). A 32-bit
|
|
counter wrap is indistinguishable from an HC counter reset, so we discard
|
|
the ambiguous data point rather than guess at a correction. Values exceeding
|
|
400 Gbps are also discarded as counter anomalies.
|
|
"""
|
|
def calculate_bps(nil, _, _), do: 0.0
|
|
def calculate_bps(_, nil, _), do: 0.0
|
|
|
|
def calculate_bps(prev_octets, curr_octets, time_diff) do
|
|
delta = curr_octets - prev_octets
|
|
|
|
# Clamp negative deltas to zero — counter wraps and HC resets both produce
|
|
# negative deltas and are indistinguishable, so we discard the data point.
|
|
bps = max(0, delta) * 8 / time_diff
|
|
|
|
# Discard values above 400 Gbps as counter anomalies
|
|
if bps > @max_reasonable_bps, do: 0.0, else: bps
|
|
end
|
|
|
|
defp zero_throughput do
|
|
%{in_bps: 0.0, out_bps: 0.0, max_bps: 0.0, peak_in_bps: 0.0, peak_out_bps: 0.0, peak_bps: 0.0}
|
|
end
|
|
|
|
@doc """
|
|
Calculates the nth percentile of a list of numbers.
|
|
Uses nearest-rank method.
|
|
"""
|
|
def percentile([], _n), do: 0.0
|
|
|
|
def percentile(values, n) when n >= 0 and n <= 100 do
|
|
sorted = Enum.sort(values)
|
|
rank = (n / 100 * length(sorted)) |> Float.ceil() |> trunc() |> max(1)
|
|
Enum.at(sorted, rank - 1)
|
|
end
|
|
|
|
defp utilization_status(pct) when pct >= 90, do: :red
|
|
defp utilization_status(pct) when pct >= 70, do: :yellow
|
|
defp utilization_status(_), do: :green
|
|
end
|