Implement all quick win improvements for agent UI and functionality

1. Fixed Docker Compose example to include socket mount
   - Added /var/run/docker.sock mount for auto-updates in agent modal

2. Show total equipment count with inheritance
   - Display both direct and inherited equipment counts in agents table
   - Shows breakdown: 'X total' with 'Y direct · Z inherited' details

3. Add visual status indicators with better CSS
   - Added animated pulsing dots for online agents
   - Color-coded status dots (green=online, yellow=warning, red=offline)
   - Improved status badge styling

4. Create agent detail page with metrics
   - New /agents/:id page showing comprehensive agent health
   - 4 metric cards: Status, Equipment count, Last seen, Agent info
   - Displays agent metadata (hostname, version, uptime)
   - Lists all polling targets with assignment source badges
   - Shows direct vs inherited equipment assignments
   - Clickable agent names in table link to detail page

5. Add health endpoint to Rust agent
   - Added /health HTTP endpoint on port 8080
   - Returns JSON with agent status, version, uptime, metrics pending
   - Uses lightweight tiny_http server
   - Non-blocking background thread
   - Ready for monitoring/health checks
This commit is contained in:
Graham McIntire 2026-01-14 18:46:03 -06:00
parent ae521d2108
commit 4297556700
No known key found for this signature in database
5 changed files with 357 additions and 9 deletions

View file

@ -10,10 +10,12 @@ defmodule ToweropsWeb.AgentLive.Index do
organization = socket.assigns.current_organization
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
# Get equipment counts for each agent (directly assigned)
# Get equipment counts for each agent (both direct and total with inheritance)
equipment_counts =
Map.new(agent_tokens, fn token ->
{token.id, Agents.count_assigned_equipment(token.id)}
direct = Agents.count_assigned_equipment(token.id)
total = length(Agents.list_agent_polling_targets(token.id))
{token.id, %{direct: direct, total: total}}
end)
# Get organization-wide agent health statistics
@ -60,7 +62,9 @@ defmodule ToweropsWeb.AgentLive.Index do
# Refresh equipment counts
equipment_counts =
Map.new(agent_tokens, fn t ->
{t.id, Agents.count_assigned_equipment(t.id)}
direct = Agents.count_assigned_equipment(t.id)
total = length(Agents.list_agent_polling_targets(t.id))
{t.id, %{direct: direct, total: total}}
end)
# Refresh health statistics
@ -183,6 +187,15 @@ defmodule ToweropsWeb.AgentLive.Index do
end
end
defp status_dot_class(status) do
case status do
:online -> "bg-green-600 dark:bg-green-400 animate-pulse"
:warning -> "bg-yellow-600 dark:bg-yellow-400"
:offline -> "bg-red-600 dark:bg-red-400"
:never -> "bg-zinc-400 dark:bg-zinc-600"
end
end
defp format_last_seen(nil), do: "Never"
defp format_last_seen(datetime) do

View file

@ -63,7 +63,12 @@
<div class="mt-6">
<.table id="agents-table" rows={@agent_tokens}>
<:col :let={agent} label="Name">
<div class="font-medium text-zinc-900 dark:text-zinc-100">{agent.name}</div>
<.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>
<%= if agent.metadata["version"] do %>
<div class="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
v{agent.metadata["version"]}
@ -73,17 +78,22 @@
<:col :let={agent} label="Status">
<% {status, label} = agent_status(agent) %>
<span class={"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium #{status_badge_class(status)}"}>
<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="Equipment">
<% counts = Map.get(@equipment_counts, agent.id, %{direct: 0, total: 0}) %>
<div class="text-sm text-zinc-900 dark:text-zinc-100">
{Map.get(@equipment_counts, agent.id, 0)}
{counts.total} total
</div>
<div class="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
directly assigned
{counts.direct} direct
<%= if counts.total > counts.direct do %>
· {counts.total - counts.direct} inherited
<% end %>
</div>
</:col>
@ -223,7 +233,9 @@
- TOWEROPS_API_URL={url(@socket, ~p"/")}
- TOWEROPS_AGENT_TOKEN={@new_token.token}
volumes:
- ./data:/data</code></pre>
- ./data:/data
# Required for automatic self-updates
- /var/run/docker.sock:/var/run/docker.sock</code></pre>
<button
type="button"
phx-click={JS.dispatch("phx:copy", to: "#docker-compose-example")}
@ -239,7 +251,9 @@
- TOWEROPS_API_URL={url(@socket, ~p"/")}
- TOWEROPS_AGENT_TOKEN={@new_token.token}
volumes:
- ./data:/data</textarea>
- ./data:/data
# Required for automatic self-updates
- /var/run/docker.sock:/var/run/docker.sock</textarea>
</div>
</div>

View file

@ -0,0 +1,126 @@
defmodule ToweropsWeb.AgentLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Agents
@impl true
def mount(%{"id" => id}, _session, socket) do
organization = socket.assigns.current_organization
agent_token = Agents.get_agent_token!(id)
# Verify the agent belongs to this organization
if agent_token.organization_id != organization.id do
raise Ecto.NoResultsError
end
# Get polling targets (all equipment this agent is responsible for)
polling_targets = Agents.list_agent_polling_targets(agent_token.id)
# Get direct assignments
direct_assignments = Agents.count_assigned_equipment(agent_token.id)
{:ok,
socket
|> assign(:page_title, agent_token.name)
|> assign(:agent_token, agent_token)
|> assign(:polling_targets, polling_targets)
|> assign(:direct_assignments, direct_assignments)}
end
@impl true
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
defp agent_status(agent_token) do
case agent_token.last_seen_at do
nil ->
{:never, "Never connected"}
last_seen ->
seconds_ago = DateTime.diff(DateTime.utc_now(), last_seen, :second)
cond do
seconds_ago < 120 -> {:online, "Online"}
seconds_ago < 300 -> {:warning, "Warning"}
true -> {:offline, "Offline"}
end
end
end
defp status_badge_class(status) do
case status do
:online -> "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
:warning -> "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300"
:offline -> "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
:never -> "bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-300"
end
end
defp status_dot_class(status) do
case status do
:online -> "bg-green-600 dark:bg-green-400 animate-pulse"
:warning -> "bg-yellow-600 dark:bg-yellow-400"
:offline -> "bg-red-600 dark:bg-red-400"
:never -> "bg-zinc-400 dark:bg-zinc-600"
end
end
defp format_last_seen(nil), do: "Never"
defp format_last_seen(datetime) do
seconds_ago = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
seconds_ago < 60 -> "#{seconds_ago}s ago"
seconds_ago < 3600 -> "#{div(seconds_ago, 60)}m ago"
seconds_ago < 86_400 -> "#{div(seconds_ago, 3600)}h ago"
true -> "#{div(seconds_ago, 86_400)}d ago"
end
end
defp format_uptime(nil), do: "Unknown"
defp format_uptime(seconds) when is_integer(seconds) do
cond do
seconds < 60 -> "#{seconds}s"
seconds < 3600 -> "#{div(seconds, 60)}m"
seconds < 86_400 -> "#{div(seconds, 3600)}h #{rem(div(seconds, 60), 60)}m"
true -> "#{div(seconds, 86_400)}d #{rem(div(seconds, 3600), 24)}h"
end
end
defp format_uptime(_), do: "Unknown"
defp assignment_source(equipment, agent_token_id) do
cond do
# Direct assignment
Enum.any?(equipment.agent_assignments || [], fn a ->
a.agent_token_id == agent_token_id and a.enabled
end) ->
{:direct, "Directly assigned"}
# Site assignment
equipment.site && equipment.site.agent_token_id == agent_token_id ->
{:site, "From site: #{equipment.site.name}"}
# Organization assignment
equipment.organization && equipment.organization.default_agent_token_id == agent_token_id ->
{:organization, "From organization"}
# Shouldn't happen but handle gracefully
true ->
{:unknown, "Unknown"}
end
end
defp source_badge_class(source) do
case source do
:direct -> "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
:site -> "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300"
:organization -> "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300"
:unknown -> "bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-300"
end
end
end

View file

@ -0,0 +1,194 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="agents"
>
<.header>
{@page_title}
<:subtitle>Agent Details and Health</:subtitle>
<:actions>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/agents"}
class="text-sm font-semibold text-zinc-700 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-zinc-100"
>
<.icon name="hero-arrow-left" class="h-5 w-5" /> Back to Agents
</.link>
</:actions>
</.header>
<!-- Agent Status Overview -->
<div class="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<!-- Status Card -->
<div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Status</p>
<% {status, label} = agent_status(@agent_token) %>
<div class="mt-2">
<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>
</div>
</div>
<.icon name="hero-signal" class="h-8 w-8 text-zinc-400 dark:text-zinc-600" />
</div>
</div>
<!-- Equipment Count Card -->
<div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Equipment</p>
<p class="mt-2 text-3xl font-semibold text-zinc-900 dark:text-zinc-100">
{length(@polling_targets)}
</p>
<p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
{@direct_assignments} direct
</p>
</div>
<.icon name="hero-server" class="h-8 w-8 text-zinc-400 dark:text-zinc-600" />
</div>
</div>
<!-- Last Seen Card -->
<div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Last Seen</p>
<p class="mt-2 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{format_last_seen(@agent_token.last_seen_at)}
</p>
<%= if @agent_token.last_ip do %>
<p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400 font-mono">
{@agent_token.last_ip}
</p>
<% end %>
</div>
<.icon name="hero-clock" class="h-8 w-8 text-zinc-400 dark:text-zinc-600" />
</div>
</div>
<!-- Version/Uptime Card -->
<div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Agent Info</p>
<%= if @agent_token.metadata["version"] do %>
<p class="mt-2 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
v{@agent_token.metadata["version"]}
</p>
<% else %>
<p class="mt-2 text-lg font-semibold text-zinc-500 dark:text-zinc-400">
Unknown
</p>
<% end %>
<%= if @agent_token.metadata["uptime_seconds"] do %>
<p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
Uptime: {format_uptime(@agent_token.metadata["uptime_seconds"])}
</p>
<% end %>
</div>
<.icon name="hero-information-circle" class="h-8 w-8 text-zinc-400 dark:text-zinc-600" />
</div>
</div>
</div>
<!-- Agent Metadata -->
<%= if @agent_token.metadata && map_size(@agent_token.metadata) > 0 do %>
<div class="mt-6 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-6">
<h3 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
Agent Metadata
</h3>
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<%= if @agent_token.metadata["hostname"] do %>
<div>
<dt class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Hostname</dt>
<dd class="mt-1 text-sm text-zinc-900 dark:text-zinc-100 font-mono">
{@agent_token.metadata["hostname"]}
</dd>
</div>
<% end %>
<%= if @agent_token.metadata["version"] do %>
<div>
<dt class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Version</dt>
<dd class="mt-1 text-sm text-zinc-900 dark:text-zinc-100">
{@agent_token.metadata["version"]}
</dd>
</div>
<% end %>
<%= if @agent_token.metadata["uptime_seconds"] do %>
<div>
<dt class="text-sm font-medium text-zinc-600 dark:text-zinc-400">Uptime</dt>
<dd class="mt-1 text-sm text-zinc-900 dark:text-zinc-100">
{format_uptime(@agent_token.metadata["uptime_seconds"])}
</dd>
</div>
<% end %>
</dl>
</div>
<% end %>
<!-- Equipment List -->
<div class="mt-6 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg">
<div class="p-6 border-b border-zinc-200 dark:border-zinc-800">
<h3 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
Polling Targets
</h3>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Equipment that this agent is responsible for polling
</p>
</div>
<%= if @polling_targets == [] do %>
<div class="p-12 text-center">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-600" />
<h3 class="mt-4 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
No equipment assigned
</h3>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
This agent is not currently polling any equipment.
</p>
</div>
<% else %>
<div class="divide-y divide-zinc-200 dark:divide-zinc-800">
<%= for equipment <- @polling_targets do %>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{equipment.id}"}
class="block hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
<div class="p-6">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3">
<p class="text-sm font-medium text-zinc-900 dark:text-zinc-100 truncate">
{equipment.name}
</p>
<% {source, source_label} = assignment_source(equipment, @agent_token.id) %>
<span class={"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium #{source_badge_class(source)}"}>
{source_label}
</span>
</div>
<div class="mt-1 flex items-center gap-4 text-sm text-zinc-600 dark:text-zinc-400">
<span class="flex items-center gap-1">
<.icon name="hero-globe-alt" class="h-4 w-4" />
{equipment.ip_address}
</span>
<%= if equipment.site do %>
<span class="flex items-center gap-1">
<.icon name="hero-building-office" class="h-4 w-4" />
{equipment.site.name}
</span>
<% end %>
</div>
</div>
<.icon name="hero-chevron-right" class="h-5 w-5 text-zinc-400 dark:text-zinc-600" />
</div>
</div>
</.link>
<% end %>
</div>
<% end %>
</div>
</Layouts.authenticated>

View file

@ -165,6 +165,7 @@ defmodule ToweropsWeb.Router do
# Agent routes
live "/agents", AgentLive.Index, :index
live "/agents/:id", AgentLive.Show, :show
end
end
end