towerops/lib/towerops_web/graphql/resolvers/time_series.ex
Graham McIntire 86db804729 fix: correct 32-bit SNMP counter wrap detection for all traffic calculations (#163)
The wrap detection only applied the 2^32 correction when the previous
counter was in the upper half of the 32-bit range (> 2^31). On fast
links, counters wrap regardless of where in the range they sit. Three
graph rendering paths had no wrap detection at all, just max(0).

Consolidated all bps calculations into a single public
Capacity.calculate_bps/3 that always tries the 2^32 correction for
negative deltas. 64-bit HC counter resets remain clamped to zero since
the corrected delta stays negative.

Reviewed-on: graham/towerops-web#163
2026-03-25 11:03:43 -05:00

172 lines
5.3 KiB
Elixir

defmodule ToweropsWeb.GraphQL.Resolvers.TimeSeries do
@moduledoc "GraphQL resolvers for time-series queries."
import Ecto.Query, warn: false
alias Towerops.Devices.Device
alias Towerops.Monitoring
alias Towerops.Monitoring.Check
alias Towerops.Repo
alias Towerops.Snmp
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Sensor
alias ToweropsWeb.GraphQL.Resolvers.Helpers
alias ToweropsWeb.ScopedResource
def device_sensors(_parent, %{device_id: device_id}, %{context: %{organization_id: org_id}}) do
with {:ok, device} <- fetch_org_device(device_id, org_id) do
device = Repo.preload(device, :snmp_device)
sensors =
case device.snmp_device do
nil -> []
snmp_device -> Snmp.list_sensors(snmp_device.id)
end
{:ok, sensors}
end
end
def device_sensors(_parent, _args, _resolution), do: Helpers.authentication_error()
def sensor_readings(_parent, %{sensor_id: sensor_id} = args, %{context: %{organization_id: org_id}}) do
with {:ok, _sensor} <- verify_sensor_access(sensor_id, org_id) do
hours = parse_time_range(Map.get(args, :time_range, "24h"))
since = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
readings = Snmp.get_sensor_readings(sensor_id, since: since)
{:ok, readings}
end
end
def sensor_readings(_parent, _args, _resolution), do: Helpers.authentication_error()
def interface_traffic(_parent, %{interface_id: interface_id} = args, %{context: %{organization_id: org_id}}) do
with {:ok, _interface} <- verify_interface_access(interface_id, org_id) do
hours = parse_time_range(Map.get(args, :time_range, "24h"))
since = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
stats = Snmp.get_interface_stats(interface_id, since: since)
{:ok, compute_traffic_rates(stats)}
end
end
def interface_traffic(_parent, _args, _resolution), do: Helpers.authentication_error()
def check_results(_parent, %{check_id: check_id} = args, %{context: %{organization_id: org_id}}) do
with {:ok, _check} <- verify_check_access(check_id, org_id) do
hours = parse_time_range(Map.get(args, :time_range, "1h"))
from_time = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
results = Monitoring.get_check_results(check_id, from: from_time)
formatted =
Enum.map(results, fn r ->
%{
check_id: r.check_id,
value: r.value,
status: r.status,
checked_at: to_string(r.checked_at),
response_time_ms: r.response_time_ms
}
end)
{:ok, formatted}
end
end
def check_results(_parent, _args, _resolution), do: Helpers.authentication_error()
# Org scoping helpers
defp fetch_org_device(id, org_id) do
case ScopedResource.fetch(Device, id, org_id) do
{:ok, device} -> {:ok, device}
{:error, _reason} -> {:error, "Device not found"}
end
end
defp verify_sensor_access(sensor_id, org_id) do
query =
from(s in Sensor,
join: sd in SnmpDevice,
on: s.snmp_device_id == sd.id,
join: d in Device,
on: sd.device_id == d.id,
where: s.id == ^sensor_id and d.organization_id == ^org_id
)
case Repo.one(query) do
nil -> {:error, "Sensor not found"}
sensor -> {:ok, sensor}
end
end
defp verify_interface_access(interface_id, org_id) do
query =
from(i in Interface,
join: sd in SnmpDevice,
on: i.snmp_device_id == sd.id,
join: d in Device,
on: sd.device_id == d.id,
where: i.id == ^interface_id and d.organization_id == ^org_id
)
case Repo.one(query) do
nil -> {:error, "Interface not found"}
interface -> {:ok, interface}
end
end
defp verify_check_access(check_id, org_id) do
query =
from(c in Check,
where: c.id == ^check_id and c.organization_id == ^org_id
)
case Repo.one(query) do
nil -> {:error, "Check not found"}
check -> {:ok, check}
end
end
# Compute bps rates from counter deltas (same pattern as GraphLive)
defp compute_traffic_rates(stats) do
# Stats come back ordered desc by checked_at, reverse for chronological order
sorted = Enum.reverse(stats)
sorted
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [stat1, stat2] ->
time_diff = stat2.checked_at |> DateTime.diff(stat1.checked_at, :second) |> max(1)
in_bps = compute_bps(stat1.if_in_octets, stat2.if_in_octets, time_diff)
out_bps = compute_bps(stat1.if_out_octets, stat2.if_out_octets, time_diff)
in_errors = (stat2.if_in_errors || 0) - (stat1.if_in_errors || 0)
out_errors = (stat2.if_out_errors || 0) - (stat1.if_out_errors || 0)
%{
timestamp: to_string(stat2.checked_at),
in_bps: in_bps,
out_bps: out_bps,
in_errors: max(in_errors, 0),
out_errors: max(out_errors, 0)
}
end)
end
defp compute_bps(nil, _, _time_diff), do: 0.0
defp compute_bps(_, nil, _time_diff), do: 0.0
defp compute_bps(octets1, octets2, time_diff) do
octets1
|> Towerops.Capacity.calculate_bps(octets2, time_diff)
|> Float.round(2)
end
defp parse_time_range(range) do
case Integer.parse(String.replace(range, "h", "")) do
{hours, _} -> hours
:error -> 24
end
end
end