Add server-initiated restart and self-update capabilities for remote agents. Restart broadcasts on the lifecycle PubSub topic causing the agent to exit cleanly for Docker to restart. Self-update fetches the latest release from GitHub, finds the matching binary for the agent's architecture, and sends the download URL with SHA256 checksum. - Add restart_agent/1 and update_agent/3 to Agents context - Handle :restart_requested and :update_requested in AgentChannel - Add superuser-only Restart and Update buttons on agent show page - Add ReleaseChecker module for GitHub Releases API with 5-min cache - Add arch field to AgentHeartbeat protobuf for architecture detection - Store arch in agent metadata from heartbeat
360 lines
15 KiB
Text
360 lines
15 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="agents"
|
|
>
|
|
<.header>
|
|
{@page_title}
|
|
<:subtitle>Agent Details and Health</:subtitle>
|
|
<:actions>
|
|
<button
|
|
:if={@current_scope.user.is_superuser}
|
|
phx-click="update_agent"
|
|
data-confirm="Are you sure you want to update this agent? It will download and replace the binary."
|
|
class="inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-green-600 text-white hover:bg-green-700 focus:ring-green-500 dark:bg-green-500 dark:hover:bg-green-600"
|
|
>
|
|
<.icon name="hero-arrow-up-tray" class="h-5 w-5" /> Update
|
|
</button>
|
|
<button
|
|
:if={@current_scope.user.is_superuser}
|
|
phx-click="restart_agent"
|
|
data-confirm="Are you sure you want to restart this agent? It will disconnect and restart its container."
|
|
class="inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 bg-amber-600 text-white hover:bg-amber-700 focus:ring-amber-500 dark:bg-amber-500 dark:hover:bg-amber-600"
|
|
>
|
|
<.icon name="hero-arrow-path" class="h-5 w-5" /> Restart
|
|
</button>
|
|
<.link
|
|
navigate={~p"/agents/#{@agent_token.id}/edit"}
|
|
class="inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600"
|
|
>
|
|
<.icon name="hero-pencil" class="h-5 w-5" /> Edit Agent
|
|
</.link>
|
|
<.link
|
|
navigate={~p"/agents"}
|
|
class="text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
|
|
>
|
|
<.icon name="hero-arrow-left" class="h-5 w-5" /> Back to Agents
|
|
</.link>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<!-- Tab Navigation (Superuser Only) -->
|
|
<div
|
|
:if={@current_scope.user.is_superuser}
|
|
class="mt-6 border-b border-gray-200 dark:border-white/10"
|
|
>
|
|
<nav class="-mb-px flex space-x-8">
|
|
<button
|
|
type="button"
|
|
phx-click="switch_tab"
|
|
phx-value-tab="overview"
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if(@active_tab == "overview",
|
|
do: "border-blue-500 text-blue-600 dark:text-blue-400",
|
|
else:
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
|
)
|
|
]}
|
|
>
|
|
Overview
|
|
</button>
|
|
<button
|
|
type="button"
|
|
phx-click="switch_tab"
|
|
phx-value-tab="debug"
|
|
class={[
|
|
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
|
|
if(@active_tab == "debug",
|
|
do: "border-blue-500 text-blue-600 dark:text-blue-400",
|
|
else:
|
|
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
|
)
|
|
]}
|
|
>
|
|
Debug Logs
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Debug Tab Content -->
|
|
<div :if={@current_scope.user.is_superuser && @active_tab == "debug"} class="mt-6">
|
|
<div class={[
|
|
"rounded-md p-4 mb-4",
|
|
if(@agent_token.allow_remote_debug,
|
|
do: "bg-green-50 dark:bg-green-900/20",
|
|
else: "bg-gray-50 dark:bg-gray-900/20"
|
|
)
|
|
]}>
|
|
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
|
Debug Status: {if @agent_token.allow_remote_debug,
|
|
do: "Enabled ✓",
|
|
else: "Disabled"}
|
|
</p>
|
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
|
<%= if @agent_token.allow_remote_debug do %>
|
|
Verbose logging is enabled. All SNMP data and agent messages are being logged.
|
|
<% else %>
|
|
Verbose logging is disabled. Enable it in the agent edit page to troubleshoot issues.
|
|
<% end %>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
|
View Debug Logs
|
|
</h3>
|
|
|
|
<div class="rounded-md bg-gray-50 dark:bg-gray-900 p-4">
|
|
<p class="text-sm mb-2 font-medium text-gray-900 dark:text-white">
|
|
Production (kubectl):
|
|
</p>
|
|
<code class="text-xs text-gray-800 dark:text-gray-200 block">
|
|
kubectl logs -n towerops deployment/towerops --tail=100 | grep "agent_token_id: {@agent_token.id}"
|
|
</code>
|
|
</div>
|
|
|
|
<div class="rounded-md bg-gray-50 dark:bg-gray-900 p-4">
|
|
<p class="text-sm mb-2 font-medium text-gray-900 dark:text-white">
|
|
Development (tail):
|
|
</p>
|
|
<code class="text-xs text-gray-800 dark:text-gray-200 block">
|
|
tail -f _build/dev/lib/towerops/priv/log/dev.log | grep "{@agent_token.id}"
|
|
</code>
|
|
</div>
|
|
|
|
<div class="mt-4">
|
|
<p class="text-sm font-medium mb-2 text-gray-900 dark:text-white">
|
|
What Gets Logged:
|
|
</p>
|
|
<ul class="text-sm text-gray-600 dark:text-gray-400 list-disc list-inside space-y-1">
|
|
<li>Agent connection/disconnection events</li>
|
|
<li>Job submissions (device IDs and job types)</li>
|
|
<li>SNMP discovery results (full OID maps)</li>
|
|
<li>Polling results (sensor readings, interface stats)</li>
|
|
<li>Error messages with full context</li>
|
|
<li>Ping submissions</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Overview Tab Content -->
|
|
<div :if={!@current_scope.user.is_superuser || @active_tab == "overview"}>
|
|
|
|
<!-- 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-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-600 dark:text-gray-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-gray-400 dark:text-gray-500" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Device Count Card -->
|
|
<div class="bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Device</p>
|
|
<p class="mt-2 text-3xl font-semibold text-gray-900 dark:text-white">
|
|
{length(@polling_targets)}
|
|
</p>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{@direct_assignments} direct
|
|
</p>
|
|
</div>
|
|
<.icon name="hero-server" class="h-8 w-8 text-gray-400 dark:text-gray-500" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Last Seen Card -->
|
|
<div class="bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
|
|
<div class="flex items-center justify-between">
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Last Seen</p>
|
|
<p class="mt-2 text-lg font-semibold text-gray-900 dark:text-white">
|
|
<.timestamp datetime={@agent_token.last_seen_at} timezone={@timezone} />
|
|
</p>
|
|
<%= if @agent_token.last_seen_at do %>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
<.timestamp
|
|
datetime={@agent_token.last_seen_at}
|
|
timezone={@timezone}
|
|
format="absolute"
|
|
/>
|
|
</p>
|
|
<% end %>
|
|
<%= if @agent_token.last_ip do %>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 font-mono">
|
|
{@agent_token.last_ip}
|
|
</p>
|
|
<% end %>
|
|
</div>
|
|
<.icon name="hero-clock" class="h-8 w-8 text-gray-400 dark:text-gray-500 flex-shrink-0" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Uptime Card -->
|
|
<div class="bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Uptime</p>
|
|
<%= if @agent_token.metadata["uptime_seconds"] do %>
|
|
<p class="mt-2 text-lg font-semibold text-gray-900 dark:text-white">
|
|
{format_uptime(@agent_token.metadata["uptime_seconds"])}
|
|
</p>
|
|
<% else %>
|
|
<p class="mt-2 text-lg font-semibold text-gray-500 dark:text-gray-400">
|
|
Unknown
|
|
</p>
|
|
<% end %>
|
|
</div>
|
|
<.icon name="hero-clock" class="h-8 w-8 text-gray-400 dark:text-gray-500" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Agent Details -->
|
|
<%= if @agent_token.metadata && map_size(@agent_token.metadata) > 0 do %>
|
|
<div class="mt-6 bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
Agent Details
|
|
</h3>
|
|
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<%= if @agent_token.metadata["version"] do %>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Version</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white font-mono">
|
|
{format_agent_version(@agent_token.metadata["version"])}
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
<%= if @agent_token.metadata["uptime_seconds"] do %>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Uptime</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
{format_uptime(@agent_token.metadata["uptime_seconds"])}
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
</dl>
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- Agent Timestamps -->
|
|
<div class="mt-6 bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
Timestamps
|
|
</h3>
|
|
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Created</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
<.timestamp datetime={@agent_token.inserted_at} timezone={@timezone} />
|
|
</dd>
|
|
</div>
|
|
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Last Updated</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
<.timestamp datetime={@agent_token.updated_at} timezone={@timezone} />
|
|
</dd>
|
|
</div>
|
|
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">
|
|
Last Seen (Heartbeat)
|
|
</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
|
<.timestamp datetime={@agent_token.last_seen_at} timezone={@timezone} />
|
|
</dd>
|
|
</div>
|
|
|
|
<%= if @agent_token.last_ip do %>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Last IP Address</dt>
|
|
<dd class="mt-1 text-sm text-gray-900 dark:text-white font-mono">
|
|
{@agent_token.last_ip}
|
|
</dd>
|
|
</div>
|
|
<% end %>
|
|
</dl>
|
|
</div>
|
|
|
|
<!-- Device List -->
|
|
<div class="mt-6 bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
|
|
<div class="p-6 border-b border-gray-200 dark:border-white/10">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
Polling Targets
|
|
</h3>
|
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
|
Device 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-gray-400 dark:text-gray-500" />
|
|
<h3 class="mt-4 text-sm font-semibold text-gray-900 dark:text-white">
|
|
No devices assigned
|
|
</h3>
|
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
This agent is not currently polling any device.
|
|
</p>
|
|
</div>
|
|
<% else %>
|
|
<div class="divide-y divide-gray-200 dark:divide-white/10">
|
|
<%= for device <- @polling_targets do %>
|
|
<.link
|
|
navigate={~p"/devices/#{device.id}"}
|
|
class="block hover:bg-gray-50 dark:hover:bg-gray-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-gray-900 dark:text-white truncate">
|
|
{device.name}
|
|
</p>
|
|
<% {source, source_label} = assignment_source(device, @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-gray-600 dark:text-gray-400">
|
|
<span class="flex items-center gap-1">
|
|
<.icon name="hero-globe-alt" class="h-4 w-4" />
|
|
{device.ip_address}
|
|
</span>
|
|
<%= if device.site do %>
|
|
<span class="flex items-center gap-1">
|
|
<.icon name="hero-building-office" class="h-4 w-4" />
|
|
{device.site.name}
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<.icon
|
|
name="hero-chevron-right"
|
|
class="h-5 w-5 text-gray-400 dark:text-gray-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</.link>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<!-- End Overview Tab -->
|
|
</Layouts.authenticated>
|