towerops/lib/towerops_web/live/agent_live/index.html.heex

418 lines
16 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="agents"
timezone={@timezone}
>
<.header>
{@page_title}
<:subtitle>Manage remote agents for local SNMP polling</:subtitle>
<:actions>
<.button phx-click={JS.show(to: "#new-agent-form")} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> New Agent
</.button>
</:actions>
</.header>
<!-- New Agent Form -->
<div id="new-agent-form" class="hidden mt-6">
<div class="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">
Create New Agent
</h3>
<.form for={@agent_form} phx-submit="create_agent">
<div class="space-y-4">
<.input
field={@agent_form[:name]}
type="text"
label="Agent Name"
placeholder="e.g., Datacenter A"
required
/>
<%= if @current_scope.user.is_superuser do %>
<.input
field={@agent_form[:is_cloud_poller]}
type="checkbox"
label="Cloud Poller (Application-wide agent, not tied to this organization)"
/>
<% end %>
<div class="flex gap-3">
<.button type="submit" variant="primary">
Create Agent
</.button>
<button
type="button"
phx-click={JS.hide(to: "#new-agent-form")}
class="px-3 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white"
>
Cancel
</button>
</div>
</div>
</.form>
</div>
</div>
<%= if @agent_tokens == [] do %>
<div class="text-center py-16">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">No agents</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Get started by creating your first remote agent.
</p>
<div class="mt-6">
<.button phx-click={JS.show(to: "#new-agent-form")} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> New Agent
</.button>
</div>
</div>
<% else %>
<div class="mt-6">
<.table id="agents-table" rows={@agent_tokens}>
<:col :let={agent} label="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}
</.link>
<%= if agent.metadata["version"] do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
v{agent.metadata["version"]}
</div>
<% end %>
</:col>
<:col :let={agent} label="Status">
<% {status, label} = agent_status(agent) %>
<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="Device">
<% counts = Map.get(@device_counts, agent.id, %{direct: 0, total: 0}) %>
<div class="text-sm text-gray-900 dark:text-white">
{counts.total} total
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{counts.direct} direct
<%= if counts.total > counts.direct do %>
· {counts.total - counts.direct} inherited
<% end %>
</div>
</:col>
<:col :let={agent} label="Last Seen">
<div class="text-sm text-gray-900 dark:text-white">
<.timestamp datetime={agent.last_seen_at} timezone={@timezone} />
</div>
<%= if agent.last_seen_at do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{format_datetime(agent.last_seen_at, @timezone)}
</div>
<% end %>
<%= if agent.last_ip do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono">
{agent.last_ip}
</div>
<% end %>
</:col>
<:col :let={agent} label="Metadata">
<%= if agent.metadata["hostname"] do %>
<div class="text-sm text-gray-600 dark:text-gray-400">
<.icon name="hero-computer-desktop" class="h-4 w-4 inline" />
{agent.metadata["hostname"]}
</div>
<% end %>
</:col>
<:col :let={agent} label="Created">
<div class="text-sm text-gray-600 dark:text-gray-400">
{ToweropsWeb.TimeHelpers.format_date(agent.inserted_at, @timezone)}
</div>
</:col>
<:action :let={agent}>
<%= if agent.enabled do %>
<div class="flex items-center gap-3">
<button
type="button"
phx-click="show_setup"
phx-value-id={agent.id}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
View Setup
</button>
<button
type="button"
phx-click="delete_agent"
phx-value-id={agent.id}
data-confirm="Are you sure you want to delete this agent? All device assignments will be removed, and devices will fall back to site/organization defaults or cloud polling."
class="text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Delete
</button>
</div>
<% else %>
<span class="text-xs text-gray-500 dark:text-gray-400">Disabled</span>
<% end %>
</:action>
</.table>
</div>
<% end %>
<%!-- Cloud Pollers Section (Superadmin Only) --%>
<%= if @current_scope.user.is_superuser && @cloud_pollers != [] do %>
<div class="mt-12">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Cloud Pollers
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Application-wide agents that can poll devices for any organization. Only visible and manageable by superadmins.
</p>
<.table id="cloud-pollers-table" rows={@cloud_pollers}>
<:col :let={agent} label="Name">
<div class="flex items-center gap-2">
<.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>
<span class="inline-flex items-center rounded-md bg-purple-50 px-2 py-1 text-xs font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10 dark:bg-purple-400/10 dark:text-purple-400 dark:ring-purple-400/30">
Cloud Poller
</span>
</div>
<%= if agent.metadata["version"] do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
v{agent.metadata["version"]}
</div>
<% end %>
</:col>
<:col :let={agent} label="Status">
<% {status, label} = agent_status(agent) %>
<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="Last Seen">
<div class="text-sm text-gray-900 dark:text-white">
<.timestamp datetime={agent.last_seen_at} timezone={@timezone} />
</div>
<%= if agent.last_seen_at do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{format_datetime(agent.last_seen_at, @timezone)}
</div>
<% end %>
<%= if agent.last_ip do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono">
{agent.last_ip}
</div>
<% end %>
</:col>
<:col :let={agent} label="Metadata">
<%= if agent.metadata["hostname"] do %>
<div class="text-sm text-gray-600 dark:text-gray-400">
<.icon name="hero-computer-desktop" class="h-4 w-4 inline" />
{agent.metadata["hostname"]}
</div>
<% end %>
</:col>
<:col :let={agent} label="Created">
<div class="text-sm text-gray-600 dark:text-gray-400">
{ToweropsWeb.TimeHelpers.format_date(agent.inserted_at, @timezone)}
</div>
</:col>
<:action :let={agent}>
<%= if agent.enabled do %>
<div class="flex items-center gap-3">
<button
type="button"
phx-click="show_setup"
phx-value-id={agent.id}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
View Setup
</button>
<button
type="button"
phx-click="delete_agent"
phx-value-id={agent.id}
data-confirm="Are you sure you want to delete this cloud poller? This will affect all organizations using it."
class="text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Delete
</button>
</div>
<% else %>
<span class="text-xs text-gray-500 dark:text-gray-400">Disabled</span>
<% end %>
</:action>
</.table>
</div>
<% end %>
<!-- Token Display Modal -->
<%= if @show_token_modal && @new_token do %>
<div
id="token-modal"
class="fixed inset-0 z-50 overflow-y-auto"
phx-mounted={JS.show()}
>
<div class="flex min-h-screen items-center justify-center p-4">
<div
class="fixed inset-0 bg-gray-500/75 dark:bg-gray-950/75 transition-opacity"
phx-click="close_token_modal"
>
</div>
<div class="relative bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full">
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Agent Setup Instructions
</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Use the token and Docker Compose configuration below to deploy this agent.
</p>
</div>
<button
type="button"
phx-click="close_token_modal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<.icon name="hero-x-mark" class="h-6 w-6" />
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Agent Name
</label>
<div class="text-base font-mono text-gray-900 dark:text-white">
{@new_token.agent_token.name}
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Authentication Token
</label>
<div class="relative">
<input
type="text"
readonly
value={@new_token.token}
class="w-full font-mono text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded px-3 py-2 pr-24 text-gray-900 dark:text-white"
id="agent-token-input"
/>
<button
type="button"
phx-hook="CopyToClipboard"
data-target="#agent-token-input"
id={"copy-token-#{@new_token.agent_token.id}"}
class="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1 text-xs font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
>
<.icon name="hero-clipboard" class="h-4 w-4" />
</button>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">
Docker Compose Setup with Auto-Updates
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
This configuration includes Watchtower for automatic agent updates. The agent will automatically update to the latest version every 15 minutes during development.
</p>
<pre class="text-xs font-mono text-gray-600 dark:text-gray-400 overflow-x-auto"><code>services:
towerops-agent:
image: {@agent_image}
container_name: towerops-agent
restart: unless-stopped
environment:
- TOWEROPS_API_URL={url(@socket, ~p"/")}
- TOWEROPS_AGENT_TOKEN={@new_token.token}
volumes:
- ./data:/data
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "com.centurylinklabs.watchtower.scope=towerops"
watchtower:
image: containrrr/watchtower:latest
container_name: towerops-watchtower
restart: unless-stopped
environment:
# Check for updates every 15 minutes (900 seconds)
- WATCHTOWER_POLL_INTERVAL=900
- WATCHTOWER_LABEL_ENABLE=true
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_LOG_LEVEL=info
# Use latest Docker API version
- DOCKER_API_VERSION=1.44
volumes:
- /var/run/docker.sock:/var/run/docker.sock</code></pre>
<button
type="button"
phx-hook="CopyToClipboard"
data-target="#docker-compose-example"
id={"copy-docker-compose-#{@new_token.agent_token.id}"}
class="mt-2 text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
>
<.icon name="hero-clipboard" class="h-3 w-3" /> Copy to clipboard
</button>
<textarea id="docker-compose-example" class="sr-only" readonly>services:
towerops-agent:
image: {@agent_image}
container_name: towerops-agent
restart: unless-stopped
environment:
- TOWEROPS_API_URL={url(@socket, ~p"/")}
- TOWEROPS_AGENT_TOKEN={@new_token.token}
volumes:
- ./data:/data
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "com.centurylinklabs.watchtower.scope=towerops"
watchtower:
image: containrrr/watchtower:latest
container_name: towerops-watchtower
restart: unless-stopped
environment:
# Check for updates every 15 minutes (900 seconds)
- WATCHTOWER_POLL_INTERVAL=900
- WATCHTOWER_LABEL_ENABLE=true
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_LOG_LEVEL=info
# Use latest Docker API version
- DOCKER_API_VERSION=1.44
volumes:
- /var/run/docker.sock:/var/run/docker.sock</textarea>
</div>
</div>
<div class="mt-6 flex justify-end">
<.button phx-click="close_token_modal" variant="primary">
I've Saved the Token
</.button>
</div>
</div>
</div>
</div>
</div>
<% end %>
</Layouts.authenticated>