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:
parent
b8bd952681
commit
a2d96f8e6e
14 changed files with 944 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" => ""}))
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue