From a2d96f8e6eca1c17f24a6c65cd98266e3318a4fe Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 14 Jan 2026 08:38:50 -0600 Subject: [PATCH] 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 --- lib/towerops/agents.ex | 179 +++++- lib/towerops/sites/site.ex | 4 +- .../controllers/api/agent_controller.ex | 6 +- lib/towerops_web/live/agent_live/index.ex | 14 + .../live/agent_live/index.html.heex | 12 +- lib/towerops_web/live/equipment_live/form.ex | 20 +- .../live/equipment_live/form.html.heex | 41 +- .../live/org/settings_live.html.heex | 9 +- lib/towerops_web/live/site_live/form.ex | 4 +- .../live/site_live/form.html.heex | 13 + ...0260114142010_add_agent_token_to_sites.exs | 11 + test/towerops/agents_test.exs | 551 ++++++++++++++++++ .../controllers/api/agent_controller_test.exs | 98 ++++ .../live/org/settings_live_test.exs | 2 +- 14 files changed, 944 insertions(+), 20 deletions(-) create mode 100644 priv/repo/migrations/20260114142010_add_agent_token_to_sites.exs diff --git a/lib/towerops/agents.ex b/lib/towerops/agents.ex index 3c186c34..861d6fc7 100644 --- a/lib/towerops/agents.ex +++ b/lib/towerops/agents.ex @@ -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 diff --git a/lib/towerops/sites/site.ex b/lib/towerops/sites/site.ex index 8a9d155e..db386f1e 100644 --- a/lib/towerops/sites/site.ex +++ b/lib/towerops/sites/site.ex @@ -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 diff --git a/lib/towerops_web/controllers/api/agent_controller.ex b/lib/towerops_web/controllers/api/agent_controller.ex index e9b591c2..12d5ade1 100644 --- a/lib/towerops_web/controllers/api/agent_controller.ex +++ b/lib/towerops_web/controllers/api/agent_controller.ex @@ -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", diff --git a/lib/towerops_web/live/agent_live/index.ex b/lib/towerops_web/live/agent_live/index.ex index 4d43571a..6724bbb0 100644 --- a/lib/towerops_web/live/agent_live/index.ex +++ b/lib/towerops_web/live/agent_live/index.ex @@ -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" => ""})) diff --git a/lib/towerops_web/live/agent_live/index.html.heex b/lib/towerops_web/live/agent_live/index.html.heex index cdc82a86..d83b594f 100644 --- a/lib/towerops_web/live/agent_live/index.html.heex +++ b/lib/towerops_web/live/agent_live/index.html.heex @@ -78,6 +78,15 @@ + <:col :let={agent} label="Equipment"> +
+ {Map.get(@equipment_counts, agent.id, 0)} +
+
+ directly assigned +
+ + <:col :let={agent} label="Last Seen">
{format_last_seen(agent.last_seen_at)} @@ -144,7 +153,8 @@
+ > +
diff --git a/lib/towerops_web/live/equipment_live/form.ex b/lib/towerops_web/live/equipment_live/form.ex index 1c5234c7..93700a79 100644 --- a/lib/towerops_web/live/equipment_live/form.ex +++ b/lib/towerops_web/live/equipment_live/form.ex @@ -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 diff --git a/lib/towerops_web/live/equipment_live/form.html.heex b/lib/towerops_web/live/equipment_live/form.html.heex index 59351290..5d753d5c 100644 --- a/lib/towerops_web/live/equipment_live/form.html.heex +++ b/lib/towerops_web/live/equipment_live/form.html.heex @@ -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})} /> -

- Assign this equipment to a remote agent for local SNMP polling. Leave empty to poll from the Towerops server. -

+ <%= if @live_action == :edit and Map.has_key?(assigns, :agent_source) do %> +

+ <%= case @agent_source do %> + <% :equipment -> %> + + <.icon name="hero-server" class="h-4 w-4" /> + Explicitly assigned to this equipment + + <% :site -> %> + + <.icon name="hero-building-office" class="h-4 w-4" /> Inherited from site + <%= if @effective_agent_name do %> + ({@effective_agent_name}) + <% end %> + + <% :organization -> %> + + <.icon name="hero-building-office-2" class="h-4 w-4" /> + Inherited from organization + <%= if @effective_agent_name do %> + ({@effective_agent_name}) + <% end %> + + <% :none -> %> + + <.icon name="hero-cloud" class="h-4 w-4" /> No agent assigned - cloud polling + + <% end %> +

+ <% else %> +

+ Assign this equipment to a remote agent for local SNMP polling. Leave empty to inherit from site or organization defaults. +

+ <% end %> <% end %>
diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index 83f29b86..028eae33 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -27,19 +27,20 @@

Default Agent

- 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.

<.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})} /> -

- You can override this setting for individual equipment in their settings. +

+ <.icon name="hero-information-circle" class="h-4 w-4 inline" /> + Agent assignment hierarchy: Equipment > Site > Organization

<% else %> diff --git a/lib/towerops_web/live/site_live/form.ex b/lib/towerops_web/live/site_live/form.ex index 11842080..0d2f363f 100644 --- a/lib/towerops_web/live/site_live/form.ex +++ b/lib/towerops_web/live/site_live/form.ex @@ -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 diff --git a/lib/towerops_web/live/site_live/form.html.heex b/lib/towerops_web/live/site_live/form.html.heex index 5e6fef4b..ed2026a5 100644 --- a/lib/towerops_web/live/site_live/form.html.heex +++ b/lib/towerops_web/live/site_live/form.html.heex @@ -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})} + /> +

+ Set a default agent for all equipment at this site. Equipment can override this setting individually. +

+ <% end %> + <.input field={@form[:description]} type="textarea" label="Description" />
diff --git a/priv/repo/migrations/20260114142010_add_agent_token_to_sites.exs b/priv/repo/migrations/20260114142010_add_agent_token_to_sites.exs new file mode 100644 index 00000000..82ce479c --- /dev/null +++ b/priv/repo/migrations/20260114142010_add_agent_token_to_sites.exs @@ -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 diff --git a/test/towerops/agents_test.exs b/test/towerops/agents_test.exs index 26feb053..72871a35 100644 --- a/test/towerops/agents_test.exs +++ b/test/towerops/agents_test.exs @@ -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 diff --git a/test/towerops_web/controllers/api/agent_controller_test.exs b/test/towerops_web/controllers/api/agent_controller_test.exs index 1a549135..6f5083ab 100644 --- a/test/towerops_web/controllers/api/agent_controller_test.exs +++ b/test/towerops_web/controllers/api/agent_controller_test.exs @@ -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 diff --git a/test/towerops_web/live/org/settings_live_test.exs b/test/towerops_web/live/org/settings_live_test.exs index 95b7eef8..1ebc3215 100644 --- a/test/towerops_web/live/org/settings_live_test.exs +++ b/test/towerops_web/live/org/settings_live_test.exs @@ -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