From 42975567004da6c418bf21490f7404627fbc2762 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 14 Jan 2026 18:46:03 -0600 Subject: [PATCH] Implement all quick win improvements for agent UI and functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/towerops_web/live/agent_live/index.ex | 19 +- .../live/agent_live/index.html.heex | 26 ++- lib/towerops_web/live/agent_live/show.ex | 126 ++++++++++++ .../live/agent_live/show.html.heex | 194 ++++++++++++++++++ lib/towerops_web/router.ex | 1 + 5 files changed, 357 insertions(+), 9 deletions(-) create mode 100644 lib/towerops_web/live/agent_live/show.ex create mode 100644 lib/towerops_web/live/agent_live/show.html.heex diff --git a/lib/towerops_web/live/agent_live/index.ex b/lib/towerops_web/live/agent_live/index.ex index e4067f52..e30d9ed0 100644 --- a/lib/towerops_web/live/agent_live/index.ex +++ b/lib/towerops_web/live/agent_live/index.ex @@ -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 diff --git a/lib/towerops_web/live/agent_live/index.html.heex b/lib/towerops_web/live/agent_live/index.html.heex index 10425514..1199dee9 100644 --- a/lib/towerops_web/live/agent_live/index.html.heex +++ b/lib/towerops_web/live/agent_live/index.html.heex @@ -63,7 +63,12 @@
<.table id="agents-table" rows={@agent_tokens}> <:col :let={agent} label="Name"> -
{agent.name}
+ <.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} + <%= if agent.metadata["version"] do %>
v{agent.metadata["version"]} @@ -73,17 +78,22 @@ <:col :let={agent} label="Status"> <% {status, label} = agent_status(agent) %> - + + {label} <:col :let={agent} label="Equipment"> + <% counts = Map.get(@equipment_counts, agent.id, %{direct: 0, total: 0}) %>
- {Map.get(@equipment_counts, agent.id, 0)} + {counts.total} total
- directly assigned + {counts.direct} direct + <%= if counts.total > counts.direct do %> + · {counts.total - counts.direct} inherited + <% end %>
@@ -223,7 +233,9 @@ - TOWEROPS_API_URL={url(@socket, ~p"/")} - TOWEROPS_AGENT_TOKEN={@new_token.token} volumes: - - ./data:/data + - ./data:/data + # Required for automatic self-updates + - /var/run/docker.sock:/var/run/docker.sock
diff --git a/lib/towerops_web/live/agent_live/show.ex b/lib/towerops_web/live/agent_live/show.ex new file mode 100644 index 00000000..5c8d71ef --- /dev/null +++ b/lib/towerops_web/live/agent_live/show.ex @@ -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 diff --git a/lib/towerops_web/live/agent_live/show.html.heex b/lib/towerops_web/live/agent_live/show.html.heex new file mode 100644 index 00000000..ca0768b4 --- /dev/null +++ b/lib/towerops_web/live/agent_live/show.html.heex @@ -0,0 +1,194 @@ + + <.header> + {@page_title} + <:subtitle>Agent Details and Health + <: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 + + + + + +
+ +
+
+
+

Status

+ <% {status, label} = agent_status(@agent_token) %> +
+ + + {label} + +
+
+ <.icon name="hero-signal" class="h-8 w-8 text-zinc-400 dark:text-zinc-600" /> +
+
+ + +
+
+
+

Equipment

+

+ {length(@polling_targets)} +

+

+ {@direct_assignments} direct +

+
+ <.icon name="hero-server" class="h-8 w-8 text-zinc-400 dark:text-zinc-600" /> +
+
+ + +
+
+
+

Last Seen

+

+ {format_last_seen(@agent_token.last_seen_at)} +

+ <%= if @agent_token.last_ip do %> +

+ {@agent_token.last_ip} +

+ <% end %> +
+ <.icon name="hero-clock" class="h-8 w-8 text-zinc-400 dark:text-zinc-600" /> +
+
+ + +
+
+
+

Agent Info

+ <%= if @agent_token.metadata["version"] do %> +

+ v{@agent_token.metadata["version"]} +

+ <% else %> +

+ Unknown +

+ <% end %> + <%= if @agent_token.metadata["uptime_seconds"] do %> +

+ Uptime: {format_uptime(@agent_token.metadata["uptime_seconds"])} +

+ <% end %> +
+ <.icon name="hero-information-circle" class="h-8 w-8 text-zinc-400 dark:text-zinc-600" /> +
+
+
+ + + <%= if @agent_token.metadata && map_size(@agent_token.metadata) > 0 do %> +
+

+ Agent Metadata +

+
+ <%= if @agent_token.metadata["hostname"] do %> +
+
Hostname
+
+ {@agent_token.metadata["hostname"]} +
+
+ <% end %> + <%= if @agent_token.metadata["version"] do %> +
+
Version
+
+ {@agent_token.metadata["version"]} +
+
+ <% end %> + <%= if @agent_token.metadata["uptime_seconds"] do %> +
+
Uptime
+
+ {format_uptime(@agent_token.metadata["uptime_seconds"])} +
+
+ <% end %> +
+
+ <% end %> + + +
+
+

+ Polling Targets +

+

+ Equipment that this agent is responsible for polling +

+
+ + <%= if @polling_targets == [] do %> +
+ <.icon name="hero-server" class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-600" /> +

+ No equipment assigned +

+

+ This agent is not currently polling any equipment. +

+
+ <% else %> +
+ <%= 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" + > +
+
+
+
+

+ {equipment.name} +

+ <% {source, source_label} = assignment_source(equipment, @agent_token.id) %> + + {source_label} + +
+
+ + <.icon name="hero-globe-alt" class="h-4 w-4" /> + {equipment.ip_address} + + <%= if equipment.site do %> + + <.icon name="hero-building-office" class="h-4 w-4" /> + {equipment.site.name} + + <% end %> +
+
+ <.icon name="hero-chevron-right" class="h-5 w-5 text-zinc-400 dark:text-zinc-600" /> +
+
+ + <% end %> +
+ <% end %> +
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 2f7889ed..cbf4c7e9 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -165,6 +165,7 @@ defmodule ToweropsWeb.Router do # Agent routes live "/agents", AgentLive.Index, :index + live "/agents/:id", AgentLive.Show, :show end end end