towerops/lib/towerops_web/live/agent_live/index.ex
Graham McIntire 4297556700
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
2026-01-14 18:46:03 -06:00

211 lines
7.1 KiB
Elixir

defmodule ToweropsWeb.AgentLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Agents
alias Towerops.Agents.Stats
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
# Get equipment counts for each agent (both direct and total with inheritance)
equipment_counts =
Map.new(agent_tokens, fn token ->
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
agent_health_stats = Stats.get_organization_agent_health(organization.id)
assignment_breakdown = Stats.get_equipment_assignment_breakdown(organization.id)
offline_agents = Stats.get_offline_agents(organization.id)
# Get agent image URL from config or use default
agent_image = Application.get_env(:towerops, :agent_docker_image, "towerops/agent:latest")
{:ok,
socket
|> assign(:page_title, "Remote Agents")
|> assign(:agent_tokens, agent_tokens)
|> assign(:equipment_counts, equipment_counts)
|> assign(:agent_health_stats, agent_health_stats)
|> assign(:assignment_breakdown, assignment_breakdown)
|> assign(:offline_agents, offline_agents)
|> assign(:agent_image, agent_image)
|> assign(:new_token, nil)
|> assign(:show_token_modal, false)
|> assign(:agent_form, to_form(%{"name" => ""}))}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
@impl true
def handle_event("create_agent", params, socket) do
name =
case params do
%{"agent_form" => %{"name" => n}} -> n
%{"name" => n} -> n
end
organization = socket.assigns.current_organization
case Agents.create_agent_token(organization.id, name) do
{:ok, agent_token, token} ->
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
# Refresh equipment counts
equipment_counts =
Map.new(agent_tokens, fn t ->
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
agent_health_stats = Stats.get_organization_agent_health(organization.id)
assignment_breakdown = Stats.get_equipment_assignment_breakdown(organization.id)
offline_agents = Stats.get_offline_agents(organization.id)
{:noreply,
socket
|> assign(:agent_tokens, agent_tokens)
|> assign(:equipment_counts, equipment_counts)
|> assign(:agent_health_stats, agent_health_stats)
|> assign(:assignment_breakdown, assignment_breakdown)
|> assign(:offline_agents, offline_agents)
|> assign(:new_token, %{agent_token: agent_token, token: token})
|> assign(:show_token_modal, true)
|> assign(:agent_form, to_form(%{"name" => ""}))
|> put_flash(:info, "Agent created successfully")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to create agent")}
end
end
@impl true
def handle_event("close_token_modal", _params, socket) do
{:noreply,
socket
|> assign(:show_token_modal, false)
|> assign(:new_token, nil)}
end
@impl true
def handle_event("show_setup", %{"id" => id}, socket) do
agent_token = Agents.get_agent_token!(id)
{:noreply,
socket
|> assign(:new_token, %{agent_token: agent_token, token: agent_token.token})
|> assign(:show_token_modal, true)}
end
@impl true
def handle_event("regenerate_token", %{"id" => id}, socket) do
agent_token = Agents.get_agent_token!(id)
organization = socket.assigns.current_organization
# Revoke the old token and create a new one
case Agents.revoke_agent_token(id) do
{:ok, _} ->
case Agents.create_agent_token(organization.id, agent_token.name) do
{:ok, new_agent_token, token} ->
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
{:noreply,
socket
|> assign(:agent_tokens, agent_tokens)
|> assign(:new_token, %{agent_token: new_agent_token, token: token})
|> assign(:show_token_modal, true)
|> put_flash(:info, "New token generated successfully")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to generate new token")}
end
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to revoke old token")}
end
end
@impl true
def handle_event("revoke_agent", %{"id" => id}, socket) do
case Agents.revoke_agent_token(id) do
{:ok, _} ->
organization = socket.assigns.current_organization
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
# Refresh health statistics
agent_health_stats = Stats.get_organization_agent_health(organization.id)
offline_agents = Stats.get_offline_agents(organization.id)
{:noreply,
socket
|> assign(:agent_tokens, agent_tokens)
|> assign(:agent_health_stats, agent_health_stats)
|> assign(:offline_agents, offline_agents)
|> put_flash(:info, "Agent revoked successfully")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to revoke agent")}
end
end
defp apply_action(socket, :index, _params) do
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
end