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:
Graham McIntire 2026-02-10 10:56:10 -06:00
parent 5c59777baa
commit 183f2a89ac
No known key found for this signature in database
6 changed files with 323 additions and 0 deletions

View file

@ -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).

View 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

View 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>

View file

@ -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

View file

@ -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

View 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