+ <.icon name="hero-exclamation-triangle" class="h-4 w-4 inline" />
+ No global default configured. Devices without assignments will use cloud polling (nil).
+
+ <% end %>
+
+
+
+
+ <% end %>
<%= if @show_token_modal && @new_token do %>
diff --git a/priv/repo/migrations/20260124003501_add_agent_token_id_to_monitoring_checks.exs b/priv/repo/migrations/20260124003501_add_agent_token_id_to_monitoring_checks.exs
new file mode 100644
index 00000000..3296482d
--- /dev/null
+++ b/priv/repo/migrations/20260124003501_add_agent_token_id_to_monitoring_checks.exs
@@ -0,0 +1,25 @@
+defmodule Towerops.Repo.Migrations.AddAgentTokenIdToMonitoringChecks do
+ use Ecto.Migration
+
+ def up do
+ # Add agent_token_id column to track which agent performed the check
+ alter table(:monitoring_checks) do
+ add :agent_token_id, references(:agent_tokens, type: :binary_id, on_delete: :nilify_all)
+ end
+
+ # Add index for efficient latency queries (agent + device + time range)
+ create index(:monitoring_checks, [:agent_token_id, :device_id, :checked_at])
+
+ # Add index for device-specific latency analysis
+ create index(:monitoring_checks, [:device_id, :agent_token_id, :status, :checked_at])
+ end
+
+ def down do
+ drop index(:monitoring_checks, [:device_id, :agent_token_id, :status, :checked_at])
+ drop index(:monitoring_checks, [:agent_token_id, :device_id, :checked_at])
+
+ alter table(:monitoring_checks) do
+ remove :agent_token_id
+ end
+ end
+end
diff --git a/priv/repo/migrations/20260124003545_create_application_settings.exs b/priv/repo/migrations/20260124003545_create_application_settings.exs
new file mode 100644
index 00000000..1ec72606
--- /dev/null
+++ b/priv/repo/migrations/20260124003545_create_application_settings.exs
@@ -0,0 +1,36 @@
+defmodule Towerops.Repo.Migrations.CreateApplicationSettings do
+ use Ecto.Migration
+
+ def up do
+ create table(:application_settings, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :key, :string, null: false
+ add :value, :text
+ add :value_type, :string, null: false, default: "string"
+ add :description, :text
+
+ timestamps(type: :utc_datetime)
+ end
+
+ # Enforce unique keys
+ create unique_index(:application_settings, [:key])
+
+ # Insert default global cloud poller setting (nil = not configured)
+ execute """
+ INSERT INTO application_settings (id, key, value, value_type, description, inserted_at, updated_at)
+ VALUES (
+ gen_random_uuid(),
+ 'global_default_cloud_poller_id',
+ NULL,
+ 'uuid',
+ 'Default cloud poller agent used as fallback when no organization-level agent is configured',
+ NOW(),
+ NOW()
+ )
+ """
+ end
+
+ def down do
+ drop table(:application_settings)
+ end
+end
diff --git a/test/towerops/agents/stats_test.exs b/test/towerops/agents/stats_test.exs
index 0c43f253..1469b129 100644
--- a/test/towerops/agents/stats_test.exs
+++ b/test/towerops/agents/stats_test.exs
@@ -7,6 +7,7 @@ defmodule Towerops.Agents.StatsTest do
alias Towerops.Agents
alias Towerops.Agents.Stats
alias Towerops.Devices.Device
+ alias Towerops.Monitoring
alias Towerops.Repo
alias Towerops.Sites
alias Towerops.Snmp.Device
@@ -452,4 +453,536 @@ defmodule Towerops.Agents.StatsTest do
assert stats.last_submission
end
end
+
+ describe "get_device_latency_by_agent/2" do
+ test "returns empty list when no monitoring checks exist" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ latency_by_agent = Stats.get_device_latency_by_agent(device.id)
+
+ assert latency_by_agent == []
+ end
+
+ test "calculates average latency per agent for a device" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, agent1, _} = Agents.create_agent_token(org.id, "Agent 1")
+ {:ok, agent2, _} = Agents.create_agent_token(org.id, "Agent 2")
+ {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create checks from agent1 with avg 50ms
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: agent1.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Create checks from agent2 with avg 100ms
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: agent2.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ latency_by_agent = Stats.get_device_latency_by_agent(device.id)
+
+ assert length(latency_by_agent) == 2
+
+ agent1_latency = Enum.find(latency_by_agent, &(&1.agent_token_id == agent1.id))
+ agent2_latency = Enum.find(latency_by_agent, &(&1.agent_token_id == agent2.id))
+
+ assert agent1_latency.avg_latency_ms == 50.0
+ assert agent1_latency.check_count == 15
+ assert agent2_latency.avg_latency_ms == 100.0
+ assert agent2_latency.check_count == 15
+ end
+
+ test "filters by successful checks only" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, agent, _} = Agents.create_agent_token(org.id, "Agent")
+ {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create successful checks with 50ms
+ for _ <- 1..10 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Create failed checks (should be ignored)
+ for _ <- 1..5 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: agent.id,
+ status: :error,
+ response_time_ms: nil,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ latency_by_agent = Stats.get_device_latency_by_agent(device.id)
+
+ assert length(latency_by_agent) == 1
+ agent_latency = hd(latency_by_agent)
+ assert agent_latency.avg_latency_ms == 50.0
+ assert agent_latency.check_count == 10
+ end
+
+ test "respects minimum check count filter" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, agent1, _} = Agents.create_agent_token(org.id, "Agent 1")
+ {:ok, agent2, _} = Agents.create_agent_token(org.id, "Agent 2")
+ {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Agent1 has 15 checks (above min)
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: agent1.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Agent2 has only 5 checks (below min)
+ for _ <- 1..5 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: agent2.id,
+ status: :success,
+ response_time_ms: 30,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ latency_by_agent = Stats.get_device_latency_by_agent(device.id, min_checks: 10)
+
+ assert length(latency_by_agent) == 1
+ assert hd(latency_by_agent).agent_token_id == agent1.id
+ end
+
+ test "respects time window parameter" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, agent, _} = Agents.create_agent_token(org.id, "Agent")
+ {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create recent checks
+ for _ <- 1..10 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Create old checks (25 hours ago, outside default 24h window)
+ old_time = DateTime.add(DateTime.utc_now(), -25, :hour)
+
+ for _ <- 1..5 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: agent.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: old_time
+ })
+ end
+
+ # Default 24h window should only include recent checks
+ latency_by_agent = Stats.get_device_latency_by_agent(device.id)
+
+ assert length(latency_by_agent) == 1
+ agent_latency = hd(latency_by_agent)
+ assert agent_latency.avg_latency_ms == 50.0
+ assert agent_latency.check_count == 10
+
+ # 48h window should include both
+ latency_by_agent_48h = Stats.get_device_latency_by_agent(device.id, hours_ago: 48)
+
+ assert length(latency_by_agent_48h) == 1
+ agent_latency_48h = hd(latency_by_agent_48h)
+ assert agent_latency_48h.check_count == 15
+ end
+ end
+
+ describe "get_device_assignment_with_latency/1" do
+ test "returns assignment details with latency stats" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, agent1, _} = Agents.create_agent_token(org.id, "Fast Agent")
+ {:ok, agent2, _} = Agents.create_agent_token(org.id, "Slow Agent")
+ {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Assign to agent2
+ {:ok, _} = Agents.assign_device_to_agent(agent2.id, device.id)
+
+ # Create checks from both agents
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: agent1.id,
+ status: :success,
+ response_time_ms: 30,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: agent2.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ result = Stats.get_device_assignment_with_latency(device.id)
+
+ assert result.device_id == device.id
+ assert result.current_agent_token_id == agent2.id
+ assert result.assignment_source == :device
+ assert length(result.latency_stats) == 2
+
+ fast_agent = Enum.find(result.latency_stats, &(&1.agent_token_id == agent1.id))
+ slow_agent = Enum.find(result.latency_stats, &(&1.agent_token_id == agent2.id))
+
+ assert fast_agent.avg_latency_ms == 30.0
+ assert slow_agent.avg_latency_ms == 100.0
+ end
+
+ test "returns nil current_agent_id when using cloud polling" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ result = Stats.get_device_assignment_with_latency(device.id)
+
+ assert result.device_id == device.id
+ assert is_nil(result.current_agent_token_id)
+ assert result.assignment_source == :none
+ end
+ end
+
+ describe "find_reassignment_candidates/1" do
+ test "returns empty list when no devices have better options" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, agent, _} = Agents.create_agent_token(org.id, "Agent")
+ {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ {:ok, _} = Agents.assign_device_to_agent(agent.id, device.id)
+
+ # Create checks showing this is the only/best agent
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ candidates = Stats.find_reassignment_candidates()
+
+ assert candidates == []
+ end
+
+ test "identifies devices with 20%+ improvement available" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, slow_agent, _} = Agents.create_agent_token(org.id, "Slow Agent")
+ {:ok, fast_agent, _} = Agents.create_agent_token(org.id, "Fast Agent")
+
+ {:ok, site} =
+ Sites.create_site(%{
+ name: "Test Site",
+ organization_id: org.id,
+ agent_token_id: slow_agent.id
+ })
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create checks: slow agent = 100ms, fast agent = 50ms (50% improvement)
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: slow_agent.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: fast_agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ candidates = Stats.find_reassignment_candidates(min_improvement_percent: 20)
+
+ assert length(candidates) == 1
+ candidate = hd(candidates)
+
+ assert candidate.device_id == device.id
+ assert candidate.current_agent_token_id == slow_agent.id
+ assert candidate.best_agent_token_id == fast_agent.id
+ assert candidate.current_latency_ms == 100.0
+ assert candidate.best_latency_ms == 50.0
+ assert candidate.improvement_percent == 50.0
+ end
+
+ test "only considers automatic assignments" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, slow_agent, _} = Agents.create_agent_token(org.id, "Slow Agent")
+ {:ok, fast_agent, _} = Agents.create_agent_token(org.id, "Fast Agent")
+ {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Direct device assignment (should be excluded from automatic reassignment)
+ {:ok, _} = Agents.assign_device_to_agent(slow_agent.id, device.id)
+
+ # Create checks showing fast agent is better
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: slow_agent.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: fast_agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Should not reassign direct assignments
+ candidates = Stats.find_reassignment_candidates()
+
+ assert candidates == []
+ end
+
+ test "includes site-level assignments for automatic reassignment" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, slow_agent, _} = Agents.create_agent_token(org.id, "Slow Agent")
+ {:ok, fast_agent, _} = Agents.create_agent_token(org.id, "Fast Agent")
+
+ {:ok, site} =
+ Sites.create_site(%{
+ name: "Test Site",
+ organization_id: org.id,
+ agent_token_id: slow_agent.id
+ })
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create checks showing fast agent is better
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: slow_agent.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: fast_agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ candidates = Stats.find_reassignment_candidates()
+
+ assert length(candidates) == 1
+ candidate = hd(candidates)
+ assert candidate.assignment_source == :site
+ assert candidate.best_agent_token_id == fast_agent.id
+ end
+
+ test "requires minimum check count" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, slow_agent, _} = Agents.create_agent_token(org.id, "Slow Agent")
+ {:ok, fast_agent, _} = Agents.create_agent_token(org.id, "Fast Agent")
+
+ {:ok, site} =
+ Sites.create_site(%{
+ name: "Test Site",
+ organization_id: org.id,
+ agent_token_id: slow_agent.id
+ })
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create only 5 checks (below min of 10)
+ for _ <- 1..5 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: slow_agent.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: fast_agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Should not reassign with insufficient data
+ candidates = Stats.find_reassignment_candidates(min_checks_per_agent: 10)
+
+ assert candidates == []
+ end
+ end
end
diff --git a/test/towerops/agents_test.exs b/test/towerops/agents_test.exs
index 78b956db..818d6bfa 100644
--- a/test/towerops/agents_test.exs
+++ b/test/towerops/agents_test.exs
@@ -691,6 +691,60 @@ defmodule Towerops.AgentsTest do
assert Agents.get_effective_agent_token(device) == nil
end
+
+ test "returns global default cloud poller when no other assignment exists" do
+ # Create a cloud poller (application-wide agent)
+ {:ok, cloud_poller, _} = Agents.create_cloud_poller("Global Cloud Poller")
+
+ # Set it as the global default
+ {:ok, _} = Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id)
+
+ # Create a fresh organization and device with no assignments
+ user = user_fixture()
+ {:ok, org} = Towerops.Organizations.create_organization(%{name: "Test Org 2"}, user.id)
+
+ {:ok, site} =
+ Towerops.Sites.create_site(%{
+ name: "Test Site 2",
+ organization_id: org.id
+ })
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Test Equipment 2",
+ ip_address: "192.168.2.1",
+ site_id: site.id
+ })
+
+ device = Repo.preload(device, site: [organization: :default_agent_token])
+
+ # Should use global default cloud poller
+ assert Agents.get_effective_agent_token(device) == cloud_poller.id
+
+ # Clean up
+ {:ok, _} = Towerops.Settings.set_global_default_cloud_poller(nil)
+ end
+
+ test "organization default takes precedence over global default", %{
+ organization: org,
+ agent1: agent1,
+ device: device
+ } do
+ # Create a cloud poller as global default
+ {:ok, cloud_poller, _} = Agents.create_cloud_poller("Global Cloud Poller")
+ {:ok, _} = Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id)
+
+ # Set organization default
+ {:ok, _org} = Towerops.Organizations.update_organization(org, %{default_agent_token_id: agent1.id})
+
+ device = Repo.preload(device, site: [organization: :default_agent_token])
+
+ # Should use organization default, not global default
+ assert Agents.get_effective_agent_token(device) == agent1.id
+
+ # Clean up
+ {:ok, _} = Towerops.Settings.set_global_default_cloud_poller(nil)
+ end
end
describe "get_effective_agent_token_with_source/1" do
@@ -764,6 +818,37 @@ defmodule Towerops.AgentsTest do
assert {nil, :none} = Agents.get_effective_agent_token_with_source(device)
end
+
+ test "returns global source for global default cloud poller" do
+ # Create a cloud poller and set as global default
+ {:ok, cloud_poller, _} = Agents.create_cloud_poller("Global Cloud Poller")
+ {:ok, _} = Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id)
+
+ # Create a fresh organization and device with no assignments
+ user = user_fixture()
+ {:ok, org} = Towerops.Organizations.create_organization(%{name: "Test Org 3"}, user.id)
+
+ {:ok, site} =
+ Towerops.Sites.create_site(%{
+ name: "Test Site 3",
+ organization_id: org.id
+ })
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Test Equipment 3",
+ ip_address: "192.168.3.1",
+ site_id: site.id
+ })
+
+ device = Repo.preload(device, site: [organization: :default_agent_token])
+
+ assert {agent_id, :global} = Agents.get_effective_agent_token_with_source(device)
+ assert agent_id == cloud_poller.id
+
+ # Clean up
+ {:ok, _} = Towerops.Settings.set_global_default_cloud_poller(nil)
+ end
end
describe "count_assigned_devices/1" do
diff --git a/test/towerops/settings_test.exs b/test/towerops/settings_test.exs
new file mode 100644
index 00000000..c96bb33c
--- /dev/null
+++ b/test/towerops/settings_test.exs
@@ -0,0 +1,152 @@
+defmodule Towerops.SettingsTest do
+ use Towerops.DataCase
+
+ alias Towerops.Settings
+ alias Towerops.Settings.ApplicationSetting
+
+ describe "get_setting/1" do
+ test "returns parsed value for existing setting" do
+ # The migration already created the global_default_cloud_poller_id setting
+ assert Settings.get_setting("global_default_cloud_poller_id") == nil
+ end
+
+ test "returns nil for non-existent setting" do
+ assert Settings.get_setting("nonexistent_key") == nil
+ end
+
+ test "parses string values correctly" do
+ {:ok, _setting} = create_setting("test_string", "hello", "string")
+ assert Settings.get_setting("test_string") == "hello"
+ end
+
+ test "parses integer values correctly" do
+ {:ok, _setting} = create_setting("test_integer", "42", "integer")
+ assert Settings.get_setting("test_integer") == 42
+ end
+
+ test "parses boolean values correctly" do
+ {:ok, _setting} = create_setting("test_bool_true", "true", "boolean")
+ {:ok, _setting} = create_setting("test_bool_false", "false", "boolean")
+ {:ok, _setting} = create_setting("test_bool_one", "1", "boolean")
+ {:ok, _setting} = create_setting("test_bool_zero", "0", "boolean")
+
+ assert Settings.get_setting("test_bool_true") == true
+ assert Settings.get_setting("test_bool_false") == false
+ assert Settings.get_setting("test_bool_one") == true
+ assert Settings.get_setting("test_bool_zero") == false
+ end
+
+ test "parses json values correctly" do
+ {:ok, _setting} = create_setting("test_json", ~s({"key": "value"}), "json")
+ assert Settings.get_setting("test_json") == %{"key" => "value"}
+ end
+
+ test "returns nil for invalid integer" do
+ {:ok, _setting} = create_setting("test_invalid_int", "not_a_number", "integer")
+ assert Settings.get_setting("test_invalid_int") == nil
+ end
+
+ test "returns nil for invalid json" do
+ {:ok, _setting} = create_setting("test_invalid_json", "not json", "json")
+ assert Settings.get_setting("test_invalid_json") == nil
+ end
+ end
+
+ describe "get_setting_record/1" do
+ test "returns the raw ApplicationSetting record" do
+ record = Settings.get_setting_record("global_default_cloud_poller_id")
+ assert %ApplicationSetting{} = record
+ assert record.key == "global_default_cloud_poller_id"
+ assert record.value_type == "uuid"
+ end
+
+ test "returns nil for non-existent setting" do
+ assert Settings.get_setting_record("nonexistent") == nil
+ end
+ end
+
+ describe "update_setting/2" do
+ test "updates an existing setting value" do
+ agent_token_id = Ecto.UUID.generate()
+ {:ok, updated} = Settings.update_setting("global_default_cloud_poller_id", agent_token_id)
+
+ assert updated.value == agent_token_id
+ assert Settings.get_setting("global_default_cloud_poller_id") == agent_token_id
+ end
+
+ test "updates setting to nil" do
+ agent_token_id = Ecto.UUID.generate()
+ {:ok, _} = Settings.update_setting("global_default_cloud_poller_id", agent_token_id)
+ {:ok, updated} = Settings.update_setting("global_default_cloud_poller_id", nil)
+
+ assert updated.value == nil
+ assert Settings.get_setting("global_default_cloud_poller_id") == nil
+ end
+
+ test "returns error for non-existent setting" do
+ assert {:error, :setting_not_found} =
+ Settings.update_setting("nonexistent_key", "value")
+ end
+
+ test "converts non-string values to strings" do
+ {:ok, _setting} = create_setting("test_number", "0", "integer")
+ {:ok, updated} = Settings.update_setting("test_number", 123)
+
+ assert updated.value == "123"
+ assert Settings.get_setting("test_number") == 123
+ end
+ end
+
+ describe "get_global_default_cloud_poller/0" do
+ test "returns nil when not configured" do
+ assert Settings.get_global_default_cloud_poller() == nil
+ end
+
+ test "returns agent token ID when configured" do
+ agent_token_id = Ecto.UUID.generate()
+ {:ok, _} = Settings.set_global_default_cloud_poller(agent_token_id)
+
+ assert Settings.get_global_default_cloud_poller() == agent_token_id
+ end
+ end
+
+ describe "set_global_default_cloud_poller/1" do
+ test "sets the global default cloud poller" do
+ agent_token_id = Ecto.UUID.generate()
+ {:ok, setting} = Settings.set_global_default_cloud_poller(agent_token_id)
+
+ assert setting.value == agent_token_id
+ assert Settings.get_global_default_cloud_poller() == agent_token_id
+ end
+
+ test "clears the global default cloud poller when set to nil" do
+ agent_token_id = Ecto.UUID.generate()
+ {:ok, _} = Settings.set_global_default_cloud_poller(agent_token_id)
+ {:ok, setting} = Settings.set_global_default_cloud_poller(nil)
+
+ assert setting.value == nil
+ assert Settings.get_global_default_cloud_poller() == nil
+ end
+ end
+
+ describe "list_all_settings/0" do
+ test "returns all application settings" do
+ settings = Settings.list_all_settings()
+
+ assert is_list(settings)
+ refute Enum.empty?(settings)
+ assert Enum.any?(settings, &(&1.key == "global_default_cloud_poller_id"))
+ end
+ end
+
+ # Helper function to create test settings
+ defp create_setting(key, value, value_type) do
+ %ApplicationSetting{}
+ |> ApplicationSetting.changeset(%{
+ key: key,
+ value: value,
+ value_type: value_type
+ })
+ |> Repo.insert()
+ end
+end
diff --git a/test/towerops/workers/agent_latency_evaluator_test.exs b/test/towerops/workers/agent_latency_evaluator_test.exs
new file mode 100644
index 00000000..e7ccaa7e
--- /dev/null
+++ b/test/towerops/workers/agent_latency_evaluator_test.exs
@@ -0,0 +1,517 @@
+defmodule Towerops.Workers.AgentLatencyEvaluatorTest do
+ use Towerops.DataCase, async: false
+
+ import Towerops.AccountsFixtures
+ import Towerops.OrganizationsFixtures
+
+ alias Towerops.Agents
+ alias Towerops.Monitoring
+ alias Towerops.Sites
+ alias Towerops.Workers.AgentLatencyEvaluator
+
+ setup do
+ # Start the worker for testing
+ {:ok, pid} = start_supervised(AgentLatencyEvaluator)
+ %{worker_pid: pid}
+ end
+
+ describe "evaluate_latency/0" do
+ test "reassigns device to faster agent when 20%+ improvement available" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, slow_agent, _} = Agents.create_agent_token(org.id, "Slow Agent")
+ {:ok, fast_agent, _} = Agents.create_agent_token(org.id, "Fast Agent")
+
+ {:ok, site} =
+ Sites.create_site(%{
+ name: "Test Site",
+ organization_id: org.id,
+ agent_token_id: slow_agent.id
+ })
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create checks: slow = 100ms, fast = 50ms (50% improvement)
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: slow_agent.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: fast_agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Trigger evaluation
+ AgentLatencyEvaluator.trigger_evaluation()
+
+ # Wait for async processing
+ Process.sleep(100)
+
+ # Verify site was reassigned
+ updated_site = Sites.get_site!(site.id)
+ assert updated_site.agent_token_id == fast_agent.id
+ end
+
+ test "does not reassign when improvement is below 20% threshold" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, agent1, _} = Agents.create_agent_token(org.id, "Agent 1")
+ {:ok, agent2, _} = Agents.create_agent_token(org.id, "Agent 2")
+
+ {:ok, site} =
+ Sites.create_site(%{
+ name: "Test Site",
+ organization_id: org.id,
+ agent_token_id: agent1.id
+ })
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create checks: agent1 = 100ms, agent2 = 85ms (15% improvement, below 20%)
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: agent1.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: agent2.id,
+ status: :success,
+ response_time_ms: 85,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Trigger evaluation
+ AgentLatencyEvaluator.trigger_evaluation()
+
+ # Wait for async processing
+ Process.sleep(100)
+
+ # Verify site was NOT reassigned
+ updated_site = Sites.get_site!(site.id)
+ assert updated_site.agent_token_id == agent1.id
+ end
+
+ test "reassigns organization-level assignment" do
+ user = user_fixture()
+
+ {:ok, slow_agent, _} = Agents.create_cloud_poller("Slow Cloud")
+ {:ok, fast_agent, _} = Agents.create_cloud_poller("Fast Cloud")
+
+ org =
+ organization_fixture(user.id, %{
+ default_agent_token_id: slow_agent.id
+ })
+
+ {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create checks showing fast agent is better
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: slow_agent.id,
+ status: :success,
+ response_time_ms: 150,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: fast_agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Trigger evaluation
+ AgentLatencyEvaluator.trigger_evaluation()
+
+ # Wait for async processing
+ Process.sleep(100)
+
+ # Verify organization was reassigned
+ updated_org = Towerops.Organizations.get_organization!(org.id)
+ assert updated_org.default_agent_token_id == fast_agent.id
+ end
+
+ test "creates device assignment for global default assignment" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+
+ {:ok, slow_cloud, _} = Agents.create_cloud_poller("Slow Global")
+ {:ok, fast_cloud, _} = Agents.create_cloud_poller("Fast Global")
+
+ # Set slow cloud as global default
+ {:ok, _} = Towerops.Settings.set_global_default_cloud_poller(slow_cloud.id)
+
+ {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create checks showing fast cloud is better
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: slow_cloud.id,
+ status: :success,
+ response_time_ms: 200,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: fast_cloud.id,
+ status: :success,
+ response_time_ms: 60,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Trigger evaluation
+ AgentLatencyEvaluator.trigger_evaluation()
+
+ # Wait for async processing
+ Process.sleep(100)
+
+ # Verify device assignment was created
+ assignment = Agents.get_device_assignment(device.id)
+ assert assignment.agent_token_id == fast_cloud.id
+
+ # Cleanup global default
+ {:ok, _} = Towerops.Settings.set_global_default_cloud_poller(nil)
+ end
+
+ test "creates device assignment for cloud polling (no assignment)" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+
+ {:ok, slow_cloud, _} = Agents.create_cloud_poller("Slow Cloud")
+ {:ok, fast_cloud, _} = Agents.create_cloud_poller("Fast Cloud")
+
+ {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create checks from both cloud agents
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: slow_cloud.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: fast_cloud.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Trigger evaluation
+ AgentLatencyEvaluator.trigger_evaluation()
+
+ # Wait for async processing
+ Process.sleep(500)
+
+ # Verify device assignment was created to the fast agent
+ assignment = Agents.get_device_assignment(device.id)
+ assert assignment != nil, "Expected device assignment to be created"
+ assert assignment.agent_token_id == fast_cloud.id
+ end
+
+ test "does not reassign direct device assignments" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, slow_agent, _} = Agents.create_agent_token(org.id, "Slow Agent")
+ {:ok, fast_agent, _} = Agents.create_agent_token(org.id, "Fast Agent")
+
+ {:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: org.id})
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create direct device assignment
+ {:ok, _} = Agents.assign_device_to_agent(slow_agent.id, device.id)
+
+ # Create checks showing fast agent is better
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: slow_agent.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: fast_agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Trigger evaluation
+ AgentLatencyEvaluator.trigger_evaluation()
+
+ # Wait for async processing
+ Process.sleep(100)
+
+ # Verify device assignment was NOT changed
+ assignment = Agents.get_device_assignment(device.id)
+ assert assignment.agent_token_id == slow_agent.id
+ end
+
+ test "requires minimum check count" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, slow_agent, _} = Agents.create_agent_token(org.id, "Slow Agent")
+ {:ok, fast_agent, _} = Agents.create_agent_token(org.id, "Fast Agent")
+
+ {:ok, site} =
+ Sites.create_site(%{
+ name: "Test Site",
+ organization_id: org.id,
+ agent_token_id: slow_agent.id
+ })
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create only 5 checks (below default minimum of 10)
+ for _ <- 1..5 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: slow_agent.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: fast_agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Trigger evaluation
+ AgentLatencyEvaluator.trigger_evaluation()
+
+ # Wait for async processing
+ Process.sleep(100)
+
+ # Verify site was NOT reassigned (insufficient data)
+ updated_site = Sites.get_site!(site.id)
+ assert updated_site.agent_token_id == slow_agent.id
+ end
+
+ test "handles multiple devices in single evaluation" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, slow_agent, _} = Agents.create_agent_token(org.id, "Slow Agent")
+ {:ok, fast_agent, _} = Agents.create_agent_token(org.id, "Fast Agent")
+
+ {:ok, site1} =
+ Sites.create_site(%{
+ name: "Site 1",
+ organization_id: org.id,
+ agent_token_id: slow_agent.id
+ })
+
+ {:ok, site2} =
+ Sites.create_site(%{
+ name: "Site 2",
+ organization_id: org.id,
+ agent_token_id: slow_agent.id
+ })
+
+ {:ok, device1} =
+ Towerops.Devices.create_device(%{
+ name: "Router 1",
+ ip_address: "192.168.1.1",
+ site_id: site1.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ {:ok, device2} =
+ Towerops.Devices.create_device(%{
+ name: "Router 2",
+ ip_address: "192.168.2.1",
+ site_id: site2.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create checks for both devices
+ for device_id <- [device1.id, device2.id] do
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device_id,
+ agent_token_id: slow_agent.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device_id,
+ agent_token_id: fast_agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+ end
+
+ # Trigger evaluation
+ AgentLatencyEvaluator.trigger_evaluation()
+
+ # Wait for async processing
+ Process.sleep(100)
+
+ # Verify both sites were reassigned
+ updated_site1 = Sites.get_site!(site1.id)
+ updated_site2 = Sites.get_site!(site2.id)
+
+ assert updated_site1.agent_token_id == fast_agent.id
+ assert updated_site2.agent_token_id == fast_agent.id
+ end
+ end
+
+ describe "handle_info(:evaluate_latency)" do
+ test "worker processes evaluation on schedule" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ {:ok, slow_agent, _} = Agents.create_agent_token(org.id, "Slow Agent")
+ {:ok, fast_agent, _} = Agents.create_agent_token(org.id, "Fast Agent")
+
+ {:ok, site} =
+ Sites.create_site(%{
+ name: "Test Site",
+ organization_id: org.id,
+ agent_token_id: slow_agent.id
+ })
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Router",
+ ip_address: "192.168.1.1",
+ site_id: site.id,
+ snmp_enabled: true,
+ snmp_community: "public",
+ snmp_version: "2c"
+ })
+
+ # Create checks
+ for _ <- 1..15 do
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: slow_agent.id,
+ status: :success,
+ response_time_ms: 100,
+ checked_at: DateTime.utc_now()
+ })
+
+ Monitoring.create_check(%{
+ device_id: device.id,
+ agent_token_id: fast_agent.id,
+ status: :success,
+ response_time_ms: 50,
+ checked_at: DateTime.utc_now()
+ })
+ end
+
+ # Send the message directly to the worker
+ send(Process.whereis(AgentLatencyEvaluator), :evaluate_latency)
+
+ # Wait for async processing
+ Process.sleep(100)
+
+ # Verify site was reassigned
+ updated_site = Sites.get_site!(site.id)
+ assert updated_site.agent_token_id == fast_agent.id
+ end
+ end
+end