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.
537 lines
22 KiB
Text
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>
|