Add CapacityInsightWorker (every 15 min) that generates critical/warning insights when backhaul utilization exceeds 90%/75% and auto-resolves when it drops below 70%. Add capacity and utilization columns to the device ports tab with set/clear capacity controls. Add organization-level /capacity page with summary cards and per-site capacity table. Add capacity summary card to site show page. Add Capacity link to nav menu.
178 lines
5.7 KiB
Elixir
178 lines
5.7 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)
|
|
|
|
if Enum.empty?(pairs) do
|
|
zero_throughput()
|
|
else
|
|
avg_in = Enum.sum(Enum.map(pairs, &elem(&1, 0))) / length(pairs)
|
|
avg_out = Enum.sum(Enum.map(pairs, &elem(&1, 1))) / length(pairs)
|
|
|
|
%{
|
|
in_bps: Float.round(avg_in, 2),
|
|
out_bps: Float.round(avg_out, 2),
|
|
max_bps: Float.round(max(avg_in, avg_out), 2)
|
|
}
|
|
end
|
|
end
|
|
|
|
defp calculate_bps(nil, _, _), do: 0.0
|
|
defp calculate_bps(_, nil, _), do: 0.0
|
|
|
|
defp calculate_bps(prev_octets, curr_octets, time_diff) do
|
|
delta = curr_octets - prev_octets
|
|
# Clamp negative deltas to 0 (counter wrap)
|
|
max(0.0, delta * 8 / time_diff)
|
|
end
|
|
|
|
defp zero_throughput, do: %{in_bps: 0.0, out_bps: 0.0, max_bps: 0.0}
|
|
|
|
defp utilization_status(pct) when pct >= 90, do: :red
|
|
defp utilization_status(pct) when pct >= 70, do: :yellow
|
|
defp utilization_status(_), do: :green
|
|
end
|