towerops/lib/towerops/capacity/resolver.ex
Graham McIntire 3913981094
feat: add backhaul capacity analysis foundation
Add per-interface capacity tracking with manual override and automatic
detection from SNMP sensors. Includes capacity resolver with priority
cascade (manual > sensor > if_speed), throughput calculation from
interface stats, and site/organization-level capacity summaries.

- Migration: add configured_capacity_bps and capacity_source to snmp_interfaces
- Capacity.Resolver: multi-source capacity detection with vendor unit normalization
- Capacity context: throughput calculation, utilization percentages, site/org summaries
- Snmp context: set_manual_capacity/2 and clear_manual_capacity/1 functions
2026-03-06 17:54:32 -06:00

98 lines
2.7 KiB
Elixir

defmodule Towerops.Capacity.Resolver do
@moduledoc """
Resolves the effective capacity for an interface using a priority cascade:
1. Manual override (capacity_source = "manual")
2. Sensor-based (capacity/rate sensors from vendor profiles)
3. if_speed (SNMP link speed)
"""
import Ecto.Query
alias Towerops.Repo
alias Towerops.Snmp.Sensor
@capacity_sensor_types ~w(capacity rate)
@doc """
Resolves the capacity for an interface, returning `{capacity_bps, source}` or `nil`.
"""
@spec resolve_capacity(map(), map()) :: {pos_integer(), String.t()} | nil
def resolve_capacity(interface, snmp_device) do
resolve_manual(interface) ||
resolve_sensor(snmp_device) ||
resolve_if_speed(interface)
end
@doc """
Finds the highest capacity/rate sensor value for a device, normalized to bps.
Returns the value in bps or nil.
"""
@spec resolve_from_sensors(map()) :: pos_integer() | nil
def resolve_from_sensors(snmp_device) do
sensors =
Sensor
|> where([s], s.snmp_device_id == ^snmp_device.id)
|> where([s], s.sensor_type in ^@capacity_sensor_types)
|> where([s], not is_nil(s.last_value) and s.last_value > 0.0)
|> Repo.all()
sensors
|> Enum.map(&normalize_sensor_to_bps/1)
|> Enum.reject(&is_nil/1)
|> Enum.max(fn -> nil end)
end
@doc """
Normalizes a sensor's last_value to bits per second based on its unit.
"""
@spec normalize_sensor_to_bps(Sensor.t()) :: pos_integer() | nil
def normalize_sensor_to_bps(%Sensor{last_value: nil}), do: nil
def normalize_sensor_to_bps(%Sensor{last_value: v}) when v <= 0, do: nil
def normalize_sensor_to_bps(%Sensor{last_value: value, sensor_unit: unit, sensor_divisor: divisor}) do
adjusted = value / (divisor || 1)
bps =
case normalize_unit(unit) do
:bps -> adjusted
:kbps -> adjusted * 1_000
:mbps -> adjusted * 1_000_000
:gbps -> adjusted * 1_000_000_000
_ -> adjusted
end
trunc(bps)
end
defp normalize_unit(nil), do: :bps
defp normalize_unit(unit) when is_binary(unit) do
case String.downcase(unit) do
"bps" -> :bps
"kbps" -> :kbps
"mbps" -> :mbps
"gbps" -> :gbps
_ -> :bps
end
end
defp resolve_manual(%{capacity_source: "manual", configured_capacity_bps: bps}) when is_integer(bps) and bps > 0 do
{bps, "manual"}
end
defp resolve_manual(_), do: nil
defp resolve_sensor(snmp_device) do
case resolve_from_sensors(snmp_device) do
nil -> nil
bps -> {bps, "sensor"}
end
end
defp resolve_if_speed(%{if_speed: speed}) when is_integer(speed) and speed > 0 do
{speed, "if_speed"}
end
defp resolve_if_speed(_), do: nil
end