poller agent fix and trigger device rediscovery

This commit is contained in:
Graham McIntire 2026-01-26 14:51:32 -06:00
parent 5e7576bc3f
commit 756a6b4cd4
No known key found for this signature in database
17 changed files with 712 additions and 80 deletions

View file

@ -29,6 +29,7 @@ config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [
:request_id,
:remote_ip,
:status,
:duration_ms,
:kind,

View file

@ -1,5 +1,8 @@
import Config
# Disable Honeybadger in development
config :honeybadger, environment_name: :dev, exclude_envs: [:dev]
# Do not include metadata nor timestamps in development logs
config :logger, :default_formatter, format: "[$level] $message\n"

View file

@ -10,6 +10,7 @@ defmodule Towerops.Devices do
alias Towerops.Repo
alias Towerops.Workers.DeviceMonitorWorker
alias Towerops.Workers.DevicePollerWorker
alias Towerops.Workers.DiscoveryWorker
@doc """
Returns the list of devices for a site.
@ -289,13 +290,15 @@ defmodule Towerops.Devices do
def update_device(%DeviceSchema{} = device, attrs) do
old_monitoring = device.monitoring_enabled
old_snmp = device.snmp_enabled
old_snmp_version = device.snmp_version
old_snmp_port = device.snmp_port
case device
|> DeviceSchema.changeset(attrs)
|> Repo.update() do
{:ok, updated_device} = result ->
_ = handle_monitoring_changes(updated_device, old_monitoring)
_ = handle_snmp_changes(updated_device, old_snmp)
_ = handle_snmp_changes(updated_device, old_snmp, old_snmp_version, old_snmp_port)
result
error ->
@ -362,6 +365,8 @@ defmodule Towerops.Devices do
cond do
device.monitoring_enabled && !old_monitoring ->
DeviceMonitorWorker.start_monitoring(device.id)
# Trigger discovery when monitoring is enabled (if SNMP is also enabled)
if device.snmp_enabled, do: DiscoveryWorker.enqueue(device.id)
!device.monitoring_enabled && old_monitoring ->
DeviceMonitorWorker.stop_monitoring(device.id)
@ -371,19 +376,45 @@ defmodule Towerops.Devices do
end
end
defp handle_snmp_changes(device, old_snmp) do
defp handle_snmp_changes(device, old_snmp, old_snmp_version, old_snmp_port) do
should_discover = should_trigger_discovery?(device, old_snmp, old_snmp_version, old_snmp_port)
cond do
device.snmp_enabled && !old_snmp ->
DevicePollerWorker.start_polling(device.id)
if should_discover, do: DiscoveryWorker.enqueue(device.id)
!device.snmp_enabled && old_snmp ->
DevicePollerWorker.stop_polling(device.id)
should_discover ->
DiscoveryWorker.enqueue(device.id)
true ->
:ok
end
end
defp should_trigger_discovery?(device, old_snmp, old_snmp_version, old_snmp_port) do
cond do
# SNMP was just enabled
device.snmp_enabled && !old_snmp ->
true
# SNMP version changed while enabled
device.snmp_enabled && device.snmp_version != old_snmp_version ->
true
# SNMP port changed while enabled
device.snmp_enabled && device.snmp_port != old_snmp_port ->
true
# Otherwise no discovery needed
true ->
false
end
end
## Events
@doc """

View file

@ -31,7 +31,7 @@ defmodule Towerops.Devices.Device do
field :check_interval_seconds, :integer, default: 300
# SNMP fields
field :snmp_enabled, :boolean, default: false
field :snmp_enabled, :boolean, default: true
field :snmp_version, :string, default: "2c"
field :snmp_community, :string
field :snmp_port, :integer, default: 161

View file

@ -77,6 +77,10 @@ defmodule Towerops.Sites.Site do
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:agent_token_id)
|> foreign_key_constraint(:parent_site_id)
|> unique_constraint(:name,
name: :sites_organization_id_name_index,
message: "has already been taken for this organization"
)
|> validate_not_circular_parent()
end

View file

@ -34,25 +34,39 @@ defmodule Towerops.Workers.DiscoveryWorker do
def perform(%Oban.Job{args: %{"device_id" => device_id}}) do
case Devices.get_device_with_details(device_id) do
nil ->
Logger.error("Device #{device_id} not found")
{:error, :device_not_found}
Logger.warning("Device #{device_id} not found, discarding discovery job")
:discard
device ->
case get_assigned_agent(device) do
{agent_token_id, source} when not is_nil(agent_token_id) ->
agent = Agents.get_agent_token!(agent_token_id)
poller_type = if agent.cloud_poller, do: "cloud poller", else: "user's poller"
# Try to get the agent token, falling back if it doesn't exist
try do
agent = Agents.get_agent_token!(agent_token_id)
poller_type = if agent.cloud_poller, do: "cloud poller", else: "user's poller"
Logger.info(
"Starting SNMP discovery for device #{device_id} using #{poller_type} '#{agent.name}' (#{source} assignment)",
device_id: device_id,
agent_token_id: agent_token_id,
agent_name: agent.name,
cloud_poller: agent.cloud_poller,
source: source
)
Logger.info(
"Starting SNMP discovery for device #{device_id} using #{poller_type} '#{agent.name}' (#{source} assignment)",
device_id: device_id,
agent_token_id: agent_token_id,
agent_name: agent.name,
cloud_poller: agent.cloud_poller,
source: source
)
attempt_agent_discovery(device, agent_token_id, [agent_token_id])
attempt_agent_discovery(device, agent_token_id, [agent_token_id])
rescue
Ecto.NoResultsError ->
Logger.warning(
"Agent token #{agent_token_id} no longer exists (#{source} assignment), falling back to cloud pollers",
device_id: device_id,
agent_token_id: agent_token_id,
source: source
)
# Fall back to cloud poller discovery
attempt_cloud_poller_discovery(device, [])
end
{nil, :none} ->
Logger.info(

View file

@ -8,6 +8,7 @@ defmodule ToweropsWeb.Api.V1.DevicesController do
use ToweropsWeb, :controller
alias Towerops.Devices
alias Towerops.Workers.DiscoveryWorker
@doc """
GET /api/v1/devices
@ -82,6 +83,11 @@ defmodule ToweropsWeb.Api.V1.DevicesController do
:ok ->
case Devices.create_device(device_params) do
{:ok, device} ->
# Trigger SNMP discovery if enabled
if device.snmp_enabled do
DiscoveryWorker.enqueue(device.id)
end
conn
|> put_status(:created)
|> json(format_device(device))

View file

@ -45,6 +45,7 @@ defmodule ToweropsWeb.Endpoint do
cookie_key: "request_logger"
plug Plug.RequestId
plug ToweropsWeb.Plugs.RemoteIpLogger
plug Plug.Telemetry,
event_prefix: [:phoenix, :endpoint],

View file

@ -47,6 +47,7 @@ defmodule ToweropsWeb.AgentLive.Index do
|> assign(:agent_tokens, agent_tokens)
|> assign(:cloud_pollers, cloud_pollers)
|> assign(:global_default_cloud_poller_id, global_default_cloud_poller_id)
|> assign(:selected_global_default, global_default_cloud_poller_id || "")
|> assign(:device_counts, equipment_counts)
|> assign(:agent_health_stats, agent_health_stats)
|> assign(:assignment_breakdown, assignment_breakdown)
@ -132,11 +133,26 @@ defmodule ToweropsWeb.AgentLive.Index do
case Agents.delete_agent_token(id) do
{:ok, _} ->
organization = socket.assigns.current_organization
# If the deleted agent was the global default, clear it
if Scope.superuser?(socket.assigns.current_scope) &&
socket.assigns.global_default_cloud_poller_id == id do
Settings.set_global_default_cloud_poller(nil)
end
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
# Refresh cloud pollers list if superadmin (in case a cloud poller was deleted)
cloud_pollers = load_cloud_pollers_if_superuser(socket.assigns.current_scope)
# Reload global default cloud poller ID
global_default_cloud_poller_id =
if Scope.superuser?(socket.assigns.current_scope) do
Settings.get_global_default_cloud_poller()
else
socket.assigns.global_default_cloud_poller_id
end
# Recalculate device counts after deletion
equipment_counts =
Map.new(agent_tokens, fn token ->
@ -154,6 +170,8 @@ defmodule ToweropsWeb.AgentLive.Index do
socket
|> assign(:agent_tokens, agent_tokens)
|> assign(:cloud_pollers, cloud_pollers)
|> assign(:global_default_cloud_poller_id, global_default_cloud_poller_id)
|> assign(:selected_global_default, global_default_cloud_poller_id || "")
|> assign(:device_counts, equipment_counts)
|> assign(:agent_health_stats, agent_health_stats)
|> assign(:assignment_breakdown, assignment_breakdown)
@ -166,16 +184,27 @@ defmodule ToweropsWeb.AgentLive.Index do
end
@impl true
def handle_event("set_global_default", %{"agent_token_id" => agent_token_id}, socket) do
def handle_event("update_selected_global_default", %{"agent_token_id" => agent_token_id}, socket) do
# Just update the selected value in the dropdown, don't save yet
{:noreply, assign(socket, :selected_global_default, agent_token_id)}
end
@impl true
def handle_event("save_global_default", _params, socket) do
current_scope = socket.assigns.current_scope
if Scope.superuser?(current_scope) do
agent_token_id = socket.assigns.selected_global_default
# Handle empty string as nil
agent_token_id = if agent_token_id == "", do: nil, else: agent_token_id
case Settings.set_global_default_cloud_poller(agent_token_id) do
{:ok, _} ->
handle_global_default_success(socket, agent_token_id)
# Validate that the agent exists if not nil
case validate_and_save_global_default(agent_token_id) do
{:ok, validated_id} ->
handle_global_default_success(socket, validated_id)
{:error, :agent_not_found} ->
{:noreply, put_flash(socket, :error, "Selected agent no longer exists. Please choose another agent.")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to update global default cloud poller")}
@ -267,6 +296,27 @@ defmodule ToweropsWeb.AgentLive.Index do
else: "Agent created successfully"
end
defp validate_and_save_global_default(nil) do
# Clearing the default is always valid
case Settings.set_global_default_cloud_poller(nil) do
{:ok, _} -> {:ok, nil}
error -> error
end
end
defp validate_and_save_global_default(agent_token_id) do
# Verify the agent exists before saving
%Agents.AgentToken{} = Agents.get_agent_token!(agent_token_id)
case Settings.set_global_default_cloud_poller(agent_token_id) do
{:ok, _} -> {:ok, agent_token_id}
error -> error
end
rescue
Ecto.NoResultsError ->
{:error, :agent_not_found}
end
defp handle_global_default_success(socket, agent_token_id) do
message =
if agent_token_id,
@ -276,6 +326,7 @@ defmodule ToweropsWeb.AgentLive.Index do
{:noreply,
socket
|> assign(:global_default_cloud_poller_id, agent_token_id)
|> assign(:selected_global_default, agent_token_id || "")
|> put_flash(:info, message)}
end
end

View file

@ -272,44 +272,73 @@
Fallback agent for organizations without a default agent configured. Devices with no assignment at any level will use this agent.
</p>
<.form for={%{}} phx-change="set_global_default" class="max-w-md">
<div class="space-y-2">
<label class="block text-sm font-medium text-amber-900 dark:text-amber-200">
Select Global Default
</label>
<select
name="agent_token_id"
class="block w-full rounded-md border-amber-300 dark:border-amber-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow-sm focus:border-amber-500 focus:ring-amber-500 sm:text-sm"
>
<option value="">No global default (cloud polling only)</option>
<%= for cloud_poller <- @cloud_pollers do %>
<option
value={cloud_poller.id}
selected={cloud_poller.id == @global_default_cloud_poller_id}
>
{cloud_poller.name}
<%= if cloud_poller.id == @global_default_cloud_poller_id do %>
(Current)
<% end %>
</option>
<% end %>
</select>
<.form for={%{}} phx-change="update_selected_global_default" class="max-w-md">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
Select Global Default
</label>
<select
name="agent_token_id"
value={@selected_global_default}
class="block w-full rounded-md border-amber-300 dark:border-amber-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow-sm focus:border-amber-500 focus:ring-amber-500 sm:text-sm"
>
<option value="">No global default (direct Phoenix polling)</option>
<%= for cloud_poller <- @cloud_pollers do %>
<option value={cloud_poller.id}>
{cloud_poller.name}
<%= if cloud_poller.id == @global_default_cloud_poller_id do %>
(Current)
<% end %>
</option>
<% end %>
</select>
</div>
<%= if @global_default_cloud_poller_id do %>
<% selected_poller =
Enum.find(@cloud_pollers, &(&1.id == @global_default_cloud_poller_id)) %>
<%= if selected_poller do %>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">
<p class="text-xs text-amber-600 dark:text-amber-400">
<.icon name="hero-check-circle" class="h-4 w-4 inline" /> Currently using:
<span class="font-medium">{selected_poller.name}</span>
</p>
<% else %>
<p class="text-xs text-red-600 dark:text-red-400">
<.icon name="hero-exclamation-triangle" class="h-4 w-4 inline" />
Warning: Selected agent no longer exists. Please choose a new agent.
</p>
<% end %>
<% else %>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">
<.icon name="hero-exclamation-triangle" class="h-4 w-4 inline" />
No global default configured. Devices without assignments will use cloud polling (nil).
<p class="text-xs text-amber-600 dark:text-amber-400">
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
No global default configured. Devices without assignments will use direct Phoenix cluster polling.
</p>
<% end %>
<div class="flex gap-3">
<.button
type="button"
phx-click="save_global_default"
variant="primary"
disabled={@selected_global_default == (@global_default_cloud_poller_id || "")}
>
<.icon name="hero-check" class="h-4 w-4" /> Save Changes
</.button>
<%= if @selected_global_default != (@global_default_cloud_poller_id || "") do %>
<button
type="button"
phx-click={
JS.push("update_selected_global_default",
value: %{agent_token_id: @global_default_cloud_poller_id || ""}
)
}
class="px-3 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
>
Cancel
</button>
<% end %>
</div>
</div>
</.form>
</div>

View file

@ -247,6 +247,15 @@ defmodule ToweropsWeb.DeviceLive.Show do
source: source,
last_seen_at: agent_token.last_seen_at
}
rescue
Ecto.NoResultsError ->
# Agent token no longer exists, fall back to cloud polling display
%{
type: :cloud,
name: "Cloud Polling (agent not found)",
source: nil,
last_seen_at: nil
}
end
defp calculate_metrics(checks, _equipment) do

View file

@ -0,0 +1,58 @@
defmodule ToweropsWeb.Plugs.RemoteIpLogger do
@moduledoc """
Plug that extracts the remote IP address and adds it to Logger metadata.
When behind a proxy/load balancer (like Traefik in Kubernetes), the real client IP
is in the X-Forwarded-For or X-Real-IP headers. This plug extracts the IP and
adds it to Logger metadata so it appears in all logs for the request.
"""
import Plug.Conn
require Logger
def init(opts), do: opts
def call(conn, _opts) do
remote_ip = get_remote_ip(conn)
# Add to Logger metadata so it appears in all logs for this request
Logger.metadata(remote_ip: remote_ip)
conn
end
defp get_remote_ip(conn) do
# Try X-Forwarded-For first (set by Traefik and other proxies)
case get_req_header(conn, "x-forwarded-for") do
[forwarded | _] ->
# X-Forwarded-For can be a comma-separated list; take the first (original client)
forwarded
|> String.split(",")
|> List.first()
|> String.trim()
[] ->
get_fallback_ip(conn)
end
end
defp get_fallback_ip(conn) do
# Try X-Real-IP header
case get_req_header(conn, "x-real-ip") do
[real_ip | _] ->
real_ip
[] ->
format_remote_ip(conn.remote_ip)
end
end
defp format_remote_ip({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}"
defp format_remote_ip({a, b, c, d, e, f, g, h}), do: format_ipv6({a, b, c, d, e, f, g, h})
defp format_remote_ip(_), do: "unknown"
defp format_ipv6({a, b, c, d, e, f, g, h}) do
Enum.map_join([a, b, c, d, e, f, g, h], ":", &Integer.to_string(&1, 16))
end
end

View file

@ -0,0 +1,9 @@
defmodule Towerops.Repo.Migrations.SetSnmpEnabledDefaultTrue do
use Ecto.Migration
def change do
alter table(:devices) do
modify :snmp_enabled, :boolean, default: true, null: false
end
end
end

View file

@ -0,0 +1,9 @@
defmodule Towerops.Repo.Migrations.AddUniqueSiteNamePerOrg do
use Ecto.Migration
def change do
create unique_index(:sites, [:organization_id, :name],
name: :sites_organization_id_name_index
)
end
end

View file

@ -778,6 +778,229 @@ defmodule Towerops.EquipmentTest do
end
end
describe "automatic rediscovery on device updates" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site, user: user}
end
test "triggers discovery when snmp_enabled changes from false to true", %{site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: false
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Update to enable SNMP
{:ok, _updated} = Devices.update_device(device, %{snmp_enabled: true})
# Check that a discovery job was enqueued
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [job] = jobs
assert job.args["device_id"] == device.id
end
test "triggers discovery when snmp_version changes while enabled", %{site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: true,
snmp_version: "2c"
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Update SNMP version
{:ok, _updated} = Devices.update_device(device, %{snmp_version: "1"})
# Check that a discovery job was enqueued
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [job] = jobs
assert job.args["device_id"] == device.id
end
test "triggers discovery when snmp_port changes while enabled", %{site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: true,
snmp_port: 161
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Update SNMP port
{:ok, _updated} = Devices.update_device(device, %{snmp_port: 1161})
# Check that a discovery job was enqueued
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [job] = jobs
assert job.args["device_id"] == device.id
end
test "triggers discovery when monitoring_enabled changes to true with SNMP enabled", %{
site: site
} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: true,
monitoring_enabled: false
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Enable monitoring
{:ok, _updated} = Devices.update_device(device, %{monitoring_enabled: true})
# Check that a discovery job was enqueued
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [job] = jobs
assert job.args["device_id"] == device.id
end
test "does not trigger discovery when monitoring_enabled changes but SNMP disabled", %{
site: site
} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: false,
monitoring_enabled: false
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Enable monitoring (but SNMP is disabled)
{:ok, _updated} = Devices.update_device(device, %{monitoring_enabled: true})
# Check that NO discovery job was enqueued
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [] = jobs
end
test "does not trigger discovery when SNMP is disabled", %{site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: true,
snmp_version: "2c"
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Disable SNMP
{:ok, _updated} = Devices.update_device(device, %{snmp_enabled: false})
# Check that NO discovery job was enqueued (SNMP is disabled)
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [] = jobs
end
test "does not trigger discovery for unrelated field changes", %{site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: true
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Update unrelated fields (name, description)
{:ok, _updated} = Devices.update_device(device, %{name: "Updated Router"})
# Check that NO discovery job was enqueued
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [] = jobs
end
test "does not trigger discovery when snmp_version changes while disabled", %{site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
snmp_enabled: false,
snmp_version: "2c"
})
# Clear any existing jobs
Towerops.Repo.delete_all(Oban.Job)
# Update SNMP version (but SNMP is disabled)
{:ok, _updated} = Devices.update_device(device, %{snmp_version: "1"})
# Check that NO discovery job was enqueued (SNMP is disabled)
jobs =
Oban.Job
|> Towerops.Repo.all()
|> Enum.filter(&(&1.worker == "Towerops.Workers.DiscoveryWorker"))
assert [] = jobs
end
end
describe "property-based tests" do
import Towerops.AccountsFixtures

View file

@ -162,6 +162,35 @@ defmodule Towerops.SitesTest do
assert "must be 1, 2c, or 3" in errors_on(changeset).snmp_version
end
test "create_site/1 prevents duplicate site names within same organization", %{
organization: organization
} do
# Create first site
{:ok, _site} = Sites.create_site(%{name: "Duplicate Site", organization_id: organization.id})
# Try to create second site with same name in same organization
assert {:error, changeset} =
Sites.create_site(%{name: "Duplicate Site", organization_id: organization.id})
assert "has already been taken for this organization" in errors_on(changeset).name
end
test "create_site/1 allows duplicate site names across different organizations" do
user1 = user_fixture()
user2 = user_fixture()
{:ok, org1} = Towerops.Organizations.create_organization(%{name: "Org 1"}, user1.id)
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, user2.id)
# Create site in first org
assert {:ok, site1} = Sites.create_site(%{name: "Main Office", organization_id: org1.id})
# Should be able to create site with same name in different org
assert {:ok, site2} = Sites.create_site(%{name: "Main Office", organization_id: org2.id})
assert site1.name == site2.name
assert site1.organization_id != site2.organization_id
end
test "create_site/1 prevents circular parent reference", %{organization: organization} do
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id})

View file

@ -22,7 +22,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
conn =
conn
|> log_in_user(user)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, _view, html} = live(conn)
@ -31,19 +31,19 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
assert html =~ agent_token2.name
end
test "shows empty state when no agents exist", %{conn: conn, user: user, organization: organization} do
test "shows empty state when no agents exist", %{conn: conn, user: user, organization: _organization} do
conn =
conn
|> log_in_user(user)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, _view, html} = live(conn)
assert html =~ "Remote Agents"
end
test "requires authentication", %{conn: conn, organization: organization} do
conn = get(conn, "/orgs/#{organization.slug}/agents")
test "requires authentication", %{conn: conn} do
conn = get(conn, "/agents")
assert redirected_to(conn) == ~p"/users/log-in"
end
end
@ -53,7 +53,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
conn =
conn
|> log_in_user(user)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, view, _html} = live(conn)
@ -69,11 +69,11 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
assert hd(agent_tokens).name == "New Agent"
end
test "creates agent with flat params format", %{conn: conn, user: user, organization: organization} do
test "creates agent with flat params format", %{conn: conn, user: user, organization: _organization} do
conn =
conn
|> log_in_user(user)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, view, _html} = live(conn)
@ -84,11 +84,11 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
assert html =~ "Flat Agent"
end
test "shows error on creation failure", %{conn: conn, user: user, organization: organization} do
test "shows error on creation failure", %{conn: conn, user: user, organization: _organization} do
conn =
conn
|> log_in_user(user)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, view, _html} = live(conn)
@ -100,11 +100,11 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
end
describe "close_token_modal event" do
test "closes the token modal", %{conn: conn, user: user, organization: organization} do
test "closes the token modal", %{conn: conn, user: user, organization: _organization} do
conn =
conn
|> log_in_user(user)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, view, _html} = live(conn)
@ -126,7 +126,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
conn =
conn
|> log_in_user(user)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, view, _html} = live(conn)
@ -144,7 +144,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
test "shows Never connected for agents without last_seen_at", %{
conn: conn,
user: user,
organization: organization
organization: _organization
} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Never Seen")
@ -154,7 +154,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
conn =
conn
|> log_in_user(user)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, _view, html} = live(conn)
@ -165,7 +165,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
test "shows agent status information in the page", %{
conn: conn,
user: user,
organization: organization
organization: _organization
} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Status Agent")
@ -177,7 +177,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
conn =
conn
|> log_in_user(user)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, _view, html} = live(conn)
@ -188,11 +188,11 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
end
describe "handle_params" do
test "handles :index action", %{conn: conn, user: user, organization: organization} do
test "handles :index action", %{conn: conn, user: user, organization: _organization} do
conn =
conn
|> log_in_user(user)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, view, _html} = live(conn)
@ -202,7 +202,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
end
describe "cloud poller creation - superadmin only" do
setup %{user: _user, organization: organization} do
setup %{organization: organization} do
# Create a superadmin user
superadmin = user_fixture()
@ -225,12 +225,12 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
test "superadmin can create cloud poller", %{
conn: conn,
superadmin: superadmin,
organization: organization
organization: _organization
} do
conn =
conn
|> log_in_user(superadmin)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, view, html} = live(conn)
@ -257,12 +257,12 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
test "non-superadmin cannot create cloud poller", %{
conn: conn,
user: user,
organization: organization
organization: _organization
} do
conn =
conn
|> log_in_user(user)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, view, html} = live(conn)
@ -289,12 +289,12 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
test "superadmin can create regular agent by not checking cloud poller", %{
conn: conn,
superadmin: superadmin,
organization: organization
organization: _organization
} do
conn =
conn
|> log_in_user(superadmin)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, view, _html} = live(conn)
@ -321,7 +321,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
end
describe "cloud poller visibility" do
setup %{user: _user, organization: organization} do
setup %{organization: organization} do
# Create a superadmin user
superadmin = user_fixture()
@ -351,7 +351,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
test "superadmin sees cloud pollers in separate section", %{
conn: conn,
superadmin: superadmin,
organization: organization,
organization: _organization,
cloud1: cloud1,
cloud2: cloud2,
org_agent: org_agent
@ -359,7 +359,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
conn =
conn
|> log_in_user(superadmin)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, _view, html} = live(conn)
@ -380,7 +380,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
test "non-superadmin does not see cloud pollers section", %{
conn: conn,
user: user,
organization: organization,
organization: _organization,
cloud1: cloud1,
cloud2: cloud2,
org_agent: org_agent
@ -388,7 +388,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
conn =
conn
|> log_in_user(user)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, _view, html} = live(conn)
@ -436,7 +436,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
conn =
conn
|> log_in_user(superadmin)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, view, _html} = live(conn)
@ -458,7 +458,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
conn =
conn
|> log_in_user(superadmin)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, view, _html} = live(conn)
@ -477,7 +477,7 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
conn =
conn
|> log_in_user(superadmin)
|> get("/orgs/#{organization.slug}/agents")
|> get("/agents")
{:ok, view, _html} = live(conn)
@ -488,4 +488,159 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
assert html =~ cloud_poller.token or html =~ "docker run" or html =~ "Setup"
end
end
describe "global default cloud poller management" do
setup %{organization: organization} do
# Create a superadmin user
superadmin = user_fixture()
superadmin =
superadmin
|> Ecto.Changeset.change(%{is_superuser: true})
|> Towerops.Repo.update!()
# Add superadmin to the organization
{:ok, _membership} =
Towerops.Organizations.create_membership(%{
organization_id: organization.id,
user_id: superadmin.id,
role: "admin"
})
{:ok, cloud_poller, _} = Agents.create_cloud_poller("Test Cloud Poller")
%{superadmin: superadmin, cloud_poller: cloud_poller}
end
test "superadmin can set global default cloud poller with validation", %{
conn: conn,
superadmin: superadmin,
organization: organization,
cloud_poller: cloud_poller
} do
conn =
conn
|> log_in_user(superadmin)
|> get("/agents")
{:ok, view, html} = live(conn)
# Should see global default section
assert html =~ "Global Default Cloud Poller"
# Update dropdown selection (doesn't save yet)
render_change(view, "update_selected_global_default", %{
"agent_token_id" => cloud_poller.id
})
# Click save button
html = render_click(view, "save_global_default", %{})
assert html =~ "Global default cloud poller set successfully"
# Verify it was saved
assert Towerops.Settings.get_global_default_cloud_poller() == cloud_poller.id
end
test "superadmin cannot save non-existent agent as global default", %{
conn: conn,
superadmin: superadmin,
organization: _organization
} do
conn =
conn
|> log_in_user(superadmin)
|> get("/agents")
{:ok, view, _html} = live(conn)
fake_id = Ecto.UUID.generate()
# Update dropdown to fake ID
render_change(view, "update_selected_global_default", %{"agent_token_id" => fake_id})
# Attempt to save
html = render_click(view, "save_global_default", %{})
assert html =~ "Selected agent no longer exists"
# Verify it was NOT saved
refute Towerops.Settings.get_global_default_cloud_poller() == fake_id
end
test "superadmin can clear global default cloud poller", %{
conn: conn,
superadmin: superadmin,
organization: organization,
cloud_poller: cloud_poller
} do
# First set a global default
Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id)
conn =
conn
|> log_in_user(superadmin)
|> get("/agents")
{:ok, view, html} = live(conn)
# Should show current default
assert html =~ "Currently using"
# Select "No global default"
render_change(view, "update_selected_global_default", %{"agent_token_id" => ""})
# Save
html = render_click(view, "save_global_default", %{})
assert html =~ "Global default cloud poller cleared"
# Verify it was cleared
assert Towerops.Settings.get_global_default_cloud_poller() == nil
end
test "deleting cloud poller clears it from global default", %{
conn: conn,
superadmin: superadmin,
organization: organization,
cloud_poller: cloud_poller
} do
# Set as global default
Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id)
conn =
conn
|> log_in_user(superadmin)
|> get("/agents")
{:ok, view, _html} = live(conn)
# Delete the cloud poller
html = render_click(view, "delete_agent", %{"id" => cloud_poller.id})
assert html =~ "Agent deleted successfully"
# Verify global default was cleared (CASCADE)
assert Towerops.Settings.get_global_default_cloud_poller() == nil
# Verify the UI reflects this
assert html =~ "No global default" or html =~ "direct Phoenix"
end
test "non-superadmin cannot see global default section", %{
conn: conn,
user: user,
organization: _organization
} do
conn =
conn
|> log_in_user(user)
|> get("/agents")
{:ok, _view, html} = live(conn)
# Should NOT see global default section
refute html =~ "Global Default Cloud Poller"
end
end
end