towerops/lib/towerops_web/live/admin/agent_live/index.ex
Graham McIntire 701ce12f08 perf+refactor: codebase-wide query and antipattern audit
Performance:
- schedule_live: preload page once instead of get_schedule!/1 per row
- alert_live + alerts: DB-side status filter; Repo.aggregate counts replace length/Enum.count over 500-row fetches on every event
- dashboard_live: drop duplicate get_device_status_counts; cap active alerts to 20 + use count_active_alerts/1
- maintenance.active_windows_for_device: 3 round-trips collapsed into one query (per-site-id branch keeps Postgres parameter types unambiguous)
- sites.build_site_tree: O(N^2) -> O(N) via group_by(parent_site_id)
- accounts.sole_owner_organizations: single group_by + having instead of per-org Repo.aggregate loop
- agents + agent_live (org + admin): count_assigned_devices_batch/1 + count_agent_polling_targets/1 (no preloads)
- alert_digest_worker: list_alerts_by_ids/1 batches digest fetch
- gaiia: distinct: true at DB; limit 50 on bidirectional ilike

Indexes:
- maintenance_windows(organization_id, starts_at, ends_at) WHERE suppress_alerts = true
- alerts(check_id) WHERE resolved_at IS NULL

Antipatterns:
- agents.delete_agent_token: PubSub.broadcast moved outside Repo.transaction so a rollback no longer leaves subscribers acting on a non-existent deletion
- integrations_controller.to_atom_keys: replaced String.to_existing_atom on user-controlled JSON keys with explicit allowlist
- 9 Task.start callsites converted to Task.Supervisor.start_child(Towerops.TaskSupervisor, ...) so background DB writes survive shutdown (test-mode discovery shims left as-is for sandbox semantics)
2026-04-28 16:58:51 -05:00

123 lines
3.8 KiB
Elixir

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
timer_ref =
if connected?(socket) do
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "admin:agents")
{:ok, ref} = :timer.send_interval(1000, :tick)
ref
end
agent_tokens = Agents.list_all_agent_tokens()
{cloud_pollers, org_agents} = Enum.split_with(agent_tokens, & &1.is_cloud_poller)
device_counts = calculate_device_counts(agent_tokens)
{:ok,
socket
|> assign(:page_title, t("All Agents"))
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> stream(:cloud_pollers, cloud_pollers, reset: true)
|> stream(:org_agents, org_agents, reset: true)
|> assign(:has_cloud_pollers, cloud_pollers != [])
|> assign(:has_org_agents, org_agents != [])
|> assign(:device_counts, device_counts)
|> assign(:now, DateTime.utc_now())
|> assign(:timer_ref, timer_ref)}
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
@impl true
def handle_info({:agent_created, agent_token_id, _is_cloud_poller}, socket) do
{:noreply, refresh_agent(socket, agent_token_id)}
end
@impl true
def handle_info({:agent_deleted, _agent_token_id, _is_cloud_poller}, 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 = Agents.count_agent_polling_targets(agent_token_id)
{stream_name, section_assign} =
if agent_token.is_cloud_poller,
do: {:cloud_pollers, :has_cloud_pollers},
else: {:org_agents, :has_org_agents}
socket
|> stream_insert(stream_name, agent_token)
|> assign(section_assign, true)
|> 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()
{cloud_pollers, org_agents} = Enum.split_with(agent_tokens, & &1.is_cloud_poller)
device_counts = calculate_device_counts(agent_tokens)
socket
|> stream(:cloud_pollers, cloud_pollers, reset: true)
|> stream(:org_agents, org_agents, reset: true)
|> assign(:has_cloud_pollers, cloud_pollers != [])
|> assign(:has_org_agents, org_agents != [])
|> assign(:device_counts, device_counts)
end
defp calculate_device_counts(agent_tokens) do
token_ids = Enum.map(agent_tokens, & &1.id)
direct_counts = Agents.count_assigned_devices_batch(token_ids)
Map.new(agent_tokens, fn t ->
direct = Map.get(direct_counts, t.id, 0)
total = Agents.count_agent_polling_targets(t.id)
{t.id, %{direct: direct, total: total}}
end)
end
@impl true
def terminate(_reason, socket) do
_ =
if timer_ref = socket.assigns[:timer_ref] do
:timer.cancel(timer_ref)
end
:ok
end
end