diff --git a/lib/towerops/agents/stats.ex b/lib/towerops/agents/stats.ex new file mode 100644 index 00000000..2d4eec11 --- /dev/null +++ b/lib/towerops/agents/stats.ex @@ -0,0 +1,312 @@ +defmodule Towerops.Agents.Stats do + @moduledoc """ + Statistical queries and analytics for agent monitoring and health. + + Provides functions to monitor agent health, performance, and usage across the organization. + """ + + import Ecto.Query, warn: false + + alias Towerops.Agents.AgentAssignment + alias Towerops.Agents.AgentToken + alias Towerops.Equipment.Equipment + alias Towerops.Repo + alias Towerops.Snmp.SensorReading + + @doc """ + Get agent health statistics for an organization. + + Returns a list of agents with their health metrics: + - Online status (last_seen_at within 5 minutes) + - Number of assigned equipment + - Last metric submission time + - Version information + + ## Examples + + iex> get_organization_agent_health(org_id) + [ + %{ + id: "uuid", + name: "DC1 Agent", + online: true, + last_seen_at: ~U[2026-01-14 12:00:00Z], + equipment_count: 15, + last_metric_at: ~U[2026-01-14 12:00:30Z], + version: "0.1.0", + hostname: "agent-dc1" + } + ] + """ + def get_organization_agent_health(organization_id) do + five_minutes_ago = DateTime.add(DateTime.utc_now(), -5, :minute) + + query = + from(a in AgentToken, + where: a.organization_id == ^organization_id and a.enabled == true, + select: %{ + id: a.id, + name: a.name, + online: a.last_seen_at > ^five_minutes_ago, + last_seen_at: a.last_seen_at, + version: fragment("?->>'version'", a.metadata), + hostname: fragment("?->>'hostname'", a.metadata) + }, + order_by: [desc: a.last_seen_at] + ) + + agents = Repo.all(query) + + # Get equipment count, last metric time for each agent + Enum.map(agents, fn agent -> + equipment_count = get_agent_equipment_count(agent.id) + last_metric_at = get_agent_last_metric_time(agent.id) + + agent + |> Map.put(:equipment_count, equipment_count) + |> Map.put(:last_metric_at, last_metric_at) + end) + end + + @doc """ + Get equipment count breakdown by agent assignment source. + + Returns counts for equipment assigned via: + - Direct assignment + - Site default + - Organization default + - No agent (cloud polling) + + ## Examples + + iex> get_equipment_assignment_breakdown(org_id) + %{ + direct: 50, + site: 30, + organization: 15, + cloud: 5 + } + """ + def get_equipment_assignment_breakdown(organization_id) do + # Get all equipment for organization + equipment_query = + from(e in Equipment, + join: s in assoc(e, :site), + where: s.organization_id == ^organization_id and e.snmp_enabled == true, + preload: [:site, site: :organization] + ) + + equipment_list = Repo.all(equipment_query) + + # Count by assignment type + Enum.reduce(equipment_list, %{direct: 0, site: 0, organization: 0, cloud: 0}, fn eq, acc -> + {_agent_id, source} = Towerops.Agents.get_effective_agent_token_with_source(eq) + + case source do + :equipment -> %{acc | direct: acc.direct + 1} + :site -> %{acc | site: acc.site + 1} + :organization -> %{acc | organization: acc.organization + 1} + :none -> %{acc | cloud: acc.cloud + 1} + end + end) + end + + @doc """ + Get metric submission stats for an agent over the last 24 hours. + + Returns: + - Total metrics submitted + - Sensor readings count + - Interface stats count + - Average metrics per hour + - Last successful submission + + ## Examples + + iex> get_agent_metric_stats(agent_id) + %{ + total_metrics: 1440, + sensor_readings: 960, + interface_stats: 480, + avg_per_hour: 60, + last_submission: ~U[2026-01-14 12:00:00Z] + } + """ + def get_agent_metric_stats(agent_token_id) do + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + + # Get sensor readings + sensor_count = + Repo.one( + from(sr in SensorReading, + join: s in assoc(sr, :sensor), + join: d in assoc(s, :snmp_device), + join: e in assoc(d, :equipment), + where: sr.checked_at > ^twenty_four_hours_ago and e.id in subquery(polling_targets_subquery(agent_token_id)), + select: count(sr.id) + ) + ) || 0 + + # Get interface stats + interface_count = + Repo.one( + from(is in Towerops.Snmp.InterfaceStat, + join: i in assoc(is, :interface), + join: d in assoc(i, :snmp_device), + join: e in assoc(d, :equipment), + where: is.checked_at > ^twenty_four_hours_ago and e.id in subquery(polling_targets_subquery(agent_token_id)), + select: count(is.id) + ) + ) || 0 + + total = sensor_count + interface_count + + # Get last submission time + last_submission = + Repo.one( + from(sr in SensorReading, + join: s in assoc(sr, :sensor), + join: d in assoc(s, :snmp_device), + join: e in assoc(d, :equipment), + where: e.id in subquery(polling_targets_subquery(agent_token_id)), + select: max(sr.checked_at) + ) + ) + + %{ + total_metrics: total, + sensor_readings: sensor_count, + interface_stats: interface_count, + avg_per_hour: if(total > 0, do: Float.round(total / 24, 1), else: 0), + last_submission: last_submission + } + end + + @doc """ + Get list of offline agents (not seen in last 5 minutes). + + Returns list of agent tokens that haven't reported in recently. + + ## Examples + + iex> get_offline_agents(org_id) + [ + %{id: "uuid", name: "DC2 Agent", last_seen_at: ~U[2026-01-14 10:00:00Z]} + ] + """ + def get_offline_agents(organization_id) do + five_minutes_ago = DateTime.add(DateTime.utc_now(), -5, :minute) + + Repo.all( + from(a in AgentToken, + where: + a.organization_id == ^organization_id and a.enabled == true and + (is_nil(a.last_seen_at) or a.last_seen_at < ^five_minutes_ago), + select: %{id: a.id, name: a.name, last_seen_at: a.last_seen_at}, + order_by: [asc: a.last_seen_at] + ) + ) + end + + @doc """ + Get agents with high load (>50 devices). + + Identifies agents that may need to be split or have their equipment redistributed. + + ## Examples + + iex> get_high_load_agents(org_id) + [ + %{id: "uuid", name: "DC1 Agent", equipment_count: 75} + ] + """ + def get_high_load_agents(organization_id, threshold \\ 50) do + query = + from(a in AgentToken, + where: a.organization_id == ^organization_id and a.enabled == true, + select: %{id: a.id, name: a.name} + ) + + agents = Repo.all(query) + + # Get equipment count for each agent, filter by threshold, and sort + agents + |> Enum.map(fn agent -> + equipment_count = get_agent_equipment_count(agent.id) + Map.put(agent, :equipment_count, equipment_count) + end) + |> Enum.filter(fn agent -> agent.equipment_count > threshold end) + |> Enum.sort_by(& &1.equipment_count, :desc) + end + + @doc """ + Get equipment that have no agent assigned and are not polling. + + Identifies orphaned equipment that may need agent assignment. + + ## Examples + + iex> get_unmonitored_equipment(org_id) + [ + %{id: "uuid", name: "Switch-01", site_name: "DC1"} + ] + """ + def get_unmonitored_equipment(organization_id) do + from(e in Equipment, + join: s in assoc(e, :site), + left_join: o in assoc(s, :organization), + where: + s.organization_id == ^organization_id and + e.snmp_enabled == true and + e.monitoring_enabled == true, + preload: [site: :organization] + ) + |> Repo.all() + |> Enum.filter(fn equipment -> + # Filter to only equipment with no agent assigned + {agent_id, _source} = Towerops.Agents.get_effective_agent_token_with_source(equipment) + is_nil(agent_id) + end) + |> Enum.map(fn equipment -> + %{ + id: equipment.id, + name: equipment.name, + ip_address: equipment.ip_address, + site_name: equipment.site.name + } + end) + end + + # Private helper functions + + defp polling_targets_subquery(agent_token_id) do + # This is a simplified version - in production you'd want to use + # Agents.list_agent_polling_targets/1 logic here + from(e in Equipment, + join: a in AgentAssignment, + on: a.equipment_id == e.id, + where: a.agent_token_id == ^agent_token_id and a.enabled == true and e.snmp_enabled == true, + select: e.id + ) + end + + defp get_agent_last_metric_time(agent_token_id) do + Repo.one( + from(sr in SensorReading, + join: s in assoc(sr, :sensor), + join: d in assoc(s, :snmp_device), + join: e in assoc(d, :equipment), + where: e.id in subquery(polling_targets_subquery(agent_token_id)), + select: max(sr.checked_at) + ) + ) + end + + defp get_agent_equipment_count(agent_token_id) do + # Count equipment by using list_agent_polling_targets + # This properly handles hierarchical assignment + agent_token_id + |> Towerops.Agents.list_agent_polling_targets() + |> length() + end +end