towerops/lib/towerops_web/live/agent_live/index.html.heex
Graham McIntire 4e3f732f21
ui: comprehensive button styling audit - convert all text-styled actions to proper buttons
Fixed 10 text-styled action buttons across 7 files:

Agent Management:
- Delete Agent buttons (2x) → danger variant
- View Setup links (already fixed) → secondary variant

Organization Settings:
- Remove Member button → danger variant

Admin Pages:
- Edit Overrides button → secondary variant
- Delete Organization button → danger variant
- Remove IP/CIDR button → danger variant
- Delete User button → danger variant

Device Management:
- Delete Backup button → danger variant

All action buttons now use proper .button component with appropriate
variants (danger for destructive actions, secondary for neutral actions).
This provides consistent visual hierarchy and makes destructive actions
more clearly identifiable.
2026-03-09 15:32:42 -05:00

537 lines
22 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="agents"
>
<.header>
{@page_title}
<:subtitle>{t("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">
{t("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={t("Agent Name")}
placeholder="e.g., Datacenter A"
required
/>
<%= if @is_superuser do %>
<.input
field={@agent_form[:is_cloud_poller]}
type="checkbox"
label={t("Cloud Poller (Application-wide agent, not tied to this organization)")}
/>
<% end %>
<div class="flex gap-3">
<.button type="submit" variant="primary">
{t("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"
>
{t("Cancel")}
</button>
</div>
</div>
</.form>
</div>
</div>
<%= if !@has_agents 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">{t("No agents")}</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{t("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={@streams.agent_tokens}
row_link={fn {_id, agent} -> ~p"/agents/#{agent.id}" end}
>
<:col :let={{_id, agent}} label={t("Name")}>
<div class="flex items-center gap-2">
<span class="font-medium">{agent.name}</span>
<%= 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={{_id, agent}} label={t("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={{_id, agent}} label={t("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={{_id, agent}} label={t("Last Seen")}>
<div class="text-sm text-gray-900 dark:text-white">
<.timestamp datetime={agent.last_seen_at} timezone={@timezone} now={@now} />
</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} format="absolute" />
</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={{_id, agent}} label={t("Version")}>
<%= if agent.metadata["version"] do %>
<div class="text-sm text-gray-600 dark:text-gray-400 font-mono">
{format_agent_version(agent.metadata["version"])}
</div>
<% end %>
</:col>
<:col :let={{_id, agent}} label={t("Created")}>
<div class="text-sm text-gray-600 dark:text-gray-400">
{ToweropsWeb.TimeHelpers.format_date(agent.inserted_at, @timezone)}
</div>
</:col>
<:action :let={{_id, agent}}>
<%= if agent.enabled do %>
<div class="flex items-center gap-3">
<.button
type="button"
phx-click="show_setup"
phx-value-id={agent.id}
variant="secondary"
>
{t("View Setup")}
</.button>
<.button
type="button"
phx-click="delete_agent"
phx-value-id={agent.id}
data-confirm={
t(
"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."
)
}
variant="danger"
>
{t("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 && @has_cloud_pollers do %>
<div class="mt-12">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t("Cloud Pollers")}
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
{t(
"Application-wide agents that can poll devices for any organization. Only visible and manageable by superadmins."
)}
</p>
<.table
id="cloud-pollers-table"
rows={@streams.cloud_pollers}
row_link={fn {_id, agent} -> ~p"/agents/#{agent.id}" end}
>
<:col :let={{_id, agent}} label={t("Name")}>
<div class="flex items-center gap-2">
<span class="font-medium">{agent.name}</span>
<%= 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={{_id, agent}} label={t("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={{_id, agent}} label={t("Device")}>
<% counts = Map.get(@cloud_poller_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={{_id, agent}} label={t("Last Seen")}>
<div class="text-sm text-gray-900 dark:text-white">
<.timestamp datetime={agent.last_seen_at} timezone={@timezone} now={@now} />
</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} format="absolute" />
</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={{_id, agent}} label={t("Version")}>
<%= if agent.metadata["version"] do %>
<div class="text-sm text-gray-600 dark:text-gray-400 font-mono">
{format_agent_version(agent.metadata["version"])}
</div>
<% end %>
</:col>
<:col :let={{_id, agent}} label={t("Created")}>
<div class="text-sm text-gray-600 dark:text-gray-400">
{ToweropsWeb.TimeHelpers.format_date(agent.inserted_at, @timezone)}
</div>
</:col>
<:action :let={{_id, agent}}>
<%= if agent.enabled do %>
<div class="flex items-center gap-3">
<.button
type="button"
phx-click="show_setup"
phx-value-id={agent.id}
variant="secondary"
>
{t("View Setup")}
</.button>
<.button
type="button"
phx-click="delete_agent"
phx-value-id={agent.id}
data-confirm={
t(
"Are you sure you want to delete this cloud poller? This will affect all organizations using it."
)
}
variant="danger"
>
{t("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">
{t("Global Default Cloud Poller")}
</h3>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-4">
{t(
"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">
{t("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_list 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_list, &(&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" />
{t("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" />
{t(
"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"
>
{t("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">
{t("Agent Setup Instructions")}
</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{t("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">
{t("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">
{t("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>{t("Copy")}</span>
</button>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{t("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">
{t("Docker Compose Setup with Auto-Updates")}
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
{t(
"This configuration includes Watchtower for automatic agent updates. The agent will automatically update to the latest version every 12 hours."
)}
</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}
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 12 hours (43200 seconds)
- WATCHTOWER_POLL_INTERVAL=43200
- WATCHTOWER_LABEL_ENABLE=true
- WATCHTOWER_SCOPE=towerops
- 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 12 hours
- WATCHTOWER_POLL_INTERVAL=43200
- 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">
{t("Close")}
</.button>
</div>
</div>
</div>
</div>
</div>
<% end %>
</Layouts.authenticated>