Add admin agents page at /admin/agents
Superuser-only page showing all agents across all organizations and cloud pollers in a single table with live-ticking timestamps and real-time status updates via PubSub.
This commit is contained in:
parent
5c59777baa
commit
183f2a89ac
6 changed files with 323 additions and 0 deletions
|
|
@ -85,6 +85,26 @@ defmodule Towerops.Agents do
|
|||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all agent tokens across all organizations, including cloud pollers.
|
||||
|
||||
Returns a list of agent tokens with organization preloaded, ordered by
|
||||
creation date (newest first). Used by the admin agents page.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_all_agent_tokens()
|
||||
[%AgentToken{organization: %Organization{}}, ...]
|
||||
|
||||
"""
|
||||
@spec list_all_agent_tokens() :: [AgentToken.t()]
|
||||
def list_all_agent_tokens do
|
||||
AgentToken
|
||||
|> order_by([t], desc: t.inserted_at)
|
||||
|> preload(:organization)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all cloud poller agent tokens (superadmin only).
|
||||
|
||||
|
|
|
|||
82
lib/towerops_web/live/admin/agent_live/index.ex
Normal file
82
lib/towerops_web/live/admin/agent_live/index.ex
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
defmodule ToweropsWeb.Admin.AgentLive.Index do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
import ToweropsWeb.AgentLive.Helpers
|
||||
|
||||
alias Towerops.Agents
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
|
||||
:timer.send_interval(1000, :tick)
|
||||
end
|
||||
|
||||
agent_tokens = Agents.list_all_agent_tokens()
|
||||
device_counts = calculate_device_counts(agent_tokens)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "All Agents")
|
||||
|> assign(:timezone, socket.assigns.current_scope.timezone)
|
||||
|> stream(:agent_tokens, agent_tokens)
|
||||
|> assign(:device_counts, device_counts)
|
||||
|> assign(:now, DateTime.utc_now())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:tick, socket) do
|
||||
{:noreply, assign(socket, :now, DateTime.utc_now())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:agent_connected, agent_token_id, _organization_id}, socket) do
|
||||
{:noreply, refresh_agent(socket, agent_token_id)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:agent_disconnected, agent_token_id, _organization_id}, socket) do
|
||||
{:noreply, refresh_agent(socket, agent_token_id)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:agent_heartbeat, agent_token_id, _organization_id}, socket) do
|
||||
{:noreply, refresh_agent(socket, agent_token_id)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:agents_stale, _stale_agents}, socket) do
|
||||
{:noreply, reload_all(socket)}
|
||||
end
|
||||
|
||||
defp refresh_agent(socket, agent_token_id) do
|
||||
agent_token = agent_token_id |> Agents.get_agent_token!() |> Towerops.Repo.preload(:organization)
|
||||
|
||||
direct = Agents.count_assigned_devices(agent_token_id)
|
||||
total = length(Agents.list_agent_polling_targets(agent_token_id))
|
||||
|
||||
socket
|
||||
|> stream_insert(:agent_tokens, agent_token)
|
||||
|> assign(:device_counts, Map.put(socket.assigns.device_counts, agent_token_id, %{direct: direct, total: total}))
|
||||
rescue
|
||||
Ecto.NoResultsError -> socket
|
||||
end
|
||||
|
||||
defp reload_all(socket) do
|
||||
agent_tokens = Agents.list_all_agent_tokens()
|
||||
device_counts = calculate_device_counts(agent_tokens)
|
||||
|
||||
socket
|
||||
|> stream(:agent_tokens, agent_tokens, reset: true)
|
||||
|> assign(:device_counts, device_counts)
|
||||
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
|
||||
end
|
||||
83
lib/towerops_web/live/admin/agent_live/index.html.heex
Normal file
83
lib/towerops_web/live/admin/agent_live/index.html.heex
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<Layouts.admin flash={@flash} timezone={@timezone}>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">All Agents</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Agents across all organizations and cloud pollers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<.table id="admin-agents-table" rows={@streams.agent_tokens}>
|
||||
<:col :let={{_id, agent}} label="Name">
|
||||
<div class="flex items-center gap-2">
|
||||
<.link
|
||||
navigate={~p"/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>
|
||||
<%= if agent.is_cloud_poller do %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-purple-50 dark:bg-purple-900/30 px-2 py-0.5 text-xs font-medium text-purple-700 dark:text-purple-300 ring-1 ring-inset ring-purple-600/20 dark:ring-purple-400/30">
|
||||
<.icon name="hero-cloud" class="h-3 w-3" /> Cloud
|
||||
</span>
|
||||
<% end %>
|
||||
<%= if agent.allow_remote_debug do %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 dark:bg-amber-900/30 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-600/20 dark:ring-amber-400/30">
|
||||
<.icon name="hero-bug-ant" class="h-3 w-3" /> Debug
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</:col>
|
||||
|
||||
<:col :let={{_id, agent}} label="Organization">
|
||||
<%= if agent.is_cloud_poller do %>
|
||||
<span class="text-sm text-purple-600 dark:text-purple-400">Cloud Poller</span>
|
||||
<% else %>
|
||||
<span class="text-sm text-gray-900 dark:text-white">
|
||||
{agent.organization && agent.organization.name}
|
||||
</span>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:col :let={{_id, 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={{_id, agent}} label="Devices">
|
||||
<% counts = Map.get(@device_counts, agent.id, %{direct: 0, total: 0}) %>
|
||||
<div class="text-sm text-gray-900 dark:text-white">
|
||||
{counts.total} total
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{counts.direct} direct
|
||||
<%= if counts.total > counts.direct do %>
|
||||
· {counts.total - counts.direct} inherited
|
||||
<% end %>
|
||||
</div>
|
||||
</:col>
|
||||
|
||||
<:col :let={{_id, agent}} label="Last Seen">
|
||||
<div class="text-sm text-gray-900 dark:text-white">
|
||||
<.timestamp datetime={agent.last_seen_at} timezone={@timezone} now={@now} />
|
||||
</div>
|
||||
<%= if agent.last_seen_at do %>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
<.timestamp datetime={agent.last_seen_at} timezone={@timezone} format="absolute" />
|
||||
</div>
|
||||
<% end %>
|
||||
</:col>
|
||||
|
||||
<:col :let={{_id, agent}} label="Version">
|
||||
<%= if agent.metadata["version"] do %>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 font-mono">
|
||||
{format_agent_version(agent.metadata["version"])}
|
||||
</div>
|
||||
<% end %>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
</Layouts.admin>
|
||||
|
|
@ -232,6 +232,7 @@ defmodule ToweropsWeb.Router do
|
|||
live "/organizations", OrgLive.Index, :index
|
||||
live "/audit", AuditLive.Index, :index
|
||||
live "/monitoring", MonitoringLive, :index
|
||||
live "/agents", AgentLive.Index, :index
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1348,6 +1348,59 @@ defmodule Towerops.AgentsTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "list_all_agent_tokens/0" do
|
||||
test "returns all agent tokens across organizations and cloud pollers", %{
|
||||
organization: org1,
|
||||
user: user
|
||||
} do
|
||||
{:ok, org2} =
|
||||
Towerops.Organizations.create_organization(%{name: "Test Org 2"}, user.id, bypass_limits: true)
|
||||
|
||||
{:ok, token1, _} = Agents.create_agent_token(org1.id, "Org1 Agent")
|
||||
{:ok, token2, _} = Agents.create_agent_token(org2.id, "Org2 Agent")
|
||||
{:ok, cloud, _} = Agents.create_cloud_poller("Cloud Poller")
|
||||
|
||||
tokens = Agents.list_all_agent_tokens()
|
||||
|
||||
assert length(tokens) == 3
|
||||
ids = Enum.map(tokens, & &1.id)
|
||||
assert token1.id in ids
|
||||
assert token2.id in ids
|
||||
assert cloud.id in ids
|
||||
end
|
||||
|
||||
test "preloads organization", %{organization: org} do
|
||||
{:ok, _token, _} = Agents.create_agent_token(org.id, "Test Agent")
|
||||
|
||||
[token] = Agents.list_all_agent_tokens()
|
||||
|
||||
assert Ecto.assoc_loaded?(token.organization)
|
||||
assert token.organization.name == "Test Org"
|
||||
end
|
||||
|
||||
test "orders by newest first", %{organization: org} do
|
||||
{:ok, token1, _} = Agents.create_agent_token(org.id, "Agent 1")
|
||||
|
||||
# Manually set an earlier timestamp so ordering is deterministic
|
||||
Towerops.Repo.update_all(
|
||||
from(t in AgentToken, where: t.id == ^token1.id),
|
||||
set: [inserted_at: ~U[2025-01-01 00:00:00Z]]
|
||||
)
|
||||
|
||||
{:ok, token2, _} = Agents.create_agent_token(org.id, "Agent 2")
|
||||
|
||||
tokens = Agents.list_all_agent_tokens()
|
||||
ids = Enum.map(tokens, & &1.id)
|
||||
|
||||
assert List.first(ids) == token2.id
|
||||
assert List.last(ids) == token1.id
|
||||
end
|
||||
|
||||
test "returns empty list when no tokens exist" do
|
||||
assert Agents.list_all_agent_tokens() == []
|
||||
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
|
||||
|
|
|
|||
84
test/towerops_web/live/admin/agent_live/index_test.exs
Normal file
84
test/towerops_web/live/admin/agent_live/index_test.exs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
defmodule ToweropsWeb.Admin.AgentLive.IndexTest do
|
||||
use ToweropsWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Towerops.Agents
|
||||
|
||||
describe "admin agents page" do
|
||||
setup [:register_and_log_in_superuser]
|
||||
|
||||
test "renders page with all agents across organizations", %{conn: conn, user: user} do
|
||||
{:ok, org1} = Towerops.Organizations.create_organization(%{name: "Org Alpha"}, user.id)
|
||||
|
||||
{:ok, org2} =
|
||||
Towerops.Organizations.create_organization(%{name: "Org Beta"}, user.id, bypass_limits: true)
|
||||
|
||||
{:ok, _agent1, _} = Agents.create_agent_token(org1.id, "Alpha Agent")
|
||||
{:ok, _agent2, _} = Agents.create_agent_token(org2.id, "Beta Agent")
|
||||
{:ok, _cloud, _} = Agents.create_cloud_poller("Cloud Poller 1")
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/agents")
|
||||
|
||||
assert html =~ "All Agents"
|
||||
assert html =~ "Alpha Agent"
|
||||
assert html =~ "Beta Agent"
|
||||
assert html =~ "Cloud Poller 1"
|
||||
assert html =~ "Org Alpha"
|
||||
assert html =~ "Org Beta"
|
||||
assert html =~ "Cloud Poller"
|
||||
end
|
||||
|
||||
test "shows cloud badge for cloud pollers", %{conn: conn} do
|
||||
{:ok, _cloud, _} = Agents.create_cloud_poller("My Cloud Poller")
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/agents")
|
||||
|
||||
assert html =~ "Cloud"
|
||||
assert html =~ "My Cloud Poller"
|
||||
end
|
||||
|
||||
test "shows debug badge for debug-enabled agents", %{conn: conn, user: user} do
|
||||
{:ok, org} = Towerops.Organizations.create_organization(%{name: "Debug Org"}, user.id)
|
||||
{:ok, agent, _} = Agents.create_agent_token(org.id, "Debug Agent")
|
||||
{:ok, _} = Agents.update_agent_token(agent, %{allow_remote_debug: true})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/agents")
|
||||
|
||||
assert html =~ "Debug"
|
||||
end
|
||||
|
||||
test "shows status badges", %{conn: conn, user: user} do
|
||||
{:ok, org} = Towerops.Organizations.create_organization(%{name: "Status Org"}, user.id)
|
||||
{:ok, _agent, _} = Agents.create_agent_token(org.id, "Status Agent")
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/agents")
|
||||
|
||||
assert html =~ "Never connected"
|
||||
end
|
||||
|
||||
test "shows version when available", %{conn: conn, user: user} do
|
||||
{:ok, org} = Towerops.Organizations.create_organization(%{name: "Version Org"}, user.id)
|
||||
{:ok, agent, _} = Agents.create_agent_token(org.id, "Versioned Agent")
|
||||
Agents.update_agent_token_heartbeat(agent.id, "10.0.0.1", %{"version" => "1.2.3"})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/admin/agents")
|
||||
|
||||
assert html =~ "v1.2.3"
|
||||
end
|
||||
end
|
||||
|
||||
describe "non-superuser access" do
|
||||
setup [:register_and_log_in_user]
|
||||
|
||||
test "redirects non-superusers", %{conn: conn} do
|
||||
assert {:error, {:redirect, %{to: "/orgs"}}} = live(conn, ~p"/admin/agents")
|
||||
end
|
||||
end
|
||||
|
||||
defp register_and_log_in_superuser(%{conn: conn}) do
|
||||
user = Towerops.AccountsFixtures.user_fixture()
|
||||
user = Towerops.Repo.update!(Ecto.Changeset.change(user, is_superuser: true))
|
||||
%{conn: log_in_user(conn, user), user: user}
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue