Fix agent page jumping by using streams for real-time updates

- Convert agent_tokens from assign to stream to prevent full page re-renders
- Update handle_info callbacks to only update the specific agent that changed
- Only update stats when agent status changes, not device counts
- Prevents IP address from temporarily disappearing during updates
This commit is contained in:
Graham McIntire 2026-02-09 13:39:59 -06:00
parent 39fa1c6e4e
commit ae9b393137
No known key found for this signature in database
2 changed files with 47 additions and 24 deletions

View file

@ -55,7 +55,7 @@ defmodule ToweropsWeb.AgentLive.Index do
|> assign(:page_title, "Remote Agents")
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:is_superuser, is_superuser)
|> assign(:agent_tokens, agent_tokens)
|> stream(:agent_tokens, agent_tokens)
|> assign(:cloud_pollers, cloud_pollers)
|> assign(:global_default_cloud_poller_id, global_default_cloud_poller_id)
|> assign(:selected_global_default, global_default_cloud_poller_id || "")
@ -355,18 +355,18 @@ defmodule ToweropsWeb.AgentLive.Index do
# Handle real-time agent status updates
@impl true
def handle_info({:agent_connected, _agent_token_id, _organization_id}, socket) do
{:noreply, reload_agent_data(socket)}
def handle_info({:agent_connected, agent_token_id, _organization_id}, socket) do
{:noreply, update_single_agent(socket, agent_token_id)}
end
@impl true
def handle_info({:agent_disconnected, _agent_token_id, _organization_id}, socket) do
{:noreply, reload_agent_data(socket)}
def handle_info({:agent_disconnected, agent_token_id, _organization_id}, socket) do
{:noreply, update_single_agent(socket, agent_token_id)}
end
@impl true
def handle_info({:agent_heartbeat, _agent_token_id, _organization_id}, socket) do
{:noreply, reload_agent_data(socket)}
def handle_info({:agent_heartbeat, agent_token_id, _organization_id}, socket) do
{:noreply, update_single_agent(socket, agent_token_id)}
end
@impl true
@ -388,7 +388,7 @@ defmodule ToweropsWeb.AgentLive.Index do
offline_agents = Stats.get_offline_agents(organization.id)
socket
|> assign(:agent_tokens, agent_tokens)
|> stream(:agent_tokens, agent_tokens, reset: true)
|> assign(:cloud_pollers, cloud_pollers)
|> assign(:device_counts, equipment_counts)
|> assign(:cloud_poller_counts, cloud_poller_counts)
@ -396,4 +396,27 @@ defmodule ToweropsWeb.AgentLive.Index do
|> assign(:assignment_breakdown, assignment_breakdown)
|> assign(:offline_agents, offline_agents)
end
defp update_single_agent(socket, agent_token_id) do
agent_token = Agents.get_agent_token!(agent_token_id)
socket
|> stream_insert(:agent_tokens, agent_token)
|> maybe_update_stats()
rescue
Ecto.NoResultsError ->
# Agent was deleted, just update stats
maybe_update_stats(socket)
end
defp maybe_update_stats(socket) do
organization = socket.assigns.current_scope.organization
agent_health_stats = Stats.get_organization_agent_health(organization.id)
offline_agents = Stats.get_offline_agents(organization.id)
socket
|> assign(:agent_health_stats, agent_health_stats)
|> assign(:offline_agents, offline_agents)
end
end

View file

@ -52,7 +52,7 @@
</div>
</div>
<%= if @agent_tokens == [] do %>
<%= if Enum.empty?(@streams.agent_tokens) do %>
<div class="text-center py-16">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">No agents</h3>
@ -67,8 +67,8 @@
</div>
<% else %>
<div class="mt-6">
<.table id="agents-table" rows={@agent_tokens}>
<:col :let={agent} label="Name">
<.table id="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}"}
@ -89,7 +89,7 @@
</div>
</:col>
<:col :let={agent} label="Status">
<: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)}"} />
@ -97,7 +97,7 @@
</span>
</:col>
<:col :let={agent} label="Device">
<:col :let={{_id, agent}} label="Device">
<% counts = Map.get(@device_counts, agent.id, %{direct: 0, total: 0}) %>
<div class="text-sm text-gray-900 dark:text-white">
{counts.total} total
@ -110,7 +110,7 @@
</div>
</:col>
<:col :let={agent} label="Last Seen">
<: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} />
</div>
@ -126,7 +126,7 @@
<% end %>
</:col>
<:col :let={agent} label="Metadata">
<:col :let={{_id, 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" />
@ -140,13 +140,13 @@
<% end %>
</:col>
<:col :let={agent} label="Created">
<:col :let={{_id, 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}>
<:action :let={{_id, agent}}>
<%= if agent.enabled do %>
<div class="flex items-center gap-3">
<button
@ -185,7 +185,7 @@
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">
<:col :let={{_id, agent}} label="Name">
<div class="flex items-center gap-2">
<.link
navigate={~p"/agents/#{agent.id}"}
@ -206,7 +206,7 @@
</div>
</:col>
<:col :let={agent} label="Status">
<: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)}"} />
@ -214,7 +214,7 @@
</span>
</:col>
<:col :let={agent} label="Device">
<:col :let={{_id, agent}} label="Device">
<% counts = Map.get(@cloud_poller_counts, agent.id, %{direct: 0, total: 0}) %>
<div class="text-sm text-gray-900 dark:text-white">
{counts.total} total
@ -227,7 +227,7 @@
</div>
</:col>
<:col :let={agent} label="Last Seen">
<: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} />
</div>
@ -243,7 +243,7 @@
<% end %>
</:col>
<:col :let={agent} label="Metadata">
<:col :let={{_id, 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" />
@ -257,13 +257,13 @@
<% end %>
</:col>
<:col :let={agent} label="Created">
<:col :let={{_id, 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}>
<:action :let={{_id, agent}}>
<%= if agent.enabled do %>
<div class="flex items-center gap-3">
<button