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:
parent
ae521d2108
commit
4297556700
5 changed files with 357 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
126
lib/towerops_web/live/agent_live/show.ex
Normal file
126
lib/towerops_web/live/agent_live/show.ex
Normal 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
|
||||
194
lib/towerops_web/live/agent_live/show.html.heex
Normal file
194
lib/towerops_web/live/agent_live/show.html.heex
Normal 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>
|
||||
|
|
@ -165,6 +165,7 @@ defmodule ToweropsWeb.Router do
|
|||
|
||||
# Agent routes
|
||||
live "/agents", AgentLive.Index, :index
|
||||
live "/agents/:id", AgentLive.Show, :show
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue