diff --git a/lib/towerops/agents.ex b/lib/towerops/agents.ex
index 06b79e8c..d9ee78a1 100644
--- a/lib/towerops/agents.ex
+++ b/lib/towerops/agents.ex
@@ -39,10 +39,35 @@ defmodule Towerops.Agents do
end
end
+ @doc """
+ Creates a new cloud poller agent token (superadmin only).
+
+ Cloud pollers are application-wide agents that are not tied to any organization
+ and can poll devices for any organization.
+
+ Returns {:ok, agent_token, token_string} where token_string is the plain token
+ that should be shown to the user ONCE and never stored.
+
+ ## Examples
+
+ iex> create_cloud_poller("Cloud Poller 1")
+ {:ok, %AgentToken{is_cloud_poller: true}, "abc123..."}
+
+ """
+ def create_cloud_poller(name) do
+ {token, changeset} = AgentToken.build_token(nil, name, is_cloud_poller: true)
+
+ case Repo.insert(changeset) do
+ {:ok, agent_token} -> {:ok, agent_token, token}
+ {:error, changeset} -> {:error, changeset}
+ end
+ end
+
@doc """
Lists all agent tokens for an organization.
Returns a list of agent tokens ordered by creation date (newest first).
+ Does not include cloud pollers.
## Examples
@@ -52,7 +77,25 @@ defmodule Towerops.Agents do
"""
def list_organization_agent_tokens(organization_id) do
AgentToken
- |> where([t], t.organization_id == ^organization_id)
+ |> where([t], t.organization_id == ^organization_id and t.is_cloud_poller == false)
+ |> order_by([t], desc: t.inserted_at)
+ |> Repo.all()
+ end
+
+ @doc """
+ Lists all cloud poller agent tokens (superadmin only).
+
+ Returns a list of cloud poller agent tokens ordered by creation date (newest first).
+
+ ## Examples
+
+ iex> list_cloud_pollers()
+ [%AgentToken{is_cloud_poller: true}, ...]
+
+ """
+ def list_cloud_pollers do
+ AgentToken
+ |> where([t], t.is_cloud_poller == true)
|> order_by([t], desc: t.inserted_at)
|> Repo.all()
end
diff --git a/lib/towerops/agents/agent_token.ex b/lib/towerops/agents/agent_token.ex
index 59159f1b..e0d1b913 100644
--- a/lib/towerops/agents/agent_token.ex
+++ b/lib/towerops/agents/agent_token.ex
@@ -23,6 +23,7 @@ defmodule Towerops.Agents.AgentToken do
field :last_seen_at, :utc_datetime
field :last_ip, :string
field :enabled, :boolean, default: true
+ field :is_cloud_poller, :boolean, default: false
field :metadata, :map, default: %{}
belongs_to :organization, Organization
@@ -37,8 +38,9 @@ defmodule Towerops.Agents.AgentToken do
last_seen_at: DateTime.t() | nil,
last_ip: String.t() | nil,
enabled: boolean(),
+ is_cloud_poller: boolean(),
metadata: map(),
- organization_id: Ecto.UUID.t(),
+ organization_id: Ecto.UUID.t() | nil,
organization: Ecto.Association.NotLoaded.t() | Organization.t(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
@@ -50,6 +52,8 @@ defmodule Towerops.Agents.AgentToken do
Returns a tuple of {token_string, changeset}. The token_string is stored in the database
and can be retrieved later for display to authorized users.
+ For cloud pollers, pass `is_cloud_poller: true` in opts and organization_id will be nil.
+
## Examples
iex> {token, changeset} = AgentToken.build_token(org_id, "My Agent")
@@ -58,28 +62,51 @@ defmodule Towerops.Agents.AgentToken do
iex> byte_size(Base.url_decode64!(token, padding: false))
48
+ iex> {token, changeset} = AgentToken.build_token(nil, "Cloud Poller", is_cloud_poller: true)
+ iex> changeset.changes.is_cloud_poller
+ true
+
"""
- def build_token(organization_id, name) do
+ def build_token(organization_id, name, opts \\ []) do
+ is_cloud_poller = Keyword.get(opts, :is_cloud_poller, false)
+
# Generate 48 bytes (384 bits) of cryptographically secure random data
token = :crypto.strong_rand_bytes(@rand_size)
encoded_token = Base.url_encode64(token, padding: false)
+ attrs = %{
+ organization_id: organization_id,
+ name: name,
+ token: encoded_token,
+ is_cloud_poller: is_cloud_poller
+ }
+
changeset =
%__MODULE__{}
- |> cast(
- %{
- organization_id: organization_id,
- name: name,
- token: encoded_token
- },
- [:organization_id, :name, :token]
- )
- |> validate_required([:organization_id, :name, :token])
+ |> cast(attrs, [:organization_id, :name, :token, :is_cloud_poller])
+ |> validate_cloud_poller_constraints()
+ |> validate_required([:name, :token])
|> unique_constraint(:token)
{encoded_token, changeset}
end
+ defp validate_cloud_poller_constraints(changeset) do
+ is_cloud_poller = get_field(changeset, :is_cloud_poller)
+ organization_id = get_field(changeset, :organization_id)
+
+ cond do
+ is_cloud_poller && organization_id != nil ->
+ add_error(changeset, :organization_id, "must be nil for cloud pollers")
+
+ !is_cloud_poller && organization_id == nil ->
+ add_error(changeset, :organization_id, "can't be blank for non-cloud pollers")
+
+ true ->
+ changeset
+ end
+ end
+
@doc """
Changeset for updating an agent token.
diff --git a/lib/towerops_web/live/agent_live/index.ex b/lib/towerops_web/live/agent_live/index.ex
index 5dd8a17c..0258c92a 100644
--- a/lib/towerops_web/live/agent_live/index.ex
+++ b/lib/towerops_web/live/agent_live/index.ex
@@ -10,8 +10,17 @@ defmodule ToweropsWeb.AgentLive.Index do
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
+ current_user = socket.assigns.current_scope.user
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
+ # If superadmin, also load cloud pollers
+ cloud_pollers =
+ if current_user.is_superuser do
+ Agents.list_cloud_pollers()
+ else
+ []
+ end
+
# device counts for each agent (both direct and total with inheritance)
equipment_counts =
Map.new(agent_tokens, fn token ->
@@ -32,6 +41,7 @@ defmodule ToweropsWeb.AgentLive.Index do
socket
|> assign(:page_title, "Remote Agents")
|> assign(:agent_tokens, agent_tokens)
+ |> assign(:cloud_pollers, cloud_pollers)
|> assign(:device_counts, equipment_counts)
|> assign(:agent_health_stats, agent_health_stats)
|> assign(:assignment_breakdown, assignment_breakdown)
@@ -39,7 +49,7 @@ defmodule ToweropsWeb.AgentLive.Index do
|> assign(:agent_image, agent_image)
|> assign(:new_token, nil)
|> assign(:show_token_modal, false)
- |> assign(:agent_form, to_form(%{"name" => ""}))}
+ |> assign(:agent_form, to_form(%{"name" => "", "is_cloud_poller" => false}))}
end
@impl true
@@ -49,45 +59,20 @@ defmodule ToweropsWeb.AgentLive.Index do
@impl true
def handle_event("create_agent", params, socket) do
- name =
- case params do
- %{"agent_form" => %{"name" => n}} -> n
- %{"name" => n} -> n
- end
+ {name, is_cloud_poller} = parse_agent_params(params)
- organization = socket.assigns.current_organization
-
- case Agents.create_agent_token(organization.id, name) do
- {:ok, agent_token, token} ->
- agent_tokens = Agents.list_organization_agent_tokens(organization.id)
-
- # device counts
- equipment_counts =
- Map.new(agent_tokens, fn t ->
- direct = Agents.count_assigned_devices(t.id)
- total = length(Agents.list_agent_polling_targets(t.id))
- {t.id, %{direct: direct, total: total}}
- end)
-
- # Refresh health statistics
- agent_health_stats = Stats.get_organization_agent_health(organization.id)
- assignment_breakdown = Stats.get_device_assignment_breakdown(organization.id)
- offline_agents = Stats.get_offline_agents(organization.id)
-
- {:noreply,
- socket
- |> assign(:agent_tokens, agent_tokens)
- |> assign(:device_counts, equipment_counts)
- |> assign(:agent_health_stats, agent_health_stats)
- |> assign(:assignment_breakdown, assignment_breakdown)
- |> assign(:offline_agents, offline_agents)
- |> assign(:new_token, %{agent_token: agent_token, token: token})
- |> assign(:show_token_modal, true)
- |> assign(:agent_form, to_form(%{"name" => ""}))
- |> put_flash(:info, "Agent created successfully")}
+ with :ok <- validate_cloud_poller_permission(socket.assigns.current_scope.user, is_cloud_poller),
+ {:ok, agent_token, token} <- create_agent(socket.assigns.current_organization, name, is_cloud_poller) do
+ handle_agent_creation_success(socket, agent_token, token, is_cloud_poller)
+ else
+ {:error, :unauthorized} ->
+ {:noreply, put_flash(socket, :error, "Only superadmins can create cloud pollers")}
{:error, _changeset} ->
- {:noreply, put_flash(socket, :error, "Failed to create agent")}
+ error_message =
+ if is_cloud_poller, do: "Failed to create cloud poller", else: "Failed to create agent"
+
+ {:noreply, put_flash(socket, :error, error_message)}
end
end
@@ -174,4 +159,79 @@ defmodule ToweropsWeb.AgentLive.Index do
defp apply_action(socket, :index, _params) do
socket
end
+
+ defp parse_agent_params(params) do
+ case params do
+ %{"agent_form" => %{"name" => n, "is_cloud_poller" => cp}} ->
+ {n, cp == "true" || cp == true}
+
+ %{"agent_form" => %{"name" => n}} ->
+ {n, false}
+
+ %{"name" => n} ->
+ {n, false}
+ end
+ end
+
+ defp validate_cloud_poller_permission(user, is_cloud_poller) do
+ if is_cloud_poller && !user.is_superuser do
+ {:error, :unauthorized}
+ else
+ :ok
+ end
+ end
+
+ defp create_agent(organization, name, is_cloud_poller) do
+ if is_cloud_poller do
+ Agents.create_cloud_poller(name)
+ else
+ Agents.create_agent_token(organization.id, name)
+ end
+ end
+
+ defp handle_agent_creation_success(socket, agent_token, token, is_cloud_poller) do
+ organization = socket.assigns.current_organization
+ agent_tokens = Agents.list_organization_agent_tokens(organization.id)
+
+ cloud_pollers = load_cloud_pollers_if_superuser(socket.assigns.current_scope.user)
+
+ equipment_counts = calculate_device_counts(agent_tokens)
+
+ agent_health_stats = Stats.get_organization_agent_health(organization.id)
+ assignment_breakdown = Stats.get_device_assignment_breakdown(organization.id)
+ offline_agents = Stats.get_offline_agents(organization.id)
+
+ success_message = get_success_message(is_cloud_poller)
+
+ {:noreply,
+ socket
+ |> assign(:agent_tokens, agent_tokens)
+ |> assign(:cloud_pollers, cloud_pollers)
+ |> assign(:device_counts, equipment_counts)
+ |> assign(:agent_health_stats, agent_health_stats)
+ |> assign(:assignment_breakdown, assignment_breakdown)
+ |> assign(:offline_agents, offline_agents)
+ |> assign(:new_token, %{agent_token: agent_token, token: token})
+ |> assign(:show_token_modal, true)
+ |> assign(:agent_form, to_form(%{"name" => "", "is_cloud_poller" => false}))
+ |> put_flash(:info, success_message)}
+ end
+
+ defp load_cloud_pollers_if_superuser(user) do
+ if user.is_superuser, do: Agents.list_cloud_pollers(), else: []
+ end
+
+ defp calculate_device_counts(agent_tokens) do
+ Map.new(agent_tokens, fn t ->
+ direct = Agents.count_assigned_devices(t.id)
+ total = length(Agents.list_agent_polling_targets(t.id))
+ {t.id, %{direct: direct, total: total}}
+ end)
+ end
+
+ defp get_success_message(is_cloud_poller) do
+ if is_cloud_poller,
+ do: "Cloud poller created successfully",
+ else: "Agent created successfully"
+ end
end
diff --git a/lib/towerops_web/live/agent_live/index.html.heex b/lib/towerops_web/live/agent_live/index.html.heex
index 33376744..39e41b49 100644
--- a/lib/towerops_web/live/agent_live/index.html.heex
+++ b/lib/towerops_web/live/agent_live/index.html.heex
@@ -30,6 +30,13 @@
placeholder="e.g., Datacenter A"
required
/>
+ <%= if @current_scope.user.is_superuser do %>
+ <.input
+ field={@agent_form[:is_cloud_poller]}
+ type="checkbox"
+ label="Cloud Poller (Application-wide agent, not tied to this organization)"
+ />
+ <% end %>
<.button type="submit" variant="primary">
Create Agent
@@ -157,6 +164,103 @@
<% end %>
+
+ <%!-- Cloud Pollers Section (Superadmin Only) --%>
+ <%= if @current_scope.user.is_superuser && @cloud_pollers != [] do %>
+
+
+ Cloud Pollers
+
+
+ Application-wide agents that can poll devices for any organization. Only visible and manageable by superadmins.
+
+ <.table id="cloud-pollers-table" rows={@cloud_pollers}>
+ <:col :let={agent} label="Name">
+
+ <.link
+ navigate={~p"/orgs/#{@current_organization.slug}/agents/#{agent.id}"}
+ class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
+ >
+ {agent.name}
+
+
+ Cloud Poller
+
+
+ <%= if agent.metadata["version"] do %>
+
+ v{agent.metadata["version"]}
+
+ <% end %>
+
+
+ <:col :let={agent} label="Status">
+ <% {status, label} = agent_status(agent) %>
+
+
+ {label}
+
+
+
+ <:col :let={agent} label="Last Seen">
+
+ <.timestamp datetime={agent.last_seen_at} timezone={@timezone} />
+
+ <%= if agent.last_seen_at do %>
+
+ {format_datetime(agent.last_seen_at, @timezone)}
+
+ <% end %>
+ <%= if agent.last_ip do %>
+
+ {agent.last_ip}
+
+ <% end %>
+
+
+ <:col :let={agent} label="Metadata">
+ <%= if agent.metadata["hostname"] do %>
+
+ <.icon name="hero-computer-desktop" class="h-4 w-4 inline" />
+ {agent.metadata["hostname"]}
+
+ <% end %>
+
+
+ <:col :let={agent} label="Created">
+
+ {ToweropsWeb.TimeHelpers.format_date(agent.inserted_at, @timezone)}
+
+
+
+ <:action :let={agent}>
+ <%= if agent.enabled do %>
+
+
+
+
+ <% else %>
+
Disabled
+ <% end %>
+
+
+
+ <% end %>
<%= if @show_token_modal && @new_token do %>
diff --git a/lib/towerops_web/live/device_live/form.ex b/lib/towerops_web/live/device_live/form.ex
index fc1eb2bf..4cdffff7 100644
--- a/lib/towerops_web/live/device_live/form.ex
+++ b/lib/towerops_web/live/device_live/form.ex
@@ -15,6 +15,10 @@ defmodule ToweropsWeb.DeviceLive.Form do
sites = Sites.list_organization_sites(organization.id)
agents = Agents.list_organization_agent_tokens(organization.id)
+ # Include cloud pollers in the available agents list
+ cloud_pollers = Agents.list_cloud_pollers()
+ all_agents = agents ++ cloud_pollers
+
# Redirect to sites page if no sites exist
if Enum.empty?(sites) do
{:ok,
@@ -26,7 +30,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
socket
|> assign(:organization, organization)
|> assign(:available_sites, sites)
- |> assign(:available_agents, agents)
+ |> assign(:available_agents, all_agents)
|> assign(:preselected_site_id, params["site_id"])
|> assign(:snmp_test_result, nil)
|> assign(:duplicate_device, nil)
diff --git a/lib/towerops_web/live/device_live/form.html.heex b/lib/towerops_web/live/device_live/form.html.heex
index 47a4112c..ac7b6aff 100644
--- a/lib/towerops_web/live/device_live/form.html.heex
+++ b/lib/towerops_web/live/device_live/form.html.heex
@@ -112,7 +112,16 @@
type="select"
label="Remote Agent"
prompt="Inherit from site/organization"
- options={Enum.map(@available_agents, &{&1.name, &1.id})}
+ options={
+ Enum.map(@available_agents, fn agent ->
+ label =
+ if agent.is_cloud_poller,
+ do: "#{agent.name} (Cloud Poller)",
+ else: agent.name
+
+ {label, agent.id}
+ end)
+ }
/>
<%= if @live_action == :edit and Map.has_key?(assigns, :agent_source) do %>
<%= case @agent_source do %>
diff --git a/mix.lock b/mix.lock
index 0e965e84..1de8fcde 100644
--- a/mix.lock
+++ b/mix.lock
@@ -9,7 +9,6 @@
"credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
- "delta_crdt": {:hex, :delta_crdt, "0.6.5", "c7bb8c2c7e60f59e46557ab4e0224f67ba22f04c02826e273738f3dcc4767adc", [:mix], [{:merkle_map, "~> 0.2.0", [hex: :merkle_map, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c6ae23a525d30f96494186dd11bf19ed9ae21d9fe2c1f1b217d492a7cc7294ae"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
@@ -30,14 +29,11 @@
"gun": {:hex, :gun, "2.1.0", "b4e4cbbf3026d21981c447e9e7ca856766046eff693720ba43114d7f5de36e87", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "52fc7fc246bfc3b00e01aea1c2854c70a366348574ab50c57dfe796d24a0101d"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"honeybadger": {:hex, :honeybadger, "0.24.1", "13ffe56b4d148649c8fbb0e091fefecc5d8e8eb7ade684b6900085a947d741d5", [:mix], [{:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:hackney, "~> 1.1", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.0.0 and < 2.0.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.0.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:process_tree, "~> 0.2.1", [hex: :process_tree, repo: "hexpm", optional: false]}, {:req, "~> 0.5.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0c97d5a82c42298b9935dbc0a7e3c14372c8f55257f603828258ef9f7e0da892"},
- "horde": {:hex, :horde, "0.10.0", "31c6a633057c3ec4e73064d7b11ba409c9f3c518aa185377d76bee441b76ceb0", [:mix], [{:delta_crdt, "~> 0.6.2", [hex: :delta_crdt, repo: "hexpm", optional: false]}, {:libring, "~> 1.7", [hex: :libring, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 0.5.0 or ~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "0b51c435cb698cac9bf9c17391dce3ebb1376ae6154c81f077fc61db771b9432"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"},
- "libring": {:hex, :libring, "1.7.0", "4f245d2f1476cd7ed8f03740f6431acba815401e40299208c7f5c640e1883bda", [:mix], [], "hexpm", "070e3593cb572e04f2c8470dd0c119bc1817a7a0a7f88229f43cf0345268ec42"},
- "merkle_map": {:hex, :merkle_map, "0.2.2", "f36ff730cca1f2658e317a3c73406f50bbf5ac8aff54cf837d7ca2069a6e251c", [:mix], [], "hexpm", "383107f0503f230ac9175e0631647c424efd027e89ea65ab5ea12eeb54257aaf"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
diff --git a/priv/repo/migrations/20260123234053_add_is_cloud_poller_to_agent_tokens.exs b/priv/repo/migrations/20260123234053_add_is_cloud_poller_to_agent_tokens.exs
new file mode 100644
index 00000000..35135fcf
--- /dev/null
+++ b/priv/repo/migrations/20260123234053_add_is_cloud_poller_to_agent_tokens.exs
@@ -0,0 +1,21 @@
+defmodule Towerops.Repo.Migrations.AddIsCloudPollerToAgentTokens do
+ use Ecto.Migration
+
+ def change do
+ alter table(:agent_tokens) do
+ add :is_cloud_poller, :boolean, default: false, null: false
+ end
+
+ create index(:agent_tokens, [:is_cloud_poller])
+
+ # Make organization_id nullable for cloud pollers
+ execute "ALTER TABLE agent_tokens ALTER COLUMN organization_id DROP NOT NULL",
+ "ALTER TABLE agent_tokens ALTER COLUMN organization_id SET NOT NULL"
+
+ # Add check constraint: cloud pollers must have null organization_id
+ create constraint(:agent_tokens, :cloud_poller_no_org,
+ check:
+ "(is_cloud_poller = false AND organization_id IS NOT NULL) OR (is_cloud_poller = true AND organization_id IS NULL)"
+ )
+ end
+end
diff --git a/test/towerops/agents_test.exs b/test/towerops/agents_test.exs
index 4d0597d0..78b956db 100644
--- a/test/towerops/agents_test.exs
+++ b/test/towerops/agents_test.exs
@@ -1154,4 +1154,228 @@ defmodule Towerops.AgentsTest do
assert Ecto.assoc_loaded?(device.site.organization)
end
end
+
+ describe "create_cloud_poller/1" do
+ test "creates cloud poller with no organization" do
+ assert {:ok, agent_token, token} = Agents.create_cloud_poller("Cloud Poller 1")
+ assert is_binary(token)
+ assert byte_size(token) > 0
+ assert agent_token.organization_id == nil
+ assert agent_token.name == "Cloud Poller 1"
+ assert agent_token.enabled == true
+ assert agent_token.is_cloud_poller == true
+ assert is_binary(agent_token.token)
+ assert agent_token.token == token
+ end
+
+ test "returns error with empty name" do
+ assert {:error, changeset} = Agents.create_cloud_poller("")
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).name
+ end
+ end
+
+ describe "list_cloud_pollers/0" do
+ test "returns all cloud pollers ordered by created date" do
+ {:ok, cloud1, _} = Agents.create_cloud_poller("Cloud Poller 1")
+ {:ok, cloud2, _} = Agents.create_cloud_poller("Cloud Poller 2")
+
+ cloud_pollers = Agents.list_cloud_pollers()
+
+ assert length(cloud_pollers) == 2
+ # Verify both cloud pollers are in the list
+ cloud_ids = Enum.map(cloud_pollers, & &1.id)
+ assert cloud1.id in cloud_ids
+ assert cloud2.id in cloud_ids
+ end
+
+ test "returns empty list when no cloud pollers exist" do
+ assert Agents.list_cloud_pollers() == []
+ end
+
+ test "does not include organization agents" do
+ user = user_fixture()
+ {:ok, org} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
+ {:ok, _org_agent, _} = Agents.create_agent_token(org.id, "Org Agent")
+ {:ok, cloud_agent, _} = Agents.create_cloud_poller("Cloud Poller")
+
+ cloud_pollers = Agents.list_cloud_pollers()
+
+ assert length(cloud_pollers) == 1
+ assert hd(cloud_pollers).id == cloud_agent.id
+ assert hd(cloud_pollers).is_cloud_poller == true
+ end
+ end
+
+ describe "list_organization_agent_tokens/1 - cloud poller exclusion" do
+ test "excludes cloud pollers from organization agents", %{organization: org} do
+ {:ok, org_agent1, _} = Agents.create_agent_token(org.id, "Org Agent 1")
+ {:ok, org_agent2, _} = Agents.create_agent_token(org.id, "Org Agent 2")
+ {:ok, _cloud_agent, _} = Agents.create_cloud_poller("Cloud Poller")
+
+ org_agents = Agents.list_organization_agent_tokens(org.id)
+
+ assert length(org_agents) == 2
+ assert Enum.any?(org_agents, &(&1.id == org_agent1.id))
+ assert Enum.any?(org_agents, &(&1.id == org_agent2.id))
+ assert Enum.all?(org_agents, &(&1.is_cloud_poller == false))
+ end
+ end
+
+ describe "cloud poller validation constraints" do
+ test "cloud poller cannot have organization_id" do
+ # Attempt to create cloud poller with organization_id should fail
+ {_token, changeset} = AgentToken.build_token("fake-org-id", "Invalid", is_cloud_poller: true)
+ refute changeset.valid?
+ assert "must be nil for cloud pollers" in errors_on(changeset).organization_id
+ end
+
+ test "regular agent must have organization_id" do
+ # Attempt to create regular agent without organization_id should fail
+ {_token, changeset} = AgentToken.build_token(nil, "Invalid", is_cloud_poller: false)
+ refute changeset.valid?
+ assert "can't be blank for non-cloud pollers" in errors_on(changeset).organization_id
+ end
+ end
+
+ describe "cloud poller polling targets" do
+ setup do
+ user = user_fixture()
+ {:ok, org1} = Towerops.Organizations.create_organization(%{name: "Org 1"}, user.id)
+ {:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, user.id)
+
+ {:ok, site1} = Towerops.Sites.create_site(%{name: "Site 1", organization_id: org1.id})
+ {:ok, site2} = Towerops.Sites.create_site(%{name: "Site 2", organization_id: org2.id})
+
+ {:ok, cloud_poller, _} = Agents.create_cloud_poller("Cloud Poller")
+
+ %{
+ org1: org1,
+ org2: org2,
+ site1: site1,
+ site2: site2,
+ cloud_poller: cloud_poller
+ }
+ end
+
+ test "cloud poller can be assigned to devices across organizations", %{
+ site1: site1,
+ site2: site2,
+ cloud_poller: cloud_poller
+ } do
+ # Create devices in different organizations
+ {:ok, device1} =
+ Towerops.Devices.create_device(%{
+ name: "Device 1",
+ ip_address: "192.168.1.1",
+ site_id: site1.id,
+ snmp_enabled: true,
+ snmp_version: "2c",
+ snmp_community: "public"
+ })
+
+ {:ok, device2} =
+ Towerops.Devices.create_device(%{
+ name: "Device 2",
+ ip_address: "192.168.2.1",
+ site_id: site2.id,
+ snmp_enabled: true,
+ snmp_version: "2c",
+ snmp_community: "public"
+ })
+
+ # Assign both devices to cloud poller
+ {:ok, _} = Agents.assign_device_to_agent(cloud_poller.id, device1.id)
+ {:ok, _} = Agents.assign_device_to_agent(cloud_poller.id, device2.id)
+
+ # Cloud poller should see both devices across organizations
+ targets = Agents.list_agent_polling_targets(cloud_poller.id)
+ assert length(targets) == 2
+ assert Enum.any?(targets, &(&1.id == device1.id))
+ assert Enum.any?(targets, &(&1.id == device2.id))
+ end
+
+ test "cloud poller can be organization default for multiple orgs", %{
+ org1: org1,
+ org2: org2,
+ site1: site1,
+ site2: site2,
+ cloud_poller: cloud_poller
+ } do
+ # Set cloud poller as default for both organizations
+ {:ok, _} =
+ Towerops.Organizations.update_organization(org1, %{
+ default_agent_token_id: cloud_poller.id
+ })
+
+ {:ok, _} =
+ Towerops.Organizations.update_organization(org2, %{
+ default_agent_token_id: cloud_poller.id
+ })
+
+ # Create devices in both organizations
+ {:ok, device1} =
+ Towerops.Devices.create_device(%{
+ name: "Device 1",
+ ip_address: "192.168.1.1",
+ site_id: site1.id,
+ snmp_enabled: true,
+ snmp_version: "2c",
+ snmp_community: "public"
+ })
+
+ {:ok, device2} =
+ Towerops.Devices.create_device(%{
+ name: "Device 2",
+ ip_address: "192.168.2.1",
+ site_id: site2.id,
+ snmp_enabled: true,
+ snmp_version: "2c",
+ snmp_community: "public"
+ })
+
+ # Cloud poller should see both devices via organization inheritance
+ targets = Agents.list_agent_polling_targets(cloud_poller.id)
+ assert length(targets) == 2
+ assert Enum.any?(targets, &(&1.id == device1.id))
+ assert Enum.any?(targets, &(&1.id == device2.id))
+ end
+
+ test "cloud poller can be site default for sites in different orgs", %{
+ site1: site1,
+ site2: site2,
+ cloud_poller: cloud_poller
+ } do
+ # Set cloud poller as default for both sites
+ {:ok, _} = Towerops.Sites.update_site(site1, %{agent_token_id: cloud_poller.id})
+ {:ok, _} = Towerops.Sites.update_site(site2, %{agent_token_id: cloud_poller.id})
+
+ # Create devices in both sites
+ {:ok, device1} =
+ Towerops.Devices.create_device(%{
+ name: "Device 1",
+ ip_address: "192.168.1.1",
+ site_id: site1.id,
+ snmp_enabled: true,
+ snmp_version: "2c",
+ snmp_community: "public"
+ })
+
+ {:ok, device2} =
+ Towerops.Devices.create_device(%{
+ name: "Device 2",
+ ip_address: "192.168.2.1",
+ site_id: site2.id,
+ snmp_enabled: true,
+ snmp_version: "2c",
+ snmp_community: "public"
+ })
+
+ # Cloud poller should see both devices via site inheritance
+ targets = Agents.list_agent_polling_targets(cloud_poller.id)
+ assert length(targets) == 2
+ assert Enum.any?(targets, &(&1.id == device1.id))
+ assert Enum.any?(targets, &(&1.id == device2.id))
+ end
+ end
end
diff --git a/test/towerops_web/live/agent_live/index_test.exs b/test/towerops_web/live/agent_live/index_test.exs
index 2a2526de..7f64cb76 100644
--- a/test/towerops_web/live/agent_live/index_test.exs
+++ b/test/towerops_web/live/agent_live/index_test.exs
@@ -200,4 +200,292 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
assert render(view) =~ "Remote Agents"
end
end
+
+ describe "cloud poller creation - superadmin only" do
+ setup %{user: _user, 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"
+ })
+
+ %{superadmin: superadmin}
+ end
+
+ test "superadmin can create cloud poller", %{
+ conn: conn,
+ superadmin: superadmin,
+ organization: organization
+ } do
+ conn =
+ conn
+ |> log_in_user(superadmin)
+ |> get("/orgs/#{organization.slug}/agents")
+
+ {:ok, view, html} = live(conn)
+
+ # Superadmin should see the cloud poller checkbox
+ assert html =~ "Cloud Poller"
+
+ # Create a cloud poller
+ html =
+ render_submit(view, "create_agent", %{
+ "agent_form" => %{"name" => "Test Cloud Poller", "is_cloud_poller" => "true"}
+ })
+
+ assert html =~ "Cloud poller created successfully"
+ assert html =~ "Test Cloud Poller"
+
+ # Verify cloud poller was created
+ cloud_pollers = Agents.list_cloud_pollers()
+ assert length(cloud_pollers) == 1
+ assert hd(cloud_pollers).name == "Test Cloud Poller"
+ assert hd(cloud_pollers).is_cloud_poller == true
+ assert hd(cloud_pollers).organization_id == nil
+ end
+
+ test "non-superadmin cannot create cloud poller", %{
+ conn: conn,
+ user: user,
+ organization: organization
+ } do
+ conn =
+ conn
+ |> log_in_user(user)
+ |> get("/orgs/#{organization.slug}/agents")
+
+ {:ok, view, html} = live(conn)
+
+ # Regular user should NOT see the cloud poller checkbox
+ refute html =~ "Cloud Poller (Application-wide agent"
+
+ # Attempt to create a cloud poller (bypassing UI)
+ html =
+ render_submit(view, "create_agent", %{
+ "agent_form" => %{"name" => "Unauthorized Cloud Poller", "is_cloud_poller" => "true"}
+ })
+
+ assert html =~ "Only superadmins can create cloud pollers"
+
+ # Verify cloud poller was NOT created
+ cloud_pollers = Agents.list_cloud_pollers()
+ assert Enum.empty?(cloud_pollers)
+
+ # Verify organization agent was NOT created
+ org_agents = Agents.list_organization_agent_tokens(organization.id)
+ assert Enum.empty?(org_agents)
+ end
+
+ test "superadmin can create regular agent by not checking cloud poller", %{
+ conn: conn,
+ superadmin: superadmin,
+ organization: organization
+ } do
+ conn =
+ conn
+ |> log_in_user(superadmin)
+ |> get("/orgs/#{organization.slug}/agents")
+
+ {:ok, view, _html} = live(conn)
+
+ # Create a regular agent (is_cloud_poller = false)
+ html =
+ render_submit(view, "create_agent", %{
+ "agent_form" => %{"name" => "Regular Agent", "is_cloud_poller" => "false"}
+ })
+
+ assert html =~ "Agent created successfully"
+ assert html =~ "Regular Agent"
+
+ # Verify regular agent was created
+ org_agents = Agents.list_organization_agent_tokens(organization.id)
+ assert length(org_agents) == 1
+ assert hd(org_agents).name == "Regular Agent"
+ assert hd(org_agents).is_cloud_poller == false
+ assert hd(org_agents).organization_id == organization.id
+
+ # Verify cloud poller was NOT created
+ cloud_pollers = Agents.list_cloud_pollers()
+ assert Enum.empty?(cloud_pollers)
+ end
+ end
+
+ describe "cloud poller visibility" do
+ setup %{user: _user, 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"
+ })
+
+ # Create some cloud pollers
+ {:ok, cloud1, _} = Agents.create_cloud_poller("Cloud Poller 1")
+ {:ok, cloud2, _} = Agents.create_cloud_poller("Cloud Poller 2")
+
+ # Create organization agents
+ {:ok, org_agent, _} = Agents.create_agent_token(organization.id, "Org Agent")
+
+ %{superadmin: superadmin, cloud1: cloud1, cloud2: cloud2, org_agent: org_agent}
+ end
+
+ test "superadmin sees cloud pollers in separate section", %{
+ conn: conn,
+ superadmin: superadmin,
+ organization: organization,
+ cloud1: cloud1,
+ cloud2: cloud2,
+ org_agent: org_agent
+ } do
+ conn =
+ conn
+ |> log_in_user(superadmin)
+ |> get("/orgs/#{organization.slug}/agents")
+
+ {:ok, _view, html} = live(conn)
+
+ # Should see cloud pollers section header
+ assert html =~ "Cloud Pollers"
+
+ # Should see both cloud pollers
+ assert html =~ cloud1.name
+ assert html =~ cloud2.name
+
+ # Should see organization agent in main section
+ assert html =~ org_agent.name
+
+ # Should see visual distinction for cloud pollers
+ assert html =~ "Cloud Poller" or html =~ "cloud-poller"
+ end
+
+ test "non-superadmin does not see cloud pollers section", %{
+ conn: conn,
+ user: user,
+ organization: organization,
+ cloud1: cloud1,
+ cloud2: cloud2,
+ org_agent: org_agent
+ } do
+ conn =
+ conn
+ |> log_in_user(user)
+ |> get("/orgs/#{organization.slug}/agents")
+
+ {:ok, _view, html} = live(conn)
+
+ # Should NOT see cloud pollers section header
+ refute html =~ "Cloud Pollers"
+
+ # Should NOT see cloud poller names
+ refute html =~ cloud1.name
+ refute html =~ cloud2.name
+
+ # Should see organization agent in main section
+ assert html =~ org_agent.name
+ end
+ end
+
+ describe "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 delete cloud poller", %{
+ conn: conn,
+ superadmin: superadmin,
+ organization: organization,
+ cloud_poller: cloud_poller
+ } do
+ conn =
+ conn
+ |> log_in_user(superadmin)
+ |> get("/orgs/#{organization.slug}/agents")
+
+ {:ok, view, _html} = live(conn)
+
+ html = render_click(view, "delete_agent", %{"id" => cloud_poller.id})
+
+ assert html =~ "Agent deleted successfully"
+
+ # Verify cloud poller was deleted
+ cloud_pollers = Agents.list_cloud_pollers()
+ assert Enum.empty?(cloud_pollers)
+ end
+
+ test "superadmin can regenerate cloud poller token", %{
+ conn: conn,
+ superadmin: superadmin,
+ organization: organization,
+ cloud_poller: cloud_poller
+ } do
+ conn =
+ conn
+ |> log_in_user(superadmin)
+ |> get("/orgs/#{organization.slug}/agents")
+
+ {:ok, view, _html} = live(conn)
+
+ html = render_click(view, "regenerate_token", %{"id" => cloud_poller.id})
+
+ assert html =~ "New token generated successfully"
+ assert html =~ "Test Cloud Poller"
+ end
+
+ test "superadmin can view cloud poller setup instructions", %{
+ conn: conn,
+ superadmin: superadmin,
+ organization: organization,
+ cloud_poller: cloud_poller
+ } do
+ conn =
+ conn
+ |> log_in_user(superadmin)
+ |> get("/orgs/#{organization.slug}/agents")
+
+ {:ok, view, _html} = live(conn)
+
+ html = render_click(view, "show_setup", %{"id" => cloud_poller.id})
+
+ assert html =~ "Test Cloud Poller"
+ # Setup modal should show token and instructions
+ assert html =~ cloud_poller.token or html =~ "docker run" or html =~ "Setup"
+ end
+ end
end