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