Add agent statistics and monitoring queries
- Created Towerops.Agents.Stats module with health monitoring functions - get_organization_agent_health/1: Online status, equipment counts, metrics - get_equipment_assignment_breakdown/1: Count by assignment source - get_agent_metric_stats/1: 24-hour metric submission statistics - get_offline_agents/1: Agents not seen in 5+ minutes - get_high_load_agents/2: Agents with >50 devices (configurable) - get_unmonitored_equipment/1: Equipment with no agent assigned Uses hierarchical agent assignment logic via list_agent_polling_targets/1 to properly count equipment across all assignment levels.
This commit is contained in:
parent
ba281ce40c
commit
4e7d3612e2
1 changed files with 312 additions and 0 deletions
312
lib/towerops/agents/stats.ex
Normal file
312
lib/towerops/agents/stats.ex
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue