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
This commit is contained in:
Graham McIntire 2026-01-14 08:38:50 -06:00
parent b8bd952681
commit a2d96f8e6e
No known key found for this signature in database
14 changed files with 944 additions and 20 deletions

View file

@ -10,6 +10,7 @@ defmodule Towerops.Agents do
alias Towerops.Agents.AgentAssignment
alias Towerops.Agents.AgentToken
alias Towerops.Equipment.Equipment
alias Towerops.Repo
## Token management
@ -56,6 +57,24 @@ defmodule Towerops.Agents do
|> Repo.all()
end
@doc """
Counts the number of equipment directly assigned to an agent.
This only counts equipment with explicit assignments, not equipment
that inherit the agent from site or organization defaults.
## Examples
iex> count_assigned_equipment(agent_token_id)
5
"""
def count_assigned_equipment(agent_token_id) do
AgentAssignment
|> where([a], a.agent_token_id == ^agent_token_id and a.enabled == true)
|> Repo.aggregate(:count)
end
@doc """
Gets a single agent token by ID.
@ -194,7 +213,7 @@ defmodule Towerops.Agents do
"""
def list_agent_equipment(agent_token_id) do
Repo.all(
from(e in Towerops.Equipment.Equipment,
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,
@ -203,6 +222,74 @@ defmodule Towerops.Agents do
)
end
@doc """
Lists all equipment that should be polled by an agent.
This includes equipment that is:
1. Directly assigned to this agent
2. At a site that has this agent as default
3. In an organization that has this agent as default (and not overridden by site or equipment)
Returns equipment records with preloaded associations, filtered to only SNMP-enabled equipment.
## Examples
iex> list_agent_polling_targets(agent_token_id)
[%Equipment{snmp_enabled: true, site: %Site{}, snmp_device: %Device{sensors: [...], interfaces: [...]}}]
"""
def list_agent_polling_targets(agent_token_id) do
# Get all equipment with necessary preloads
equipment_query =
from(e in Equipment,
where: e.snmp_enabled == true,
preload: [
:site,
site: :organization,
snmp_device: [:sensors, :interfaces]
]
)
equipment_list = Repo.all(equipment_query)
# Filter to only equipment that resolves to this agent
Enum.filter(equipment_list, fn equipment ->
equipment_matches_agent?(equipment, agent_token_id)
end)
end
defp equipment_matches_agent?(equipment, agent_token_id) do
# Check direct assignment first
case get_equipment_assignment(equipment.id) do
%AgentAssignment{agent_token_id: ^agent_token_id, enabled: true} ->
true
%AgentAssignment{} ->
# Has a different direct assignment
false
nil ->
# No direct assignment, check site then org
site = equipment.site
site_matches_agent?(site, agent_token_id)
end
end
defp site_matches_agent?(site, agent_token_id) do
cond do
# Check site agent
site.agent_token_id == agent_token_id ->
true
# Check org default (only if site doesn't have an agent set)
is_nil(site.agent_token_id) and site.organization.default_agent_token_id == agent_token_id ->
true
true ->
false
end
end
@doc """
Gets the agent assignment for a piece of equipment.
@ -255,4 +342,94 @@ defmodule Towerops.Agents do
|> Repo.update()
end
end
@doc """
Gets the effective agent token for a piece of equipment.
Resolves the agent token using the following hierarchy:
1. Equipment's direct agent assignment (highest priority)
2. Site's agent_token_id
3. Organization's default_agent_token_id (lowest priority)
Returns the agent_token_id if found, nil otherwise.
The equipment must be preloaded with site: [organization: :default_agent_token].
## Examples
iex> equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
iex> get_effective_agent_token(equipment)
"agent-token-uuid"
iex> get_effective_agent_token(equipment_without_any_assignment)
nil
"""
def get_effective_agent_token(%Equipment{} = equipment) do
# 1. Check for direct equipment assignment
case get_equipment_assignment(equipment.id) do
%AgentAssignment{agent_token_id: agent_token_id} when not is_nil(agent_token_id) ->
agent_token_id
_ ->
# 2. Check site's agent token
site = equipment.site
case site do
%{agent_token_id: agent_token_id} when not is_nil(agent_token_id) ->
agent_token_id
%{organization: %{default_agent_token_id: agent_token_id}}
when not is_nil(agent_token_id) ->
# 3. Use organization's default agent token
agent_token_id
_ ->
nil
end
end
end
@doc """
Gets information about where the effective agent token comes from.
Returns a tuple of {agent_token_id, source} where source is one of:
- :equipment - Direct equipment assignment
- :site - Inherited from site
- :organization - Inherited from organization
- :none - No agent assigned
## Examples
iex> get_effective_agent_token_with_source(equipment)
{"token-id", :equipment}
iex> get_effective_agent_token_with_source(equipment_no_assignment)
{nil, :none}
"""
def get_effective_agent_token_with_source(%Equipment{} = equipment) do
# 1. Check for direct equipment assignment
case get_equipment_assignment(equipment.id) do
%AgentAssignment{agent_token_id: agent_token_id} when not is_nil(agent_token_id) ->
{agent_token_id, :equipment}
_ ->
# 2. Check site's agent token
site = equipment.site
case site do
%{agent_token_id: agent_token_id} when not is_nil(agent_token_id) ->
{agent_token_id, :site}
%{organization: %{default_agent_token_id: agent_token_id}}
when not is_nil(agent_token_id) ->
# 3. Use organization's default agent token
{agent_token_id, :organization}
_ ->
{nil, :none}
end
end
end
end

View file

@ -14,6 +14,7 @@ defmodule Towerops.Sites.Site do
field :location, :string
belongs_to :organization, Towerops.Organizations.Organization
belongs_to :agent_token, Towerops.Agents.AgentToken
belongs_to :parent_site, Site
has_many :child_sites, Site, foreign_key: :parent_site_id
has_many :equipment, Towerops.Equipment.Equipment
@ -24,12 +25,13 @@ defmodule Towerops.Sites.Site do
@doc false
def changeset(site, attrs) do
site
|> cast(attrs, [:name, :description, :location, :organization_id, :parent_site_id])
|> cast(attrs, [:name, :description, :location, :organization_id, :agent_token_id, :parent_site_id])
|> validate_required([:name, :organization_id])
|> validate_length(:name, min: 2, max: 200)
|> validate_length(:description, max: 1000)
|> validate_length(:location, max: 200)
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:agent_token_id)
|> foreign_key_constraint(:parent_site_id)
|> validate_not_circular_parent()
end

View file

@ -17,11 +17,13 @@ defmodule ToweropsWeb.Api.AgentController do
@doc """
GET /api/v1/agent/config
Returns polling configuration for all equipment assigned to the authenticated agent.
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_equipment(agent_token.id)
equipment_list = Agents.list_agent_polling_targets(agent_token.id)
config = %{
version: "1.0",

View file

@ -9,10 +9,17 @@ defmodule ToweropsWeb.AgentLive.Index do
organization = socket.assigns.current_organization
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
# Get equipment counts for each agent
equipment_counts =
Map.new(agent_tokens, fn token ->
{token.id, Agents.count_assigned_equipment(token.id)}
end)
{:ok,
socket
|> assign(:page_title, "Remote Agents")
|> assign(:agent_tokens, agent_tokens)
|> assign(:equipment_counts, equipment_counts)
|> assign(:new_token, nil)
|> assign(:show_token_modal, false)
|> assign(:agent_form, to_form(%{"name" => ""}))}
@ -37,9 +44,16 @@ defmodule ToweropsWeb.AgentLive.Index do
{:ok, agent_token, token} ->
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
# Refresh equipment counts
equipment_counts =
Map.new(agent_tokens, fn t ->
{t.id, Agents.count_assigned_equipment(t.id)}
end)
{:noreply,
socket
|> assign(:agent_tokens, agent_tokens)
|> assign(:equipment_counts, equipment_counts)
|> assign(:new_token, %{agent_token: agent_token, token: token})
|> assign(:show_token_modal, true)
|> assign(:agent_form, to_form(%{"name" => ""}))

View file

@ -78,6 +78,15 @@
</span>
</:col>
<:col :let={agent} label="Equipment">
<div class="text-sm text-zinc-900 dark:text-zinc-100">
{Map.get(@equipment_counts, agent.id, 0)}
</div>
<div class="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
directly assigned
</div>
</:col>
<:col :let={agent} label="Last Seen">
<div class="text-sm text-zinc-600 dark:text-zinc-400">
{format_last_seen(agent.last_seen_at)}
@ -144,7 +153,8 @@
<div
class="fixed inset-0 bg-zinc-500/75 dark:bg-zinc-950/75 transition-opacity"
phx-click="close_token_modal"
></div>
>
</div>
<div class="relative bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-2xl w-full">
<div class="p-6">

View file

@ -71,17 +71,27 @@ defmodule ToweropsWeb.EquipmentLive.Form do
defp apply_action(socket, :edit, %{"id" => id}) do
equipment = Equipment.get_equipment!(id)
organization = socket.assigns.organization
# Preload necessary associations for agent resolution
equipment = Towerops.Repo.preload(equipment, site: [organization: :default_agent_token])
# Get current agent assignment if any
current_assignment = Agents.get_equipment_assignment(equipment.id)
# Get the effective agent and where it comes from
{effective_agent_id, agent_source} = Agents.get_effective_agent_token_with_source(equipment)
# For the form, we only want the equipment-specific assignment
agent_token_id =
if current_assignment do
current_assignment.agent_token_id
else
# Use organization default if no explicit assignment
organization.default_agent_token_id
end
# Get agent name for display
effective_agent_name =
if effective_agent_id do
agent = Enum.find(socket.assigns.available_agents, &(&1.id == effective_agent_id))
agent && agent.name
end
# Add agent_token_id to the changeset data
@ -92,6 +102,8 @@ defmodule ToweropsWeb.EquipmentLive.Form do
|> assign(:page_title, "Edit Equipment")
|> assign(:equipment, equipment_with_agent)
|> assign(:form, to_form(changeset))
|> assign(:agent_source, agent_source)
|> assign(:effective_agent_name, effective_agent_name)
end
@impl true

View file

@ -57,13 +57,44 @@
<.input
field={@form[:agent_token_id]}
type="select"
label="Remote Agent (optional)"
prompt="No agent - poll from server"
label="Remote Agent"
prompt="Inherit from site/organization"
options={Enum.map(@available_agents, &{&1.name, &1.id})}
/>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Assign this equipment to a remote agent for local SNMP polling. Leave empty to poll from the Towerops server.
</p>
<%= if @live_action == :edit and Map.has_key?(assigns, :agent_source) do %>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
<%= case @agent_source do %>
<% :equipment -> %>
<span class="inline-flex items-center gap-1">
<.icon name="hero-server" class="h-4 w-4" />
Explicitly assigned to this equipment
</span>
<% :site -> %>
<span class="inline-flex items-center gap-1">
<.icon name="hero-building-office" class="h-4 w-4" /> Inherited from site
<%= if @effective_agent_name do %>
(<strong>{@effective_agent_name}</strong>)
<% end %>
</span>
<% :organization -> %>
<span class="inline-flex items-center gap-1">
<.icon name="hero-building-office-2" class="h-4 w-4" />
Inherited from organization
<%= if @effective_agent_name do %>
(<strong>{@effective_agent_name}</strong>)
<% end %>
</span>
<% :none -> %>
<span class="inline-flex items-center gap-1">
<.icon name="hero-cloud" class="h-4 w-4" /> No agent assigned - cloud polling
</span>
<% end %>
</p>
<% else %>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Assign this equipment to a remote agent for local SNMP polling. Leave empty to inherit from site or organization defaults.
</p>
<% end %>
<% end %>
<div class="mt-8 border-t pt-6">

View file

@ -27,19 +27,20 @@
<div class="mt-6 border-t pt-6">
<h3 class="text-lg font-medium mb-4">Default Agent</h3>
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
Select a default agent for SNMP polling. This will be used for all equipment unless overridden at the equipment level.
Select a default agent for SNMP polling. This will be used for all equipment in this organization unless overridden at the site or equipment level.
</p>
<.input
field={@form[:default_agent_token_id]}
type="select"
label="Default Remote Agent"
prompt="No default agent - poll from server"
prompt="No default agent - cloud polling"
options={Enum.map(@available_agents, &{&1.name, &1.id})}
/>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
You can override this setting for individual equipment in their settings.
<p class="mt-2 text-sm text-zinc-500 dark:text-zinc-400 italic">
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
Agent assignment hierarchy: Equipment > Site > Organization
</p>
</div>
<% else %>

View file

@ -2,6 +2,7 @@ defmodule ToweropsWeb.SiteLive.Form do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Agents
alias Towerops.Sites
alias Towerops.Sites.Site
@ -12,7 +13,8 @@ defmodule ToweropsWeb.SiteLive.Form do
{:ok,
socket
|> assign(:organization, organization)
|> assign(:available_parent_sites, Sites.list_organization_sites(organization.id))}
|> assign(:available_parent_sites, Sites.list_organization_sites(organization.id))
|> assign(:available_agents, Agents.list_organization_agent_tokens(organization.id))}
end
@impl true

View file

@ -40,6 +40,19 @@
<.input field={@form[:location]} type="text" label="Location" />
<%= if @available_agents != [] do %>
<.input
field={@form[:agent_token_id]}
type="select"
label="Default Agent for Site"
prompt="Inherit from organization"
options={Enum.map(@available_agents, &{&1.name, &1.id})}
/>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Set a default agent for all equipment at this site. Equipment can override this setting individually.
</p>
<% end %>
<.input field={@form[:description]} type="textarea" label="Description" />
<div class="flex gap-3 mt-6">

View file

@ -0,0 +1,11 @@
defmodule Towerops.Repo.Migrations.AddAgentTokenToSites do
use Ecto.Migration
def change do
alter table(:sites) do
add :agent_token_id, references(:agent_tokens, type: :binary_id, on_delete: :nilify_all)
end
create index(:sites, [:agent_token_id])
end
end

View file

@ -358,4 +358,555 @@ defmodule Towerops.AgentsTest do
assert Ecto.assoc_loaded?(equipment.site)
end
end
describe "get_effective_agent_token/1 - hierarchical resolution" do
setup %{organization: org} do
{:ok, agent1, _} = Agents.create_agent_token(org.id, "Agent 1")
{:ok, agent2, _} = Agents.create_agent_token(org.id, "Agent 2")
{:ok, agent3, _} = Agents.create_agent_token(org.id, "Agent 3")
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id
})
%{
agent1: agent1,
agent2: agent2,
agent3: agent3,
site: site,
equipment: equipment
}
end
test "returns equipment-level assignment (highest priority)", %{
organization: org,
agent1: agent1,
agent2: agent2,
agent3: agent3,
site: site,
equipment: equipment
} do
# Set up hierarchy: org default, site override, equipment override
{:ok, _org} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
{:ok, _site} = Towerops.Sites.update_site(site, %{agent_token_id: agent2.id})
{:ok, _} = Agents.assign_equipment_to_agent(agent3.id, equipment.id)
# Preload necessary associations
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
# Equipment assignment should take precedence
assert Agents.get_effective_agent_token(equipment) == agent3.id
end
test "returns site-level assignment when equipment not assigned", %{
organization: org,
agent1: agent1,
agent2: agent2,
site: site,
equipment: equipment
} do
# Set up hierarchy: org default and site override, but no equipment assignment
{:ok, _org} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
{:ok, _site} = Towerops.Sites.update_site(site, %{agent_token_id: agent2.id})
# Preload necessary associations
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
# Site assignment should be used
assert Agents.get_effective_agent_token(equipment) == agent2.id
end
test "returns organization-level assignment when site and equipment not assigned", %{
organization: org,
agent1: agent1,
equipment: equipment
} do
# Set up hierarchy: only org default
{:ok, _org} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
# Preload necessary associations
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
# Organization default should be used
assert Agents.get_effective_agent_token(equipment) == agent1.id
end
test "returns nil when no agent assigned at any level", %{equipment: equipment} do
# No assignments at any level
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
assert Agents.get_effective_agent_token(equipment) == nil
end
end
describe "get_effective_agent_token_with_source/1" do
setup %{organization: org} do
{:ok, agent1, _} = Agents.create_agent_token(org.id, "Agent 1")
{:ok, agent2, _} = Agents.create_agent_token(org.id, "Agent 2")
{:ok, agent3, _} = Agents.create_agent_token(org.id, "Agent 3")
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id
})
%{
agent1: agent1,
agent2: agent2,
agent3: agent3,
site: site,
equipment: equipment
}
end
test "returns equipment source for equipment-level assignment", %{
agent3: agent3,
equipment: equipment
} do
{:ok, _} = Agents.assign_equipment_to_agent(agent3.id, equipment.id)
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
assert {agent_id, :equipment} = Agents.get_effective_agent_token_with_source(equipment)
assert agent_id == agent3.id
end
test "returns site source for site-level assignment", %{
agent2: agent2,
site: site,
equipment: equipment
} do
{:ok, _site} = Towerops.Sites.update_site(site, %{agent_token_id: agent2.id})
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
assert {agent_id, :site} = Agents.get_effective_agent_token_with_source(equipment)
assert agent_id == agent2.id
end
test "returns organization source for organization-level assignment", %{
organization: org,
agent1: agent1,
equipment: equipment
} do
{:ok, _org} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
assert {agent_id, :organization} = Agents.get_effective_agent_token_with_source(equipment)
assert agent_id == agent1.id
end
test "returns none source when no assignment", %{equipment: equipment} do
equipment = Repo.preload(equipment, site: [organization: :default_agent_token])
assert {nil, :none} = Agents.get_effective_agent_token_with_source(equipment)
end
end
describe "count_assigned_equipment/1" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
name: "Test Equipment 1",
ip_address: "192.168.1.1",
site_id: site.id
})
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
name: "Test Equipment 2",
ip_address: "192.168.1.2",
site_id: site.id
})
{:ok, equipment3} =
Towerops.Equipment.create_equipment(%{
name: "Test Equipment 3",
ip_address: "192.168.1.3",
site_id: site.id
})
%{site: site, equipment1: equipment1, equipment2: equipment2, equipment3: equipment3}
end
test "counts directly assigned equipment", %{
organization: org,
equipment1: equipment1,
equipment2: equipment2
} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
# Initially no assignments
assert Agents.count_assigned_equipment(agent_token.id) == 0
# Assign two pieces of equipment
{:ok, _} = Agents.assign_equipment_to_agent(agent_token.id, equipment1.id)
assert Agents.count_assigned_equipment(agent_token.id) == 1
{:ok, _} = Agents.assign_equipment_to_agent(agent_token.id, equipment2.id)
assert Agents.count_assigned_equipment(agent_token.id) == 2
end
test "does not count disabled assignments", %{
organization: org,
equipment1: equipment1
} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
{:ok, assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment1.id)
assert Agents.count_assigned_equipment(agent_token.id) == 1
# Disable the assignment
assignment
|> Ecto.Changeset.change(enabled: false)
|> Repo.update!()
assert Agents.count_assigned_equipment(agent_token.id) == 0
end
end
describe "list_agent_polling_targets/1 - hierarchical polling" do
setup %{organization: org} do
{:ok, agent1, _} = Agents.create_agent_token(org.id, "Agent 1")
{:ok, agent2, _} = Agents.create_agent_token(org.id, "Agent 2")
{:ok, agent3, _} = Agents.create_agent_token(org.id, "Agent 3")
{:ok, site1} =
Towerops.Sites.create_site(%{
name: "Site 1",
organization_id: org.id
})
{:ok, site2} =
Towerops.Sites.create_site(%{
name: "Site 2",
organization_id: org.id
})
%{
agent1: agent1,
agent2: agent2,
agent3: agent3,
site1: site1,
site2: site2
}
end
test "returns equipment with direct agent assignment", %{
agent1: agent1,
agent2: agent2,
site1: site1
} do
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Assign equipment to agent1
{:ok, _} = Agents.assign_equipment_to_agent(agent1.id, equipment1.id)
{:ok, _} = Agents.assign_equipment_to_agent(agent1.id, equipment2.id)
# Agent1 should see both equipment
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == equipment1.id))
assert Enum.any?(targets, &(&1.id == equipment2.id))
# Agent2 should see nothing
targets = Agents.list_agent_polling_targets(agent2.id)
assert targets == []
end
test "returns equipment inheriting from site agent", %{
agent1: agent1,
agent2: agent2,
site1: site1
} do
# Set site1 to use agent1
{:ok, _site} = Towerops.Sites.update_site(site1, %{agent_token_id: agent1.id})
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Agent1 should see both equipment (inherited from site)
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == equipment1.id))
assert Enum.any?(targets, &(&1.id == equipment2.id))
# Agent2 should see nothing
targets = Agents.list_agent_polling_targets(agent2.id)
assert targets == []
end
test "returns equipment inheriting from organization default", %{
organization: org,
agent1: agent1,
agent2: agent2,
site1: site1
} do
# Set organization default to agent1
{:ok, _org} =
Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Agent1 should see both equipment (inherited from organization)
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 2
assert Enum.any?(targets, &(&1.id == equipment1.id))
assert Enum.any?(targets, &(&1.id == equipment2.id))
# Agent2 should see nothing
targets = Agents.list_agent_polling_targets(agent2.id)
assert targets == []
end
test "equipment-level assignment overrides site and organization", %{
organization: org,
agent1: agent1,
agent2: agent2,
agent3: agent3,
site1: site1
} do
# Set org default to agent1, site to agent2, equipment to agent3
{:ok, _org} =
Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
{:ok, _site} = Towerops.Sites.update_site(site1, %{agent_token_id: agent2.id})
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Directly assign to agent3
{:ok, _} = Agents.assign_equipment_to_agent(agent3.id, equipment1.id)
# Only agent3 should see the equipment
targets = Agents.list_agent_polling_targets(agent3.id)
assert length(targets) == 1
assert hd(targets).id == equipment1.id
# Agent1 and agent2 should not see it
assert Agents.list_agent_polling_targets(agent1.id) == []
assert Agents.list_agent_polling_targets(agent2.id) == []
end
test "site-level assignment overrides organization default", %{
organization: org,
agent1: agent1,
agent2: agent2,
site1: site1
} do
# Set org default to agent1, site to agent2
{:ok, _org} =
Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
{:ok, _site} = Towerops.Sites.update_site(site1, %{agent_token_id: agent2.id})
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Agent2 should see the equipment (site level)
targets = Agents.list_agent_polling_targets(agent2.id)
assert length(targets) == 1
assert hd(targets).id == equipment1.id
# Agent1 should not see it (org default is overridden by site)
assert Agents.list_agent_polling_targets(agent1.id) == []
end
test "only returns SNMP-enabled equipment", %{
agent1: agent1,
site1: site1
} do
# Equipment with SNMP enabled
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Equipment with SNMP disabled
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site1.id,
snmp_enabled: false
})
# Assign both to agent1
{:ok, _} = Agents.assign_equipment_to_agent(agent1.id, equipment1.id)
{:ok, _} = Agents.assign_equipment_to_agent(agent1.id, equipment2.id)
# Agent1 should only see SNMP-enabled equipment
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 1
assert hd(targets).id == equipment1.id
assert hd(targets).snmp_enabled == true
end
test "returns equipment from multiple sites with different assignment levels", %{
organization: org,
agent1: agent1,
agent2: agent2,
site1: site1,
site2: site2
} do
# Set org default to agent1
{:ok, _org} =
Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
# Site2 overrides to agent2
{:ok, _site} = Towerops.Sites.update_site(site2, %{agent_token_id: agent2.id})
# Equipment at site1 (inherits org default - agent1)
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Equipment at site2 (inherits site default - agent2)
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
site_id: site2.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
# Agent1 should see equipment1 (from site1)
targets = Agents.list_agent_polling_targets(agent1.id)
assert length(targets) == 1
assert hd(targets).id == equipment1.id
# Agent2 should see equipment2 (from site2)
targets = Agents.list_agent_polling_targets(agent2.id)
assert length(targets) == 1
assert hd(targets).id == equipment2.id
end
test "preloads necessary associations for API response", %{
agent1: agent1,
site1: site1
} do
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
site_id: site1.id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, _} = Agents.assign_equipment_to_agent(agent1.id, equipment1.id)
targets = Agents.list_agent_polling_targets(agent1.id)
equipment = hd(targets)
# Verify preloads
assert Ecto.assoc_loaded?(equipment.site)
assert Ecto.assoc_loaded?(equipment.site.organization)
end
end
end

View file

@ -173,6 +173,104 @@ defmodule ToweropsWeb.Api.AgentControllerTest do
assert interface_config["if_index"] == 1
assert interface_config["if_name"] == "GigabitEthernet0/1"
end
test "returns equipment with hierarchical agent assignment", %{
conn: conn,
token: token,
organization: org,
agent_token: agent_token
} do
# Create another agent
{:ok, other_agent, _other_token} =
Agents.create_agent_token(org.id, "Other Agent")
# Set organization default to other_agent
{:ok, _org} =
Towerops.Organizations.update_organization(org, %{
default_agent_token_id: other_agent.id
})
# Create site1 with no agent (will inherit org default)
{:ok, site1} =
Towerops.Sites.create_site(%{
name: "Site 1",
organization_id: org.id
})
# Create site2 with explicit agent assignment to our agent
{:ok, site2} =
Towerops.Sites.create_site(%{
name: "Site 2",
organization_id: org.id,
agent_token_id: agent_token.id
})
# Create site3 with no agent (will inherit org default)
{:ok, site3} =
Towerops.Sites.create_site(%{
name: "Site 3",
organization_id: org.id
})
# Equipment 1: Directly assigned to our agent (highest priority)
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 1",
ip_address: "192.168.1.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
site_id: site1.id
})
{:ok, _} = Agents.assign_equipment_to_agent(agent_token.id, equipment1.id)
# Equipment 2: At site2, inherits from site (our agent)
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 2",
ip_address: "192.168.1.2",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
site_id: site2.id
})
# Equipment 3: At site3, inherits from org default (other agent) - should NOT be returned
{:ok, _equipment3} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 3",
ip_address: "192.168.1.3",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
site_id: site3.id
})
# Equipment 4: SNMP disabled - should NOT be returned
{:ok, _equipment4} =
Towerops.Equipment.create_equipment(%{
name: "Equipment 4",
ip_address: "192.168.1.4",
snmp_enabled: false,
site_id: site1.id
})
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> get(~p"/api/v1/agent/config")
response = json_response(conn, 200)
assert %{"equipment" => equipment_list} = response
assert length(equipment_list) == 2
# Verify equipment IDs match
equipment_ids = Enum.map(equipment_list, & &1["id"])
assert equipment1.id in equipment_ids
assert equipment2.id in equipment_ids
end
end
describe "POST /api/v1/agent/metrics" do

View file

@ -53,7 +53,7 @@ defmodule ToweropsWeb.Org.SettingsLiveTest do
assert html =~ "Default Remote Agent"
assert html =~ agent_token.name
assert html =~ "No default agent - poll from server"
assert html =~ "No default agent - cloud polling"
end
test "updates organization name successfully", %{conn: conn, user: user, organization: org} do