add multiple cloud agents
This commit is contained in:
parent
f8d6837f20
commit
f084e09ea1
10 changed files with 831 additions and 55 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
<div class="flex gap-3">
|
||||
<.button type="submit" variant="primary">
|
||||
Create Agent
|
||||
|
|
@ -157,6 +164,103 @@
|
|||
</.table>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Cloud Pollers Section (Superadmin Only) --%>
|
||||
<%= if @current_scope.user.is_superuser && @cloud_pollers != [] do %>
|
||||
<div class="mt-12">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Cloud Pollers
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Application-wide agents that can poll devices for any organization. Only visible and manageable by superadmins.
|
||||
</p>
|
||||
<.table id="cloud-pollers-table" rows={@cloud_pollers}>
|
||||
<:col :let={agent} label="Name">
|
||||
<div class="flex items-center gap-2">
|
||||
<.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}
|
||||
</.link>
|
||||
<span class="inline-flex items-center rounded-md bg-purple-50 px-2 py-1 text-xs font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10 dark:bg-purple-400/10 dark:text-purple-400 dark:ring-purple-400/30">
|
||||
Cloud Poller
|
||||
</span>
|
||||
</div>
|
||||
<%= if agent.metadata["version"] do %>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
v{agent.metadata["version"]}
|
||||
</div>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:col :let={agent} label="Status">
|
||||
<% {status, label} = agent_status(agent) %>
|
||||
<span class={"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium #{status_badge_class(status)}"}>
|
||||
<span class={"h-1.5 w-1.5 rounded-full #{status_dot_class(status)}"} />
|
||||
{label}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:col :let={agent} label="Last Seen">
|
||||
<div class="text-sm text-gray-900 dark:text-white">
|
||||
<.timestamp datetime={agent.last_seen_at} timezone={@timezone} />
|
||||
</div>
|
||||
<%= if agent.last_seen_at do %>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{format_datetime(agent.last_seen_at, @timezone)}
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if agent.last_ip do %>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono">
|
||||
{agent.last_ip}
|
||||
</div>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:col :let={agent} label="Metadata">
|
||||
<%= if agent.metadata["hostname"] do %>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<.icon name="hero-computer-desktop" class="h-4 w-4 inline" />
|
||||
{agent.metadata["hostname"]}
|
||||
</div>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:col :let={agent} label="Created">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{ToweropsWeb.TimeHelpers.format_date(agent.inserted_at, @timezone)}
|
||||
</div>
|
||||
</:col>
|
||||
|
||||
<:action :let={agent}>
|
||||
<%= if agent.enabled do %>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="show_setup"
|
||||
phx-value-id={agent.id}
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
View Setup
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="delete_agent"
|
||||
phx-value-id={agent.id}
|
||||
data-confirm="Are you sure you want to delete this cloud poller? This will affect all organizations using it."
|
||||
class="text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Disabled</span>
|
||||
<% end %>
|
||||
</:action>
|
||||
</.table>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Token Display Modal -->
|
||||
<%= if @show_token_modal && @new_token do %>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
4
mix.lock
4
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"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue