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

521 lines
21 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="agents"
>
<.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 @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">
<div class="flex items-center gap-2">
<.link
navigate={~p"/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.is_cloud_poller do %>
<span class="inline-flex items-center gap-1 rounded-full bg-purple-50 dark:bg-purple-900/30 px-2 py-0.5 text-xs font-medium text-purple-700 dark:text-purple-300 ring-1 ring-inset ring-purple-600/20 dark:ring-purple-400/30">
<.icon name="hero-cloud" class="h-3 w-3" /> Cloud
</span>
<% end %>
<%= if agent.allow_remote_debug do %>
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 dark:bg-amber-900/30 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-600/20 dark:ring-amber-400/30">
<.icon name="hero-bug-ant" class="h-3 w-3" /> Debug
</span>
<% end %>
</div>
</: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">
<.timestamp datetime={agent.last_seen_at} timezone={@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 %>
<%= if agent.metadata["version"] do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono">
v{agent.metadata["version"]}
</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 @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"/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.id == @global_default_cloud_poller_id do %>
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 dark:bg-green-900/30 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300 ring-1 ring-inset ring-green-600/20 dark:ring-green-400/30">
<.icon name="hero-check-circle" class="h-3 w-3" /> Default
</span>
<% end %>
<%= if agent.allow_remote_debug do %>
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 dark:bg-amber-900/30 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-600/20 dark:ring-amber-400/30">
<.icon name="hero-bug-ant" class="h-3 w-3" /> Debug
</span>
<% end %>
</div>
</: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">
<.timestamp datetime={agent.last_seen_at} timezone={@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 %>
<%= if agent.metadata["version"] do %>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono">
v{agent.metadata["version"]}
</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 %>
<%!-- Global Default Cloud Poller (Superadmin Only) --%>
<%= if @is_superuser do %>
<div class="mt-8">
<div class="bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800/30 rounded-lg p-6">
<h3 class="text-lg font-semibold text-amber-900 dark:text-amber-200 mb-2">
Global Default Cloud Poller
</h3>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-4">
Fallback agent for organizations without a default agent configured. Devices with no assignment at any level will use this agent.
</p>
<.form for={%{}} phx-change="update_selected_global_default" class="max-w-md">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
Select Global Default
</label>
<select
name="agent_token_id"
value={@selected_global_default}
class="block w-full rounded-md border-amber-300 dark:border-amber-700 bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow-sm focus:border-amber-500 focus:ring-amber-500 sm:text-sm"
>
<option value="">No global default (direct Phoenix polling)</option>
<%= for cloud_poller <- @cloud_pollers do %>
<option value={cloud_poller.id}>
{cloud_poller.name}
<%= if cloud_poller.id == @global_default_cloud_poller_id do %>
(Current)
<% end %>
</option>
<% end %>
</select>
</div>
<%= if @global_default_cloud_poller_id do %>
<% selected_poller =
Enum.find(@cloud_pollers, &(&1.id == @global_default_cloud_poller_id)) %>
<%= if selected_poller do %>
<p class="text-xs text-amber-600 dark:text-amber-400">
<.icon name="hero-check-circle" class="h-4 w-4 inline" /> Currently using:
<span class="font-medium">{selected_poller.name}</span>
</p>
<% else %>
<p class="text-xs text-red-600 dark:text-red-400">
<.icon name="hero-exclamation-triangle" class="h-4 w-4 inline" />
Warning: Selected agent no longer exists. Please choose a new agent.
</p>
<% end %>
<% else %>
<p class="text-xs text-amber-600 dark:text-amber-400">
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
No global default configured. Devices without assignments will use direct Phoenix cluster polling.
</p>
<% end %>
<div class="flex gap-3">
<.button
type="button"
phx-click="save_global_default"
variant="primary"
disabled={@selected_global_default == (@global_default_cloud_poller_id || "")}
>
<.icon name="hero-check" class="h-4 w-4" /> Save Changes
</.button>
<%= if @selected_global_default != (@global_default_cloud_poller_id || "") do %>
<button
type="button"
phx-click={
JS.push("update_selected_global_default",
value: %{agent_token_id: @global_default_cloud_poller_id || ""}
)
}
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>
<% end %>
</div>
</div>
</.form>
</div>
</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={String.trim(@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-28 text-gray-900 dark:text-white select-all"
id="agent-token-input"
onclick="this.select()"
/>
<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.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 rounded shadow-sm transition-colors flex items-center gap-1.5"
>
<.icon name="hero-clipboard" class="h-3.5 w-3.5" />
<span>Copy</span>
</button>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
Click the token to select all, or use the Copy button
</p>
</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}
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 day
- WATCHTOWER_POLL_INTERVAL=86400
- WATCHTOWER_LABEL_ENABLE=true
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_LOG_LEVEL=info
# Use latest Docker API version
- DOCKER_API_VERSION=1.44
</textarea>
</div>
</div>
<div class="mt-6 flex justify-end">
<.button phx-click="close_token_modal" variant="primary">
Close
</.button>
</div>
</div>
</div>
</div>
</div>
<% end %>
</Layouts.authenticated>