towerops/lib/towerops_web/controllers/api/agent_controller.ex
Graham McIntire a2d96f8e6e
Implement hierarchical agent assignment for SNMP polling
Add three-level agent assignment hierarchy (Equipment > Site > Organization)
allowing flexible agent deployment strategies. Agents now receive only equipment
assigned to them through direct assignment or inheritance from site/organization
defaults.

Key changes:
- Add agent_token_id to Sites table with migration
- Implement get_effective_agent_token/1 for hierarchical resolution
- Add list_agent_polling_targets/1 to return polling targets per agent
- Update API config endpoint to use hierarchical polling targets
- Add agent assignment UI to equipment, site, and organization forms
- Show agent source (direct/site/org/none) in equipment form
- Add equipment count column to agent list
- Update terminology from "poll from server" to "cloud polling"

Tests:
- Add 8 comprehensive tests for list_agent_polling_targets/1
- Add end-to-end test for hierarchical config endpoint
- All 775 tests passing
2026-01-14 08:38:50 -06:00

212 lines
5.7 KiB
Elixir

defmodule ToweropsWeb.Api.AgentController do
@moduledoc """
API controller for remote agent communication.
Provides endpoints for agents to:
- Fetch polling configuration
- Submit metrics
- Send heartbeats
"""
use ToweropsWeb, :controller
alias Towerops.Agent.MetricBatch
alias Towerops.Agents
alias Towerops.Snmp
@doc """
GET /api/v1/agent/config
Returns polling configuration for all equipment that should be polled by this agent.
This includes directly assigned equipment plus equipment that inherits the agent
from site or organization defaults.
"""
def get_config(conn, _params) do
agent_token = conn.assigns.current_agent_token
equipment_list = Agents.list_agent_polling_targets(agent_token.id)
config = %{
version: "1.0",
poll_interval_seconds: 60,
equipment: Enum.map(equipment_list, &build_equipment_config/1)
}
json(conn, config)
end
@doc """
POST /api/v1/agent/metrics
Accepts a batch of metrics from the agent and processes them asynchronously.
Supports both JSON (legacy) and Protocol Buffers (efficient).
"""
def submit_metrics(conn, params) do
agent_token = conn.assigns.current_agent_token
metrics =
case get_req_header(conn, "content-type") do
["application/x-protobuf" | _] ->
# Decode protobuf
{:ok, body, _conn} = Plug.Conn.read_body(conn)
batch = MetricBatch.decode(body)
convert_protobuf_metrics(batch.metrics)
_ ->
# JSON format (fallback)
Map.get(params, "metrics", [])
end
_ =
Task.start(fn ->
process_metrics(agent_token, metrics)
end)
json(conn, %{status: "accepted", received: length(metrics)})
end
@doc """
POST /api/v1/agent/heartbeat
Updates the agent's last_seen_at timestamp and metadata.
"""
def heartbeat(conn, params) do
agent_token = conn.assigns.current_agent_token
ip = to_string(:inet_parse.ntoa(conn.remote_ip))
metadata = %{
"version" => Map.get(params, "version"),
"hostname" => Map.get(params, "hostname"),
"uptime_seconds" => Map.get(params, "uptime_seconds")
}
# Update synchronously in the heartbeat endpoint
Agents.update_agent_token_heartbeat(agent_token.id, ip, metadata)
json(conn, %{status: "ok"})
end
# Private helpers
defp build_equipment_config(equipment) do
device = equipment.snmp_device
%{
id: equipment.id,
name: equipment.name,
ip_address: equipment.ip_address,
snmp: %{
enabled: equipment.snmp_enabled,
version: equipment.snmp_version,
community: equipment.snmp_community,
port: equipment.snmp_port || 161
},
poll_interval_seconds: equipment.check_interval_seconds || 60,
sensors: build_sensor_config(device),
interfaces: build_interface_config(device)
}
end
defp build_sensor_config(nil), do: []
defp build_sensor_config(device) do
Enum.map(device.sensors, fn sensor ->
%{
id: sensor.id,
type: sensor.sensor_type,
oid: sensor.sensor_oid,
divisor: sensor.sensor_divisor,
unit: sensor.sensor_unit,
metadata: sensor.metadata
}
end)
end
defp build_interface_config(nil), do: []
defp build_interface_config(device) do
Enum.map(device.interfaces, fn interface ->
%{
id: interface.id,
if_index: interface.if_index,
if_name: interface.if_name
}
end)
end
defp process_metrics(_agent_token, metrics) do
Enum.each(metrics, fn metric ->
case metric do
%{"type" => "sensor_reading"} = m ->
Snmp.create_sensor_reading(%{
sensor_id: m["sensor_id"],
value: m["value"],
status: m["status"] || "ok",
checked_at: parse_timestamp(m["timestamp"])
})
%{"type" => "interface_stat"} = m ->
Snmp.create_interface_stat(%{
interface_id: m["interface_id"],
if_in_octets: m["if_in_octets"],
if_out_octets: m["if_out_octets"],
if_in_errors: m["if_in_errors"],
if_out_errors: m["if_out_errors"],
if_in_discards: m["if_in_discards"],
if_out_discards: m["if_out_discards"],
checked_at: parse_timestamp(m["timestamp"])
})
_ ->
:ok
end
end)
end
defp convert_protobuf_metrics(metrics) do
metrics
|> Enum.map(fn metric ->
case metric.metric_type do
{:sensor_reading, sr} ->
%{
"type" => "sensor_reading",
"sensor_id" => sr.sensor_id,
"value" => sr.value,
"status" => sr.status,
"timestamp" => sr.timestamp
}
{:interface_stat, is} ->
%{
"type" => "interface_stat",
"interface_id" => is.interface_id,
"if_in_octets" => is.if_in_octets,
"if_out_octets" => is.if_out_octets,
"if_in_errors" => is.if_in_errors,
"if_out_errors" => is.if_out_errors,
"if_in_discards" => is.if_in_discards,
"if_out_discards" => is.if_out_discards,
"timestamp" => is.timestamp
}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
end
defp parse_timestamp(nil), do: DateTime.truncate(DateTime.utc_now(), :second)
defp parse_timestamp(ts) when is_integer(ts) do
ts
|> DateTime.from_unix!(:second)
|> DateTime.truncate(:second)
end
defp parse_timestamp(ts) when is_binary(ts) do
case DateTime.from_iso8601(ts) do
{:ok, dt, _} -> DateTime.truncate(dt, :second)
_ -> DateTime.truncate(DateTime.utc_now(), :second)
end
end
end