towerops/lib/towerops/capacity.ex
Graham McIntire 1c222f8ff5
feat: add capacity insight worker, UI, and reporting views
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.
2026-03-07 14:42:40 -06:00

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