- Make insight messages more descriptive (explain what untracked/ghost/mismatch means) - Clarify action button labels on insight cards - Redesign Gaiia mapping page to be TowerOps-centric: devices/sites as primary rows, search Gaiia for what to link them to - Add IP-based match suggestions and smart sorting (suggestions first, then unlinked, then linked) - Add search_inventory_items/2 and search_network_sites/2 to Gaiia context - Make IP addresses clickable links (http://<ip>) - Add optional Gaiia App URL setting to enable deep links to inventory items and network sites
2396 lines
121 KiB
Text
2396 lines
121 KiB
Text
<Layouts.authenticated
|
||
flash={@flash}
|
||
current_scope={@current_scope}
|
||
>
|
||
<div class="pb-5">
|
||
<div class="mb-4">
|
||
<.link
|
||
navigate={~p"/dashboard"}
|
||
class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||
>
|
||
<.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Dashboard
|
||
</.link>
|
||
</div>
|
||
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||
{t("Organization Settings")}
|
||
</h1>
|
||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||
{t("Manage organization defaults, integrations, and configuration")}
|
||
</p>
|
||
</div>
|
||
|
||
<header class="border-b border-gray-200 dark:border-white/5">
|
||
<nav class="flex overflow-x-auto py-4">
|
||
<ul
|
||
role="list"
|
||
class="flex min-w-full flex-none gap-x-6 px-4 text-sm/6 font-semibold text-gray-500 sm:px-6 lg:px-8 dark:text-gray-400"
|
||
>
|
||
<li>
|
||
<.link
|
||
patch={~p"/orgs/#{@organization.slug}/settings?tab=general"}
|
||
class={
|
||
if @active_tab == "general", do: "text-indigo-600 dark:text-indigo-400", else: ""
|
||
}
|
||
>
|
||
{t("General")}
|
||
</.link>
|
||
</li>
|
||
<li>
|
||
<.link
|
||
patch={~p"/orgs/#{@organization.slug}/settings?tab=snmp"}
|
||
class={if @active_tab == "snmp", do: "text-indigo-600 dark:text-indigo-400", else: ""}
|
||
>
|
||
{t("SNMP")}
|
||
</.link>
|
||
</li>
|
||
<%= if @current_scope.user.is_superuser do %>
|
||
<%= if false do %>
|
||
<li>
|
||
<.link
|
||
patch={~p"/orgs/#{@organization.slug}/settings?tab=mikrotik"}
|
||
class={
|
||
if @active_tab == "mikrotik",
|
||
do: "text-indigo-600 dark:text-indigo-400",
|
||
else: ""
|
||
}
|
||
>
|
||
{t("MikroTik")}
|
||
</.link>
|
||
</li>
|
||
<% end %>
|
||
<% end %>
|
||
<li>
|
||
<.link
|
||
patch={~p"/orgs/#{@organization.slug}/settings?tab=agents"}
|
||
class={
|
||
if @active_tab == "agents", do: "text-indigo-600 dark:text-indigo-400", else: ""
|
||
}
|
||
>
|
||
{t("Agents")}
|
||
</.link>
|
||
</li>
|
||
<li>
|
||
<.link
|
||
patch={~p"/orgs/#{@organization.slug}/settings?tab=members"}
|
||
class={
|
||
if @active_tab == "members", do: "text-indigo-600 dark:text-indigo-400", else: ""
|
||
}
|
||
>
|
||
{t("Members")}
|
||
</.link>
|
||
</li>
|
||
<li>
|
||
<.link
|
||
patch={~p"/orgs/#{@organization.slug}/settings?tab=integrations"}
|
||
class={
|
||
if @active_tab == "integrations",
|
||
do: "text-indigo-600 dark:text-indigo-400",
|
||
else: ""
|
||
}
|
||
>
|
||
{t("Integrations")}
|
||
</.link>
|
||
</li>
|
||
<li>
|
||
<.link
|
||
patch={~p"/orgs/#{@organization.slug}/settings?tab=billing"}
|
||
class={
|
||
if @active_tab == "billing", do: "text-indigo-600 dark:text-indigo-400", else: ""
|
||
}
|
||
>
|
||
{t("Billing")}
|
||
</.link>
|
||
</li>
|
||
</ul>
|
||
</nav>
|
||
</header>
|
||
|
||
<%= if @active_tab not in ["integrations", "members"] do %>
|
||
<.form
|
||
for={@form}
|
||
id="organization-form"
|
||
phx-change="validate"
|
||
phx-submit="save"
|
||
data-1p-ignore
|
||
data-form-type="other"
|
||
>
|
||
<div class="divide-y divide-gray-200 dark:divide-white/10">
|
||
<%= if @active_tab == "general" do %>
|
||
<!-- Organization Name Section -->
|
||
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
|
||
<div>
|
||
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||
{t("Organization Name")}
|
||
</h2>
|
||
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||
{t("Update the name of your organization.")}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="md:col-span-2">
|
||
<.input
|
||
field={@form[:name]}
|
||
type="text"
|
||
label={t("Organization Name")}
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<%= if @user_orgs_count > 1 do %>
|
||
<!-- Default Organization Section -->
|
||
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
|
||
<div>
|
||
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||
{t("Default Organization")}
|
||
</h2>
|
||
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||
{t(
|
||
"Set this organization as your default. When you log in, you'll be directed to your default organization."
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="md:col-span-2">
|
||
<%= if @membership.is_default do %>
|
||
<div class="rounded-md bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 p-4">
|
||
<div class="flex items-center">
|
||
<.icon
|
||
name="hero-check-circle"
|
||
class="h-5 w-5 text-green-600 dark:text-green-400"
|
||
/>
|
||
<p class="ml-3 text-sm text-green-800 dark:text-green-200">
|
||
{t("This is your default organization")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<% else %>
|
||
<button
|
||
type="button"
|
||
phx-click="toggle_default_org"
|
||
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400"
|
||
>
|
||
<.icon name="hero-star" class="h-4 w-4 inline" /> Set as Default Organization
|
||
</button>
|
||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||
{t("Click to make this your default organization")}
|
||
</p>
|
||
<% end %>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
|
||
<!-- Use Sites Section -->
|
||
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
|
||
<div>
|
||
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||
{t("Site Organization")}
|
||
</h2>
|
||
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||
{t(
|
||
"Enable site organization to group devices into physical locations (offices, datacenters, etc.)."
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="md:col-span-2">
|
||
<.input
|
||
field={@form[:use_sites]}
|
||
type="checkbox"
|
||
label={t("Use sites to organize devices")}
|
||
/>
|
||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||
{t(
|
||
"When enabled, you can organize devices into sites. When disabled, all devices belong directly to the organization."
|
||
)}
|
||
</p>
|
||
|
||
<%= if @organization.use_sites && @form[:use_sites].value == false do %>
|
||
<div class="mt-4 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-4">
|
||
<div class="flex">
|
||
<div class="shrink-0">
|
||
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-amber-400" />
|
||
</div>
|
||
<div class="ml-3">
|
||
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||
{t("Warning: Disabling Sites")}
|
||
</h3>
|
||
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
||
<p>
|
||
{t(
|
||
"All devices will be removed from their current sites and assigned directly to the organization."
|
||
)}
|
||
{t(
|
||
"Site assignments will be lost, but devices will remain in the organization."
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @active_tab == "snmp" do %>
|
||
<!-- SNMP Configuration Section -->
|
||
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
|
||
<div>
|
||
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||
{t("SNMP Configuration")}
|
||
</h2>
|
||
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||
{t(
|
||
"Set default SNMP settings for all devices in this organization. These can be overridden at the site or device level."
|
||
)}
|
||
</p>
|
||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400 italic">
|
||
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
|
||
Hierarchy: Device > Site > Organization
|
||
</p>
|
||
</div>
|
||
|
||
<div class="md:col-span-2">
|
||
<.input
|
||
field={@form[:snmp_version]}
|
||
type="select"
|
||
label={t("SNMP Version")}
|
||
prompt="Select SNMP version"
|
||
options={[{"v1", "1"}, {"v2c", "2c"}, {"v3", "3"}]}
|
||
/>
|
||
|
||
<!-- v1/v2c Community String -->
|
||
<%= if @form[:snmp_version].value in ["1", "2c"] do %>
|
||
<.input
|
||
field={@form[:snmp_community]}
|
||
type="text"
|
||
label={t("SNMP Community String")}
|
||
placeholder="e.g., public"
|
||
/>
|
||
<% end %>
|
||
|
||
<!-- v3 Credentials -->
|
||
<%= if @form[:snmp_version].value == "3" do %>
|
||
<.input
|
||
field={@form[:snmpv3_security_level]}
|
||
type="select"
|
||
label={t("Security Level")}
|
||
prompt="Select security level"
|
||
options={[
|
||
{"No Auth, No Priv", "noAuthNoPriv"},
|
||
{"Auth, No Priv", "authNoPriv"},
|
||
{"Auth, Priv", "authPriv"}
|
||
]}
|
||
/>
|
||
|
||
<.input
|
||
field={@form[:snmpv3_username]}
|
||
type="text"
|
||
label={t("Username")}
|
||
placeholder="e.g., snmpuser"
|
||
/>
|
||
|
||
<!-- Auth fields (shown for authNoPriv and authPriv) -->
|
||
<%= if @form[:snmpv3_security_level].value in ["authNoPriv", "authPriv"] do %>
|
||
<.input
|
||
field={@form[:snmpv3_auth_protocol]}
|
||
type="select"
|
||
label={t("Auth Protocol")}
|
||
prompt="Select protocol"
|
||
options={[
|
||
{"SHA-256 (recommended)", "SHA-256"},
|
||
{"SHA-512", "SHA-512"},
|
||
{"SHA-384", "SHA-384"},
|
||
{"SHA-224", "SHA-224"},
|
||
{"SHA (SHA-1)", "SHA"},
|
||
{"MD5", "MD5"}
|
||
]}
|
||
/>
|
||
|
||
<.input
|
||
field={@form[:snmpv3_auth_password]}
|
||
type="password"
|
||
label={t("Auth Password")}
|
||
placeholder={t("Min 8 characters")}
|
||
autocomplete="off"
|
||
/>
|
||
<% end %>
|
||
|
||
<!-- Priv fields (shown only for authPriv) -->
|
||
<%= if @form[:snmpv3_security_level].value == "authPriv" do %>
|
||
<.input
|
||
field={@form[:snmpv3_priv_protocol]}
|
||
type="select"
|
||
label={t("Privacy Protocol")}
|
||
prompt="Select protocol"
|
||
options={[
|
||
{"AES-128 (recommended)", "AES"},
|
||
{"AES-256", "AES-256"},
|
||
{"AES-192", "AES-192"},
|
||
{"AES-256-C", "AES-256-C"},
|
||
{"DES (legacy)", "DES"}
|
||
]}
|
||
/>
|
||
|
||
<.input
|
||
field={@form[:snmpv3_priv_password]}
|
||
type="password"
|
||
label={t("Privacy Password")}
|
||
placeholder={t("Min 8 characters")}
|
||
autocomplete="off"
|
||
/>
|
||
<% end %>
|
||
<% end %>
|
||
|
||
<.input
|
||
field={@form[:snmp_port]}
|
||
type="number"
|
||
label={t("SNMP Port")}
|
||
placeholder="161"
|
||
/>
|
||
|
||
<%= if @organization.snmp_community do %>
|
||
<div class="mt-6 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-4">
|
||
<h3 class="text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
|
||
{t("Force Apply to All Devices")}
|
||
</h3>
|
||
<p class="text-sm text-amber-700 dark:text-amber-300 mb-3">
|
||
{t(
|
||
"This will override SNMP settings for ALL devices across all sites in this organization."
|
||
)}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
phx-click="apply_snmp_to_all"
|
||
data-confirm={
|
||
t(
|
||
"This will replace SNMP settings for ALL devices in this organization. Are you sure?"
|
||
)
|
||
}
|
||
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400"
|
||
>
|
||
<.icon name="hero-arrow-path" class="h-4 w-4 inline" />
|
||
{t("Apply SNMP Config to All Devices")}
|
||
</button>
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if false && @active_tab == "mikrotik" && @current_scope.user.is_superuser do %>
|
||
<!-- MikroTik API Configuration Section -->
|
||
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
|
||
<div>
|
||
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||
{t("MikroTik API Configuration")}
|
||
</h2>
|
||
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||
{t(
|
||
"Set default MikroTik RouterOS API credentials for all devices in this organization. Only applies to devices detected as MikroTik."
|
||
)}
|
||
</p>
|
||
<div class="mt-3 rounded-md bg-blue-50 dark:bg-blue-900/20 p-3">
|
||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||
<.icon name="hero-beaker" class="h-4 w-4 inline" />
|
||
<strong>{t("Experimental Feature:")}</strong>
|
||
{t("MikroTik API integration is under active development.")}
|
||
</p>
|
||
</div>
|
||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400 italic">
|
||
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
|
||
Hierarchy: Device > Site > Organization
|
||
</p>
|
||
</div>
|
||
|
||
<div class="md:col-span-2">
|
||
<.input
|
||
field={@form[:mikrotik_enabled]}
|
||
type="checkbox"
|
||
label={t("Enable MikroTik API")}
|
||
/>
|
||
|
||
<div class={"#{unless @form[:mikrotik_enabled].value, do: "hidden"}"}>
|
||
<.input
|
||
field={@form[:mikrotik_username]}
|
||
type="text"
|
||
label={t("Username")}
|
||
placeholder="e.g., admin"
|
||
autocomplete="off"
|
||
/>
|
||
</div>
|
||
|
||
<div class={"#{unless @form[:mikrotik_enabled].value, do: "hidden"}"}>
|
||
<.input
|
||
field={@form[:mikrotik_password]}
|
||
type="password"
|
||
label={t("Password")}
|
||
placeholder={t("RouterOS API password")}
|
||
autocomplete="off"
|
||
/>
|
||
</div>
|
||
|
||
<div class={"#{unless @form[:mikrotik_enabled].value, do: "hidden"}"}>
|
||
<.input
|
||
field={@form[:mikrotik_port]}
|
||
type="number"
|
||
label={t("API Port")}
|
||
placeholder="8729"
|
||
autocomplete="off"
|
||
/>
|
||
</div>
|
||
|
||
<div class={"#{unless @form[:mikrotik_enabled].value, do: "hidden"}"}>
|
||
<.input
|
||
field={@form[:mikrotik_use_ssl]}
|
||
type="checkbox"
|
||
label={t("Use SSL (API-SSL)")}
|
||
/>
|
||
</div>
|
||
|
||
<div class={"#{unless @form[:mikrotik_enabled].value, do: "hidden"} #{if @form[:mikrotik_use_ssl].value != false, do: "hidden"}"}>
|
||
<div class="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||
<p class="text-sm text-red-700 dark:text-red-300">
|
||
<strong>{t("Critical Security Warning:")}</strong>
|
||
{t(
|
||
"Plain API (port 8728) sends credentials unencrypted over the network. This setting is"
|
||
)}
|
||
<strong>blocked for devices using cloud pollers</strong>
|
||
and should only be used with local agents on trusted networks.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class={"#{unless @form[:mikrotik_enabled].value, do: "hidden"} #{if @form[:mikrotik_use_ssl].value == false, do: "hidden"}"}>
|
||
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-4">
|
||
<p class="text-sm text-yellow-700 dark:text-yellow-300">
|
||
<strong>{t("Security Warning:")}</strong>
|
||
{t(
|
||
"Plain API (port 8728) sends credentials unencrypted. Use SSL (port 8729) whenever possible."
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @active_tab == "agents" do %>
|
||
<!-- Default Agent Section -->
|
||
<%= if @available_agents != [] do %>
|
||
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
|
||
<div>
|
||
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||
{t("Default Agent")}
|
||
</h2>
|
||
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||
{t(
|
||
"Select a default agent for SNMP polling. This will be used for all devices in this organization unless overridden at the site or device level."
|
||
)}
|
||
</p>
|
||
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400 italic">
|
||
<.icon name="hero-information-circle" class="h-4 w-4 inline" />
|
||
Hierarchy: Device > Site > Organization
|
||
</p>
|
||
</div>
|
||
|
||
<div class="md:col-span-2">
|
||
<.input
|
||
field={@form[:default_agent_token_id]}
|
||
type="select"
|
||
label={t("Default Remote Agent")}
|
||
prompt="No default agent - cloud polling"
|
||
options={Enum.map(@available_agents, &{&1.name, &1.id})}
|
||
/>
|
||
|
||
<div class="rounded-md bg-gray-50 dark:bg-gray-800/50 p-4">
|
||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("Current Device Assignment:")}
|
||
</p>
|
||
<div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||
<div class="flex items-center gap-2">
|
||
<.icon name="hero-server" class="h-4 w-4" />
|
||
<span>
|
||
<strong>{@assignment_breakdown.direct}</strong> device-level override
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<.icon name="hero-building-office" class="h-4 w-4" />
|
||
<span>
|
||
<strong>{@assignment_breakdown.site}</strong> inherited from site
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<.icon name="hero-building-office-2" class="h-4 w-4" />
|
||
<span>
|
||
<strong>{@assignment_breakdown.organization}</strong>
|
||
inheriting organization default
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<.icon name="hero-cloud" class="h-4 w-4" />
|
||
<span>
|
||
<strong>{@assignment_breakdown.cloud}</strong> cloud polling (no agent)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<%= if @organization.default_agent_token_id do %>
|
||
<div class="mt-6 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-4">
|
||
<h3 class="text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
|
||
{t("Force Apply to All Devices")}
|
||
</h3>
|
||
<p class="text-sm text-amber-700 dark:text-amber-300 mb-3">
|
||
{t(
|
||
"This will assign the default agent to ALL devices across all sites in this organization."
|
||
)}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
phx-click="apply_agent_to_all"
|
||
data-confirm={
|
||
t(
|
||
"This will replace agent assignments for ALL devices in this organization. Are you sure?"
|
||
)
|
||
}
|
||
class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400"
|
||
>
|
||
<.icon name="hero-arrow-path" class="h-4 w-4 inline" />
|
||
{t("Apply Default Agent to All Devices")}
|
||
</button>
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
</div>
|
||
<% else %>
|
||
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
|
||
<div>
|
||
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||
{t("Default Agent")}
|
||
</h2>
|
||
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||
{t("No agents configured yet.")}
|
||
<.link
|
||
navigate={~p"/agents"}
|
||
class="text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||
>
|
||
{t("Create an agent")}
|
||
</.link>
|
||
to enable remote polling.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="md:col-span-2">
|
||
<!-- Empty space for layout consistency -->
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
<% end %>
|
||
</div>
|
||
|
||
<!-- Save Button at Bottom (not shown on integrations tab) -->
|
||
<div class="flex items-center justify-end gap-x-6 border-t border-gray-200 px-4 py-4 sm:px-6 lg:px-8 dark:border-white/10">
|
||
<.link
|
||
navigate={~p"/dashboard"}
|
||
class="text-sm font-semibold text-gray-900 dark:text-white"
|
||
>
|
||
{t("Cancel")}
|
||
</.link>
|
||
<button
|
||
type="submit"
|
||
phx-disable-with={t("Saving...")}
|
||
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
|
||
>
|
||
{t("Save Settings")}
|
||
</button>
|
||
</div>
|
||
</.form>
|
||
<% end %>
|
||
|
||
<%= if @active_tab == "members" do %>
|
||
<div class="divide-y divide-gray-200 dark:divide-white/10">
|
||
<!-- Invite form (owner/admin only) -->
|
||
<%= if @membership.role in [:owner, :admin] do %>
|
||
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
|
||
<div>
|
||
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||
{t("Invite a Team Member")}
|
||
</h2>
|
||
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||
{t("Send an invitation email to add someone to this organization.")}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="md:col-span-2">
|
||
<.form for={@invite_form} id="invite-form" phx-submit="send_invitation">
|
||
<div class="grid gap-4 sm:grid-cols-[1fr_10rem_auto] sm:items-end">
|
||
<.input
|
||
field={@invite_form[:email]}
|
||
type="email"
|
||
label={t("Email address")}
|
||
placeholder="colleague@example.com"
|
||
required
|
||
/>
|
||
<.input
|
||
field={@invite_form[:role]}
|
||
type="select"
|
||
label={t("Role")}
|
||
options={[
|
||
{"Admin", "admin"},
|
||
{"Executive", "executive"},
|
||
{"Technician", "technician"},
|
||
{"Viewer", "viewer"}
|
||
]}
|
||
/>
|
||
<div>
|
||
<label class="block text-sm/6 font-medium text-gray-900 dark:text-white invisible">
|
||
|
||
</label>
|
||
<button
|
||
type="submit"
|
||
phx-disable-with={t("Sending...")}
|
||
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 h-[42px]"
|
||
>
|
||
{t("Send Invite")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</.form>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
|
||
<!-- Pending invitations -->
|
||
<%= if @pending_invitations != [] do %>
|
||
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
|
||
<div>
|
||
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||
{t("Pending Invitations")}
|
||
</h2>
|
||
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||
{t("Invitations that haven't been accepted yet.")}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="md:col-span-2">
|
||
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-white/10">
|
||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||
<thead class="bg-gray-50 dark:bg-white/5">
|
||
<tr>
|
||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||
{t("Email")}
|
||
</th>
|
||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||
{t("Role")}
|
||
</th>
|
||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||
{t("Sent")}
|
||
</th>
|
||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||
{t("Expires")}
|
||
</th>
|
||
<%= if @membership.role in [:owner, :admin] do %>
|
||
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||
{t("Actions")}
|
||
</th>
|
||
<% end %>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-transparent">
|
||
<tr :for={invitation <- @pending_invitations}>
|
||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
|
||
{invitation.email}
|
||
</td>
|
||
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||
{invitation.role}
|
||
</span>
|
||
</td>
|
||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||
<.timestamp
|
||
datetime={invitation.inserted_at}
|
||
timezone={@timezone}
|
||
format="relative"
|
||
/>
|
||
</td>
|
||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||
<.timestamp
|
||
datetime={invitation.expires_at}
|
||
timezone={@timezone}
|
||
format="date"
|
||
/>
|
||
</td>
|
||
<%= if @membership.role in [:owner, :admin] do %>
|
||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||
<button
|
||
type="button"
|
||
phx-click="cancel_invitation"
|
||
phx-value-id={invitation.id}
|
||
data-confirm={t("Cancel this invitation?")}
|
||
class="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||
>
|
||
{t("Cancel")}
|
||
</button>
|
||
</td>
|
||
<% end %>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
|
||
<!-- Current members -->
|
||
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8">
|
||
<div>
|
||
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
|
||
{t("Members")}
|
||
</h2>
|
||
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
|
||
{t("People who have access to this organization.")}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="md:col-span-2">
|
||
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||
<thead class="bg-gray-50 dark:bg-white/5">
|
||
<tr>
|
||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||
{t("User")}
|
||
</th>
|
||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||
{t("Role")}
|
||
</th>
|
||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||
{t("Joined")}
|
||
</th>
|
||
<%= if @membership.role in [:owner, :admin] do %>
|
||
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||
{t("Actions")}
|
||
</th>
|
||
<% end %>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/10 dark:bg-transparent">
|
||
<tr :for={member <- @members}>
|
||
<td class="whitespace-nowrap px-4 py-3">
|
||
<div>
|
||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||
{member.user.email}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||
<span class={[
|
||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||
case member.role do
|
||
:owner ->
|
||
"bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
|
||
|
||
:admin ->
|
||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||
|
||
:executive ->
|
||
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
||
|
||
role when role in [:technician, :member] ->
|
||
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||
|
||
:viewer ->
|
||
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400"
|
||
end
|
||
]}>
|
||
{member.role}
|
||
</span>
|
||
</td>
|
||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||
<.timestamp
|
||
datetime={member.inserted_at}
|
||
timezone={@timezone}
|
||
format="absolute"
|
||
/>
|
||
</td>
|
||
<%= if @membership.role in [:owner, :admin] do %>
|
||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||
<%= if member.role != :owner do %>
|
||
<div class="flex items-center justify-end gap-3">
|
||
<form phx-change="change_role" phx-value-user-id={member.user_id}>
|
||
<select
|
||
name="role"
|
||
class="rounded-md border-gray-300 py-1 pl-2 pr-8 text-xs dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||
>
|
||
<option value="admin" selected={member.role == :admin}>
|
||
{t("Admin")}
|
||
</option>
|
||
<option value="executive" selected={member.role == :executive}>
|
||
{t("Executive")}
|
||
</option>
|
||
<option
|
||
value="technician"
|
||
selected={member.role in [:technician, :member]}
|
||
>
|
||
{t("Technician")}
|
||
</option>
|
||
<option value="viewer" selected={member.role == :viewer}>
|
||
{t("Viewer")}
|
||
</option>
|
||
</select>
|
||
</form>
|
||
<.button
|
||
type="button"
|
||
phx-click="remove_member"
|
||
phx-value-user-id={member.user_id}
|
||
data-confirm={t("Remove this member from the organization?")}
|
||
variant="danger"
|
||
>
|
||
{t("Remove")}
|
||
</.button>
|
||
</div>
|
||
<% end %>
|
||
</td>
|
||
<% end %>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @active_tab == "integrations" do %>
|
||
<div class="mt-8 space-y-5">
|
||
<%= for {provider, idx} <- Enum.with_index(@providers) do %>
|
||
<% billing_locked? =
|
||
@active_billing_provider != nil and provider.id in @billing_provider_ids and
|
||
provider.id != @active_billing_provider %>
|
||
<%!-- Skip locked billing providers — they're shown in the collapsed group below --%>
|
||
<%= unless billing_locked? do %>
|
||
<%!-- Section headers --%>
|
||
<%= if idx == 0 do %>
|
||
<div class="flex flex-wrap items-center gap-3">
|
||
<h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||
Billing & Subscriber Management
|
||
</h3>
|
||
<%= if @active_billing_provider == nil do %>
|
||
<span class="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-semibold text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
|
||
<.icon name="hero-arrow-path" class="h-3 w-3" /> Choose one
|
||
</span>
|
||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||
— only one billing platform can be active at a time
|
||
</span>
|
||
<% end %>
|
||
</div>
|
||
<% end %>
|
||
<%= if Map.has_key?(provider, :category) and (idx == 0 or Enum.at(@providers, idx - 1) |> Map.get(:category) != provider.category) do %>
|
||
<div class="pt-4 mt-4 border-t border-gray-200 dark:border-white/10">
|
||
<h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||
{provider.category}
|
||
</h3>
|
||
</div>
|
||
<% end %>
|
||
<div class={[
|
||
"relative overflow-hidden rounded-xl border bg-white shadow-sm transition-all dark:bg-white/[0.03]",
|
||
cond do
|
||
@integrations[provider.id] &&
|
||
@integrations[provider.id].last_sync_status == "failed" ->
|
||
"border-red-200 dark:border-red-500/30"
|
||
|
||
@integrations[provider.id] && @integrations[provider.id].enabled ->
|
||
"border-green-200 dark:border-green-500/20"
|
||
|
||
true ->
|
||
"border-gray-200 dark:border-white/10"
|
||
end
|
||
]}>
|
||
<%!-- Left status accent bar --%>
|
||
<div class={[
|
||
"absolute inset-y-0 left-0 w-1",
|
||
cond do
|
||
@integrations[provider.id] &&
|
||
@integrations[provider.id].last_sync_status == "failed" ->
|
||
"bg-red-500"
|
||
|
||
@integrations[provider.id] && @integrations[provider.id].enabled ->
|
||
"bg-green-500"
|
||
|
||
@integrations[provider.id] ->
|
||
"bg-gray-300 dark:bg-gray-600"
|
||
|
||
true ->
|
||
"bg-gray-200 dark:bg-gray-700"
|
||
end
|
||
]} />
|
||
|
||
<div class="pl-5 pr-6 py-5">
|
||
<%!-- === Header Row === --%>
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center gap-4">
|
||
<div class={[
|
||
"shrink-0 flex h-11 w-11 items-center justify-center rounded-full",
|
||
case provider.id do
|
||
"preseem" -> "bg-blue-100 dark:bg-blue-900/40"
|
||
"gaiia" -> "bg-purple-100 dark:bg-purple-900/40"
|
||
"pagerduty" -> "bg-emerald-100 dark:bg-emerald-900/40"
|
||
"netbox" -> "bg-orange-100 dark:bg-orange-900/40"
|
||
"sonar" -> "bg-violet-100 dark:bg-violet-900/40"
|
||
"splynx" -> "bg-blue-100 dark:bg-blue-900/40"
|
||
"visp" -> "bg-green-100 dark:bg-green-900/40"
|
||
_ -> "bg-indigo-100 dark:bg-indigo-900/40"
|
||
end
|
||
]}>
|
||
<.icon
|
||
name={provider.icon}
|
||
class={[
|
||
"h-5 w-5",
|
||
case provider.id do
|
||
"preseem" -> "text-blue-600 dark:text-blue-400"
|
||
"gaiia" -> "text-purple-600 dark:text-purple-400"
|
||
"pagerduty" -> "text-emerald-600 dark:text-emerald-400"
|
||
"netbox" -> "text-orange-600 dark:text-orange-400"
|
||
"sonar" -> "text-violet-600 dark:text-violet-400"
|
||
"splynx" -> "text-blue-600 dark:text-blue-400"
|
||
"visp" -> "text-green-600 dark:text-green-400"
|
||
_ -> "text-indigo-600 dark:text-indigo-400"
|
||
end
|
||
]}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||
{provider.name}
|
||
</h3>
|
||
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
||
{provider.description}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- === Actions (top right) === --%>
|
||
<div class="flex items-center gap-4 shrink-0 ml-4">
|
||
<%= if integration = @integrations[provider.id] do %>
|
||
<div class="flex items-center gap-2">
|
||
<span class={[
|
||
"text-xs font-medium",
|
||
if(integration.enabled,
|
||
do: "text-green-700 dark:text-green-400",
|
||
else: "text-gray-500 dark:text-gray-400"
|
||
)
|
||
]}>
|
||
{if integration.enabled, do: "Enabled", else: "Disabled"}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
phx-click="toggle_enabled"
|
||
phx-value-provider={provider.id}
|
||
class={[
|
||
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2 dark:focus:ring-offset-gray-900",
|
||
if(integration.enabled,
|
||
do: "bg-indigo-600",
|
||
else: "bg-gray-200 dark:bg-gray-700"
|
||
)
|
||
]}
|
||
>
|
||
<span class={[
|
||
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
|
||
if(integration.enabled, do: "translate-x-5", else: "translate-x-0")
|
||
]}>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<% end %>
|
||
|
||
<button
|
||
type="button"
|
||
id={"configure-#{provider.id}"}
|
||
phx-click="configure"
|
||
phx-value-provider={provider.id}
|
||
class={[
|
||
"rounded-lg px-4 py-2 text-sm font-semibold shadow-xs transition-colors",
|
||
if(@configuring == provider.id,
|
||
do:
|
||
"bg-gray-100 text-gray-700 ring-1 ring-inset ring-gray-300 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10",
|
||
else:
|
||
"bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
|
||
)
|
||
]}
|
||
>
|
||
{if @configuring == provider.id, do: "Close", else: "Configure"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- === Status Row (when configured) === --%>
|
||
<%= if integration = @integrations[provider.id] do %>
|
||
<%!-- Failed sync banner --%>
|
||
<%= if integration.last_sync_status == "failed" do %>
|
||
<div class="mt-4 flex items-start gap-3 rounded-lg bg-red-50 px-4 py-3 dark:bg-red-900/20">
|
||
<.icon
|
||
name="hero-exclamation-triangle"
|
||
class="h-5 w-5 shrink-0 text-red-500 dark:text-red-400 mt-0.5"
|
||
/>
|
||
<div>
|
||
<p class="text-sm font-medium text-red-800 dark:text-red-300">
|
||
{t("Last sync failed")}
|
||
</p>
|
||
<%= if integration.last_sync_message && integration.last_sync_message != "" do %>
|
||
<p class="mt-0.5 text-sm text-red-700 dark:text-red-400">
|
||
{integration.last_sync_message}
|
||
</p>
|
||
<% end %>
|
||
<%= if integration.last_synced_at do %>
|
||
<p class="mt-1 text-xs text-red-600/70 dark:text-red-400/60">
|
||
<.timestamp
|
||
datetime={integration.last_synced_at}
|
||
timezone={@timezone}
|
||
format="absolute"
|
||
/>
|
||
</p>
|
||
<% end %>
|
||
</div>
|
||
</div>
|
||
<% else %>
|
||
<div class="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
|
||
<%!-- Status dot + label --%>
|
||
<div class="flex items-center gap-2">
|
||
<span class={[
|
||
"inline-block h-2 w-2 rounded-full",
|
||
if(integration.enabled,
|
||
do: "bg-green-500",
|
||
else: "bg-gray-400 dark:bg-gray-500"
|
||
)
|
||
]} />
|
||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||
{if integration.enabled, do: "Connected", else: "Paused"}
|
||
</span>
|
||
</div>
|
||
|
||
<%!-- Last synced --%>
|
||
<%= if integration.last_synced_at do %>
|
||
<div class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||
<.icon name="hero-clock" class="h-3.5 w-3.5" />
|
||
<span>
|
||
{t("Synced")}
|
||
<.timestamp
|
||
datetime={integration.last_synced_at}
|
||
timezone={@timezone}
|
||
format="absolute"
|
||
/>
|
||
</span>
|
||
</div>
|
||
<% else %>
|
||
<span class="text-sm italic text-gray-400 dark:text-gray-500">
|
||
{t("Never synced")}
|
||
</span>
|
||
<% end %>
|
||
|
||
<%!-- Sync status badge (success/partial) --%>
|
||
<%= if integration.last_sync_status && integration.last_sync_status not in ["never", "failed"] do %>
|
||
<span class={[
|
||
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||
case integration.last_sync_status do
|
||
"success" ->
|
||
"bg-green-50 text-green-700 ring-1 ring-inset ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20"
|
||
|
||
"partial" ->
|
||
"bg-yellow-50 text-yellow-700 ring-1 ring-inset ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20"
|
||
|
||
_ ->
|
||
"bg-gray-50 text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:bg-gray-500/10 dark:text-gray-400 dark:ring-gray-500/20"
|
||
end
|
||
]}>
|
||
<%= if integration.last_sync_status == "success" do %>
|
||
<.icon name="hero-check-circle-mini" class="h-3.5 w-3.5" />
|
||
<% end %>
|
||
{String.capitalize(integration.last_sync_status)}
|
||
</span>
|
||
<% end %>
|
||
|
||
<%!-- Sync message (success/partial) --%>
|
||
<%= if integration.last_sync_message && integration.last_sync_message != "" && integration.last_sync_status != "failed" do %>
|
||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||
{integration.last_sync_message}
|
||
</span>
|
||
<% end %>
|
||
|
||
<%!-- Next sync --%>
|
||
<%= if integration.last_synced_at && integration.enabled do %>
|
||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||
Next in ~{next_sync_minutes(
|
||
integration.last_synced_at,
|
||
integration.sync_interval_minutes
|
||
)}m
|
||
</span>
|
||
<% end %>
|
||
|
||
<%!-- Sync schedule note --%>
|
||
<%= if integration.enabled && provider.id == "pagerduty" do %>
|
||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||
· Event-driven (real-time)
|
||
</span>
|
||
<% end %>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%!-- === Provider Links Row === --%>
|
||
<%= if provider.id == "preseem" || provider.id == "gaiia" do %>
|
||
<div class="mt-3 flex flex-wrap gap-3">
|
||
<%= if provider.id == "preseem" do %>
|
||
<.link
|
||
navigate={
|
||
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices"
|
||
}
|
||
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
|
||
>
|
||
<.icon name="hero-cpu-chip" class="h-3.5 w-3.5" /> Manage Devices
|
||
</.link>
|
||
<.link
|
||
navigate={~p"/insights?source=preseem"}
|
||
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
|
||
>
|
||
<.icon name="hero-chart-bar" class="h-3.5 w-3.5" /> Network Insights
|
||
</.link>
|
||
<% end %>
|
||
<%= if provider.id == "gaiia" do %>
|
||
<.link
|
||
navigate={
|
||
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"
|
||
}
|
||
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
|
||
>
|
||
<.icon name="hero-arrows-right-left" class="h-3.5 w-3.5" /> Entity Mapping
|
||
</.link>
|
||
<.link
|
||
navigate={
|
||
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation"
|
||
}
|
||
class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10"
|
||
>
|
||
<.icon name="hero-scale" class="h-3.5 w-3.5" /> Reconciliation
|
||
</.link>
|
||
<% end %>
|
||
</div>
|
||
<% end %>
|
||
<% end %>
|
||
</div>
|
||
|
||
<%!-- === Configure Panel === --%>
|
||
<%= if @configuring == provider.id do %>
|
||
<div class="border-t border-gray-200 bg-gray-50/50 px-6 py-6 dark:border-white/10 dark:bg-white/[0.02]">
|
||
<%= if provider.id == "netbox" do %>
|
||
<.form
|
||
for={@integration_form}
|
||
id="netbox-form"
|
||
phx-change="validate_integration"
|
||
phx-submit="save_integration"
|
||
>
|
||
<div class="space-y-6">
|
||
<%!-- Connection Section --%>
|
||
<div>
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<.icon name="hero-link" class="h-4 w-4 text-gray-400" />
|
||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||
{t("Connection")}
|
||
</h4>
|
||
</div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4 ml-6">
|
||
{t("Your NetBox instance URL and API token.")}
|
||
</p>
|
||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
<div>
|
||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||
{t("NetBox URL")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="integration[url]"
|
||
value={get_credential(@integrations["netbox"], "url")}
|
||
placeholder="https://netbox.example.com"
|
||
autocomplete="off"
|
||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||
{t("API Token")}
|
||
</label>
|
||
<input
|
||
type="password"
|
||
name="integration[api_token]"
|
||
value={get_credential(@integrations["netbox"], "api_token")}
|
||
placeholder={t("Enter your NetBox API token")}
|
||
autocomplete="off"
|
||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="mt-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 p-4">
|
||
<h4 class="flex items-center gap-1.5 text-xs font-semibold text-blue-900 dark:text-blue-300 mb-2">
|
||
<.icon name="hero-information-circle" class="h-4 w-4" />
|
||
{t("How to create your API token")}
|
||
</h4>
|
||
<ol class="list-decimal list-inside space-y-1 text-xs text-blue-800 dark:text-blue-300 ml-5">
|
||
<%= for step <- provider.setup_help.steps do %>
|
||
<li>{raw(format_help_step(step))}</li>
|
||
<% end %>
|
||
</ol>
|
||
<p class="mt-2.5 text-xs text-blue-700 dark:text-blue-400 ml-5">
|
||
<.icon
|
||
name="hero-shield-check"
|
||
class="h-3.5 w-3.5 inline -mt-0.5"
|
||
/>
|
||
{raw(format_help_step(provider.setup_help.permissions))}
|
||
</p>
|
||
<p class="mt-2 text-xs text-blue-600/80 dark:text-blue-400/70 ml-5">
|
||
<.icon
|
||
name="hero-arrow-top-right-on-square"
|
||
class="h-3 w-3 inline -mt-0.5"
|
||
/> Example URL:
|
||
<code class="font-mono">{provider.setup_help.url_pattern}</code>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Sync Direction Section --%>
|
||
<div class="border-t border-gray-200 pt-6 dark:border-white/10">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<.icon name="hero-arrows-right-left" class="h-4 w-4 text-gray-400" />
|
||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||
{t("Sync Direction")}
|
||
</h4>
|
||
</div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4 ml-6">
|
||
{t(
|
||
"Choose how data flows between TowerOps and NetBox. You can change this later."
|
||
)}
|
||
</p>
|
||
<input
|
||
type="hidden"
|
||
name="integration[sync_direction]"
|
||
value={@netbox_config["sync_direction"] || "pull"}
|
||
/>
|
||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||
<div
|
||
phx-click="set_netbox_sync_direction"
|
||
phx-value-direction="pull"
|
||
class={[
|
||
"relative flex cursor-pointer rounded-lg border p-4 shadow-xs transition-all",
|
||
if(
|
||
@netbox_config["sync_direction"] in ["pull", "", nil],
|
||
do:
|
||
"border-indigo-600 ring-2 ring-indigo-600 bg-indigo-50 dark:bg-indigo-900/20",
|
||
else:
|
||
"border-gray-200 dark:border-white/10 hover:border-gray-300 dark:hover:border-white/20 hover:shadow-md"
|
||
)
|
||
]}
|
||
>
|
||
<div class="flex flex-col">
|
||
<span class="flex items-center gap-1.5 text-sm font-semibold text-gray-900 dark:text-white">
|
||
<.icon
|
||
name="hero-arrow-down-tray"
|
||
class="h-4 w-4 text-indigo-600"
|
||
/> NetBox → TowerOps
|
||
</span>
|
||
<span class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||
{t(
|
||
"NetBox is the source of truth. Import devices, sites, and IPs from NetBox into TowerOps."
|
||
)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div
|
||
phx-click="set_netbox_sync_direction"
|
||
phx-value-direction="push"
|
||
class={[
|
||
"relative flex cursor-pointer rounded-lg border p-4 shadow-xs transition-all",
|
||
if(
|
||
@netbox_config["sync_direction"] == "push",
|
||
do:
|
||
"border-indigo-600 ring-2 ring-indigo-600 bg-indigo-50 dark:bg-indigo-900/20",
|
||
else:
|
||
"border-gray-200 dark:border-white/10 hover:border-gray-300 dark:hover:border-white/20 hover:shadow-md"
|
||
)
|
||
]}
|
||
>
|
||
<div class="flex flex-col">
|
||
<span class="flex items-center gap-1.5 text-sm font-semibold text-gray-900 dark:text-white">
|
||
<.icon name="hero-arrow-up-tray" class="h-4 w-4 text-orange-600" />
|
||
TowerOps → NetBox
|
||
</span>
|
||
<span class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||
{t(
|
||
"TowerOps is the source of truth. Push discovered devices and monitoring data to NetBox."
|
||
)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div
|
||
phx-click="set_netbox_sync_direction"
|
||
phx-value-direction="both"
|
||
class={[
|
||
"relative flex cursor-pointer rounded-lg border p-4 shadow-xs transition-all",
|
||
if(
|
||
@netbox_config["sync_direction"] == "both",
|
||
do:
|
||
"border-indigo-600 ring-2 ring-indigo-600 bg-indigo-50 dark:bg-indigo-900/20",
|
||
else:
|
||
"border-gray-200 dark:border-white/10 hover:border-gray-300 dark:hover:border-white/20 hover:shadow-md"
|
||
)
|
||
]}
|
||
>
|
||
<div class="flex flex-col">
|
||
<span class="flex items-center gap-1.5 text-sm font-semibold text-gray-900 dark:text-white">
|
||
<.icon
|
||
name="hero-arrows-right-left"
|
||
class="h-4 w-4 text-green-600"
|
||
/> Bidirectional
|
||
</span>
|
||
<span class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||
{t(
|
||
"Merge data from both systems. Conflicts resolved by most-recently-updated. Requires write token."
|
||
)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- What to Sync Section --%>
|
||
<div class="border-t border-gray-200 pt-6 dark:border-white/10">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<.icon name="hero-circle-stack" class="h-4 w-4 text-gray-400" />
|
||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||
{t("What to Sync")}
|
||
</h4>
|
||
</div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4 ml-6">
|
||
{t(
|
||
"Choose which NetBox objects participate in sync. Devices and sites are recommended at minimum."
|
||
)}
|
||
</p>
|
||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<label class="flex items-start gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5">
|
||
<input
|
||
type="hidden"
|
||
name="integration[sync_devices]"
|
||
value="false"
|
||
/>
|
||
<input
|
||
type="checkbox"
|
||
name="integration[sync_devices]"
|
||
value="true"
|
||
checked={@netbox_config["sync_devices"]}
|
||
class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-600"
|
||
/>
|
||
<div>
|
||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||
{t("Devices")}
|
||
</span>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
{t("Name, IP, role, platform, status, serial number")}
|
||
</p>
|
||
</div>
|
||
</label>
|
||
<label class="flex items-start gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5">
|
||
<input
|
||
type="hidden"
|
||
name="integration[sync_sites]"
|
||
value="false"
|
||
/>
|
||
<input
|
||
type="checkbox"
|
||
name="integration[sync_sites]"
|
||
value="true"
|
||
checked={@netbox_config["sync_sites"]}
|
||
class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-600"
|
||
/>
|
||
<div>
|
||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||
{t("Sites")}
|
||
</span>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
{t("Site names, locations, addresses, and coordinates")}
|
||
</p>
|
||
</div>
|
||
</label>
|
||
<label class="flex items-start gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5">
|
||
<input
|
||
type="hidden"
|
||
name="integration[sync_ip_addresses]"
|
||
value="false"
|
||
/>
|
||
<input
|
||
type="checkbox"
|
||
name="integration[sync_ip_addresses]"
|
||
value="true"
|
||
checked={@netbox_config["sync_ip_addresses"]}
|
||
class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-600"
|
||
/>
|
||
<div>
|
||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||
{t("IP Addresses")}
|
||
</span>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
{t("IPAM data — assigned IPs, prefixes, VRFs")}
|
||
</p>
|
||
</div>
|
||
</label>
|
||
<label class="flex items-start gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-white/10 dark:hover:bg-white/5">
|
||
<input
|
||
type="hidden"
|
||
name="integration[sync_interfaces]"
|
||
value="false"
|
||
/>
|
||
<input
|
||
type="checkbox"
|
||
name="integration[sync_interfaces]"
|
||
value="true"
|
||
checked={@netbox_config["sync_interfaces"]}
|
||
class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-600"
|
||
/>
|
||
<div>
|
||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||
{t("Interfaces")}
|
||
</span>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
{t("Physical and virtual interfaces, LAGs, connections")}
|
||
</p>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Filtering Section --%>
|
||
<div class="border-t border-gray-200 pt-6 dark:border-white/10">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<.icon name="hero-funnel" class="h-4 w-4 text-gray-400" />
|
||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||
{t("Filters")}
|
||
</h4>
|
||
<span class="text-xs font-normal text-gray-400 dark:text-gray-500">
|
||
(optional)
|
||
</span>
|
||
</div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4 ml-6">
|
||
{t(
|
||
"Narrow the sync scope. Leave blank to sync everything. Comma-separated for multiple values."
|
||
)}
|
||
</p>
|
||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
{t("Device Role")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="integration[device_role_filter]"
|
||
value={@netbox_config["device_role_filter"]}
|
||
placeholder="e.g. access-point, router"
|
||
class="block w-full rounded-md border-gray-300 py-1.5 text-sm shadow-xs dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
{t("Site")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="integration[site_filter]"
|
||
value={@netbox_config["site_filter"]}
|
||
placeholder="e.g. tower-north, main-pop"
|
||
class="block w-full rounded-md border-gray-300 py-1.5 text-sm shadow-xs dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
{t("Tag")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="integration[tag_filter]"
|
||
value={@netbox_config["tag_filter"]}
|
||
placeholder="e.g. monitored, production"
|
||
class="block w-full rounded-md border-gray-300 py-1.5 text-sm shadow-xs dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Sync Interval --%>
|
||
<div class="border-t border-gray-200 pt-6 dark:border-white/10">
|
||
<div class="max-w-xs">
|
||
<.input
|
||
field={@integration_form[:sync_interval_minutes]}
|
||
type="number"
|
||
label={t("Sync interval (minutes)")}
|
||
min="5"
|
||
value={
|
||
if(@integrations["netbox"],
|
||
do: @integrations["netbox"].sync_interval_minutes,
|
||
else: 30
|
||
)
|
||
}
|
||
/>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||
{t(
|
||
"How often TowerOps checks NetBox for changes. 30 minutes is recommended for most setups."
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Test & Save --%>
|
||
<%= if @test_result do %>
|
||
<div class={[
|
||
"flex items-start gap-3 rounded-lg p-4",
|
||
case @test_result do
|
||
{:ok, _} -> "bg-green-50 dark:bg-green-900/20"
|
||
{:error, _} -> "bg-red-50 dark:bg-red-900/20"
|
||
end
|
||
]}>
|
||
<%= case @test_result do %>
|
||
<% {:ok, msg} -> %>
|
||
<.icon
|
||
name="hero-check-circle"
|
||
class="h-5 w-5 shrink-0 text-green-500 dark:text-green-400"
|
||
/>
|
||
<p class="text-sm font-medium text-green-800 dark:text-green-300">
|
||
{msg}
|
||
</p>
|
||
<% {:error, msg} -> %>
|
||
<.icon
|
||
name="hero-x-circle"
|
||
class="h-5 w-5 shrink-0 text-red-500 dark:text-red-400"
|
||
/>
|
||
<p class="text-sm font-medium text-red-800 dark:text-red-300">
|
||
{msg}
|
||
</p>
|
||
<% end %>
|
||
</div>
|
||
<% end %>
|
||
|
||
<div class="flex items-center justify-end gap-x-3 border-t border-gray-200 pt-5 dark:border-white/10">
|
||
<button
|
||
type="button"
|
||
phx-click="test_connection"
|
||
class="rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 transition-colors hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
|
||
>
|
||
<.icon name="hero-signal" class="h-4 w-4 inline -mt-0.5" />
|
||
Test Connection
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
phx-disable-with={t("Saving...")}
|
||
class="rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-xs transition-colors hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||
>
|
||
{t("Save Integration")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</.form>
|
||
<% else %>
|
||
<%= if provider.id == "sonar" do %>
|
||
<.form
|
||
for={@integration_form}
|
||
id="sonar-form"
|
||
phx-change="validate_integration"
|
||
phx-submit="save_integration"
|
||
>
|
||
<div class="space-y-6">
|
||
<div>
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<.icon name="hero-link" class="h-4 w-4 text-gray-400" />
|
||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||
{t("Connection")}
|
||
</h4>
|
||
</div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4 ml-6">
|
||
{t("Your Sonar instance URL and API token.")}
|
||
</p>
|
||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
<div>
|
||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||
{t("Instance URL")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="integration[instance_url]"
|
||
value={get_credential(@integrations["sonar"], "instance_url")}
|
||
placeholder="https://myisp.sonar.software"
|
||
autocomplete="off"
|
||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||
{t("API Token")}
|
||
</label>
|
||
<input
|
||
type="password"
|
||
name="integration[api_token]"
|
||
value={get_credential(@integrations["sonar"], "api_token")}
|
||
placeholder={t("Enter your Sonar API token")}
|
||
autocomplete="off"
|
||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="mt-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 p-4">
|
||
<h4 class="flex items-center gap-1.5 text-xs font-semibold text-blue-900 dark:text-blue-300 mb-2">
|
||
<.icon name="hero-information-circle" class="h-4 w-4" />
|
||
{t("How to create your API token")}
|
||
</h4>
|
||
<ol class="list-decimal list-inside space-y-1 text-xs text-blue-800 dark:text-blue-300 ml-5">
|
||
<%= for step <- provider.setup_help.steps do %>
|
||
<li>{raw(format_help_step(step))}</li>
|
||
<% end %>
|
||
</ol>
|
||
<p class="mt-2.5 text-xs text-blue-700 dark:text-blue-400 ml-5">
|
||
<.icon
|
||
name="hero-shield-check"
|
||
class="h-3.5 w-3.5 inline -mt-0.5"
|
||
/>
|
||
{raw(format_help_step(provider.setup_help.permissions))}
|
||
</p>
|
||
<p class="mt-2 text-xs text-blue-600/80 dark:text-blue-400/70 ml-5">
|
||
<.icon
|
||
name="hero-arrow-top-right-on-square"
|
||
class="h-3 w-3 inline -mt-0.5"
|
||
/> Example URL:
|
||
<code class="font-mono">{provider.setup_help.url_pattern}</code>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="border-t border-gray-200 pt-5 dark:border-white/10">
|
||
<div class="max-w-xs">
|
||
<.input
|
||
field={@integration_form[:sync_interval_minutes]}
|
||
type="number"
|
||
label={t("Sync interval (minutes)")}
|
||
min="5"
|
||
value={
|
||
if(@integrations["sonar"],
|
||
do: @integrations["sonar"].sync_interval_minutes,
|
||
else: 10
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<%= if @test_result do %>
|
||
<div class={[
|
||
"flex items-start gap-3 rounded-lg p-4",
|
||
case @test_result do
|
||
{:ok, _} -> "bg-green-50 dark:bg-green-900/20"
|
||
{:error, _} -> "bg-red-50 dark:bg-red-900/20"
|
||
end
|
||
]}>
|
||
<p class={[
|
||
"text-sm font-medium",
|
||
case @test_result do
|
||
{:ok, _} -> "text-green-800 dark:text-green-300"
|
||
{:error, _} -> "text-red-800 dark:text-red-300"
|
||
end
|
||
]}>
|
||
{elem(@test_result, 1)}
|
||
</p>
|
||
</div>
|
||
<% end %>
|
||
|
||
<div class="flex items-center justify-end gap-x-3 border-t border-gray-200 pt-5 dark:border-white/10">
|
||
<button
|
||
type="button"
|
||
phx-click="test_connection"
|
||
class="rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 transition-colors hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
|
||
>
|
||
<.icon name="hero-signal" class="h-4 w-4 inline -mt-0.5" />
|
||
Test Connection
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
phx-disable-with={t("Saving...")}
|
||
class="rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-xs transition-colors hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||
>
|
||
{t("Save Integration")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</.form>
|
||
<% else %>
|
||
<%= if provider.id == "splynx" do %>
|
||
<.form
|
||
for={@integration_form}
|
||
id="splynx-form"
|
||
phx-change="validate_integration"
|
||
phx-submit="save_integration"
|
||
>
|
||
<div class="space-y-6">
|
||
<div>
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<.icon name="hero-link" class="h-4 w-4 text-gray-400" />
|
||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||
{t("Connection")}
|
||
</h4>
|
||
</div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4 ml-6">
|
||
{t("Your Splynx instance URL, API key, and API secret.")}
|
||
</p>
|
||
<div class="grid grid-cols-1 gap-4">
|
||
<div>
|
||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||
{t("Instance URL")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="integration[instance_url]"
|
||
value={get_credential(@integrations["splynx"], "instance_url")}
|
||
placeholder="https://myisp.splynx.app"
|
||
autocomplete="off"
|
||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||
/>
|
||
</div>
|
||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
<div>
|
||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||
{t("API Key")}
|
||
</label>
|
||
<input
|
||
type="password"
|
||
name="integration[api_key]"
|
||
value={get_credential(@integrations["splynx"], "api_key")}
|
||
placeholder={t("Enter your Splynx API key")}
|
||
autocomplete="off"
|
||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||
{t("API Secret")}
|
||
</label>
|
||
<input
|
||
type="password"
|
||
name="integration[api_secret]"
|
||
value={get_credential(@integrations["splynx"], "api_secret")}
|
||
placeholder={t("Enter your Splynx API secret")}
|
||
autocomplete="off"
|
||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 p-4">
|
||
<h4 class="flex items-center gap-1.5 text-xs font-semibold text-blue-900 dark:text-blue-300 mb-2">
|
||
<.icon name="hero-information-circle" class="h-4 w-4" />
|
||
{t("How to create your API key")}
|
||
</h4>
|
||
<ol class="list-decimal list-inside space-y-1 text-xs text-blue-800 dark:text-blue-300 ml-5">
|
||
<%= for step <- provider.setup_help.steps do %>
|
||
<li>{raw(format_help_step(step))}</li>
|
||
<% end %>
|
||
</ol>
|
||
<p class="mt-2.5 text-xs text-blue-700 dark:text-blue-400 ml-5">
|
||
<.icon
|
||
name="hero-shield-check"
|
||
class="h-3.5 w-3.5 inline -mt-0.5"
|
||
/>
|
||
{raw(format_help_step(provider.setup_help.permissions))}
|
||
</p>
|
||
<p class="mt-2 text-xs text-blue-600/80 dark:text-blue-400/70 ml-5">
|
||
<.icon
|
||
name="hero-arrow-top-right-on-square"
|
||
class="h-3 w-3 inline -mt-0.5"
|
||
/> Example URL:
|
||
<code class="font-mono">{provider.setup_help.url_pattern}</code>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="border-t border-gray-200 pt-5 dark:border-white/10">
|
||
<div class="max-w-xs">
|
||
<.input
|
||
field={@integration_form[:sync_interval_minutes]}
|
||
type="number"
|
||
label={t("Sync interval (minutes)")}
|
||
min="5"
|
||
value={
|
||
if(@integrations["splynx"],
|
||
do: @integrations["splynx"].sync_interval_minutes,
|
||
else: 10
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<%= if @test_result do %>
|
||
<div class={[
|
||
"flex items-start gap-3 rounded-lg p-4",
|
||
case @test_result do
|
||
{:ok, _} -> "bg-green-50 dark:bg-green-900/20"
|
||
{:error, _} -> "bg-red-50 dark:bg-red-900/20"
|
||
end
|
||
]}>
|
||
<p class={[
|
||
"text-sm font-medium",
|
||
case @test_result do
|
||
{:ok, _} -> "text-green-800 dark:text-green-300"
|
||
{:error, _} -> "text-red-800 dark:text-red-300"
|
||
end
|
||
]}>
|
||
{elem(@test_result, 1)}
|
||
</p>
|
||
</div>
|
||
<% end %>
|
||
|
||
<div class="flex items-center justify-end gap-x-3 border-t border-gray-200 pt-5 dark:border-white/10">
|
||
<button
|
||
type="button"
|
||
phx-click="test_connection"
|
||
class="rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 transition-colors hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
|
||
>
|
||
<.icon name="hero-signal" class="h-4 w-4 inline -mt-0.5" />
|
||
Test Connection
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
phx-disable-with={t("Saving...")}
|
||
class="rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-xs transition-colors hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||
>
|
||
{t("Save Integration")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</.form>
|
||
<% else %>
|
||
<.form
|
||
for={@integration_form}
|
||
id={"#{provider.id}-form"}
|
||
phx-change="validate_integration"
|
||
phx-submit="save_integration"
|
||
>
|
||
<div class="space-y-5">
|
||
<div>
|
||
<div class="flex items-center gap-2 mb-4">
|
||
<.icon name="hero-key" class="h-4 w-4 text-gray-400" />
|
||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||
{t("Connection Settings")}
|
||
</h4>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||
{if(provider.id == "pagerduty",
|
||
do: "Integration Key (Routing Key)",
|
||
else: "API Key"
|
||
)}
|
||
</label>
|
||
<input
|
||
type="password"
|
||
name="integration[api_key]"
|
||
value={get_credential(@integrations[provider.id], "api_key")}
|
||
placeholder={
|
||
if(provider.id == "pagerduty",
|
||
do: "Enter your PagerDuty integration key",
|
||
else: "Enter your #{provider.name} API key"
|
||
)
|
||
}
|
||
autocomplete="off"
|
||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<%= if Map.has_key?(provider, :setup_help) && provider.id != "pagerduty" do %>
|
||
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 p-4">
|
||
<h4 class="flex items-center gap-1.5 text-xs font-semibold text-blue-900 dark:text-blue-300 mb-2">
|
||
<.icon name="hero-information-circle" class="h-4 w-4" />
|
||
{t("How to create your API key")}
|
||
</h4>
|
||
<ol class="list-decimal list-inside space-y-1 text-xs text-blue-800 dark:text-blue-300 ml-5">
|
||
<%= for step <- provider.setup_help.steps do %>
|
||
<li>{raw(format_help_step(step))}</li>
|
||
<% end %>
|
||
</ol>
|
||
<p class="mt-2.5 text-xs text-blue-700 dark:text-blue-400 ml-5">
|
||
<.icon
|
||
name="hero-shield-check"
|
||
class="h-3.5 w-3.5 inline -mt-0.5"
|
||
/>
|
||
{raw(format_help_step(provider.setup_help.permissions))}
|
||
</p>
|
||
<p class="mt-2 text-xs text-blue-600/80 dark:text-blue-400/70 ml-5">
|
||
<.icon
|
||
name="hero-arrow-top-right-on-square"
|
||
class="h-3 w-3 inline -mt-0.5"
|
||
/> Example URL:
|
||
<code class="font-mono">{provider.setup_help.url_pattern}</code>
|
||
</p>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if provider.id == "pagerduty" do %>
|
||
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 p-4">
|
||
<h4 class="flex items-center gap-1.5 text-xs font-semibold text-blue-900 dark:text-blue-300 mb-2">
|
||
<.icon name="hero-information-circle" class="h-4 w-4" />
|
||
{t("Where to find your Integration Key")}
|
||
</h4>
|
||
<ol class="list-decimal list-inside space-y-1 text-xs text-blue-800 dark:text-blue-300 ml-5">
|
||
<li>
|
||
In PagerDuty, go to <strong>{t("Services")}</strong>
|
||
→ select your service (or create one)
|
||
</li>
|
||
<li>Click the <strong>{t("Integrations")}</strong> tab</li>
|
||
<li>
|
||
Click <strong>{t("Add Integration")}</strong>
|
||
→ select <strong>{t("Events API v2")}</strong>
|
||
</li>
|
||
<li>
|
||
Copy the <strong>{t("Integration Key")}</strong>
|
||
and paste it above
|
||
</li>
|
||
</ol>
|
||
<p class="mt-2.5 text-xs text-blue-700 dark:text-blue-400 ml-5">
|
||
{t(
|
||
"When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts."
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="border-t border-gray-200 pt-5 mt-5 dark:border-white/10">
|
||
<div class="flex items-center gap-2 mb-4">
|
||
<.icon name="hero-arrow-path" class="h-4 w-4 text-gray-400" />
|
||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||
{t("Two-Way Sync (Webhooks)")}
|
||
</h4>
|
||
</div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||
{t(
|
||
"When someone resolves or acknowledges an incident directly in PagerDuty,"
|
||
)}
|
||
{t("TowerOps will automatically update the corresponding alert.")}
|
||
</p>
|
||
<div class="mb-3">
|
||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
{t("Webhook URL")}
|
||
</label>
|
||
<div class="flex items-center gap-2">
|
||
<code class="text-xs bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 px-2.5 py-1.5 rounded-md break-all">
|
||
{ToweropsWeb.Endpoint.url()}/api/v1/webhooks/pagerduty/{@current_scope.organization.id}
|
||
</code>
|
||
</div>
|
||
</div>
|
||
<.input
|
||
name="integration[webhook_secret]"
|
||
type="password"
|
||
label={t("Webhook Signing Secret")}
|
||
value={
|
||
if(@integrations[provider.id],
|
||
do:
|
||
get_in(@integrations[provider.id].credentials, [
|
||
"webhook_secret"
|
||
]) ||
|
||
"",
|
||
else: ""
|
||
)
|
||
}
|
||
placeholder={
|
||
t("Optional — paste from PagerDuty webhook extension")
|
||
}
|
||
/>
|
||
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 p-3 mt-3">
|
||
<ol class="list-decimal list-inside space-y-1 text-xs text-blue-800 dark:text-blue-300 ml-1">
|
||
<li>
|
||
In PagerDuty, go to <strong>{t("Integrations")}</strong>
|
||
→ <strong>Generic Webhooks (v3)</strong>
|
||
</li>
|
||
<li>
|
||
{t("Add a subscription with the Webhook URL above")}
|
||
</li>
|
||
<li>
|
||
Select events: <strong>incident.resolved</strong>
|
||
and <strong>incident.acknowledged</strong>
|
||
</li>
|
||
<li>
|
||
Copy the <strong>{t("Signing Secret")}</strong>
|
||
and paste it above
|
||
</li>
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if provider.id != "pagerduty" do %>
|
||
<div class="border-t border-gray-200 pt-5 dark:border-white/10">
|
||
<div class="flex items-center gap-2 mb-4">
|
||
<.icon name="hero-clock" class="h-4 w-4 text-gray-400" />
|
||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||
{t("Sync Settings")}
|
||
</h4>
|
||
</div>
|
||
<div class="max-w-xs">
|
||
<.input
|
||
field={@integration_form[:sync_interval_minutes]}
|
||
type="number"
|
||
label={t("Sync interval (minutes)")}
|
||
min="5"
|
||
value={
|
||
if(@integrations[provider.id],
|
||
do: @integrations[provider.id].sync_interval_minutes,
|
||
else: if(provider.id == "gaiia", do: 15, else: 10)
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @test_result do %>
|
||
<div class={[
|
||
"flex items-start gap-3 rounded-lg p-4",
|
||
case @test_result do
|
||
{:ok, _} -> "bg-green-50 dark:bg-green-900/20"
|
||
{:error, _} -> "bg-red-50 dark:bg-red-900/20"
|
||
end
|
||
]}>
|
||
<p class={[
|
||
"text-sm font-medium",
|
||
case @test_result do
|
||
{:ok, _} -> "text-green-800 dark:text-green-300"
|
||
{:error, _} -> "text-red-800 dark:text-red-300"
|
||
end
|
||
]}>
|
||
{elem(@test_result, 1)}
|
||
</p>
|
||
</div>
|
||
<% end %>
|
||
|
||
<div class="flex items-center justify-between border-t border-gray-200 pt-5 dark:border-white/10">
|
||
<button
|
||
type="button"
|
||
id="test-connection"
|
||
phx-click="test_connection"
|
||
class="rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 transition-colors hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
|
||
>
|
||
<.icon name="hero-signal" class="h-4 w-4 inline -mt-0.5" />
|
||
Test Connection
|
||
</button>
|
||
|
||
<div class="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
phx-click="close_config"
|
||
class="rounded-lg px-4 py-2.5 text-sm font-semibold text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||
>
|
||
{t("Cancel")}
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
phx-disable-with={t("Saving...")}
|
||
class="rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-xs transition-colors hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||
>
|
||
{t("Save Integration")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</.form>
|
||
<% end %>
|
||
<% end %>
|
||
<% end %>
|
||
|
||
<%!-- Gaiia Webhook Section --%>
|
||
<%= if provider.id == "gaiia" && @integrations["gaiia"] do %>
|
||
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-white/10">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<.icon name="hero-globe-alt" class="h-4 w-4 text-gray-400" />
|
||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||
{t("Webhook Configuration")}
|
||
</h4>
|
||
</div>
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-6">
|
||
{t(
|
||
"Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change."
|
||
)}
|
||
</p>
|
||
|
||
<div class="mt-4 space-y-4">
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||
{t("Webhook URL")}
|
||
</label>
|
||
<div class="mt-1 flex items-center gap-2">
|
||
<input
|
||
id="gaiia-webhook-url"
|
||
type="text"
|
||
readonly
|
||
value={webhook_url(@organization.id)}
|
||
class="block w-full rounded-md border-gray-300 bg-gray-50 py-1.5 text-sm text-gray-900 shadow-xs sm:leading-6 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||
/>
|
||
<button
|
||
type="button"
|
||
phx-hook="CopyToClipboard"
|
||
data-target="#gaiia-webhook-url"
|
||
id="copy-webhook-url"
|
||
class="inline-flex items-center gap-1 rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/20"
|
||
>
|
||
<.icon name="hero-clipboard" class="h-3.5 w-3.5" /> Copy
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||
{t("Gaiia App URL")}
|
||
</label>
|
||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||
{t(
|
||
"Your Gaiia app URL (e.g. https://app.gaiia.com/your-org). Used to create direct links to inventory items and network sites."
|
||
)}
|
||
</p>
|
||
<div class="mt-1">
|
||
<input
|
||
id="gaiia-app-url"
|
||
type="url"
|
||
name="app_url"
|
||
value={get_credential(@integrations["gaiia"], "app_url")}
|
||
placeholder="https://app.gaiia.com/your-org"
|
||
phx-blur="save_gaiia_app_url"
|
||
class="block w-full rounded-md border-gray-300 py-1.5 text-sm text-gray-900 shadow-xs sm:leading-6 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||
{t("Webhook Secret")}
|
||
</label>
|
||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||
{t(
|
||
"Paste the secret key generated by Gaiia when you create the webhook."
|
||
)}
|
||
</p>
|
||
<div class="mt-1 flex items-center gap-2">
|
||
<input
|
||
id="gaiia-webhook-secret"
|
||
type="password"
|
||
name="webhook_secret"
|
||
value={get_credential(@integrations["gaiia"], "webhook_secret")}
|
||
placeholder={t("Paste the secret from Gaiia")}
|
||
phx-blur="save_webhook_secret"
|
||
class="block w-full rounded-md border-gray-300 py-1.5 font-mono text-sm text-gray-900 shadow-xs sm:leading-6 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50">
|
||
<h5 class="flex items-center gap-1.5 text-xs font-semibold text-blue-800 dark:text-blue-300">
|
||
<.icon name="hero-information-circle" class="h-4 w-4" />
|
||
Setup Instructions
|
||
</h5>
|
||
<ol class="mt-2 list-inside list-decimal space-y-1 text-xs text-blue-700 dark:text-blue-400 ml-5">
|
||
<li>In your Gaiia admin panel, go to Settings → Webhooks</li>
|
||
<li>Click "Add Webhook"</li>
|
||
<li>Paste the Webhook URL above</li>
|
||
<li>Under Events, select "All Events"</li>
|
||
<li>Gaiia will generate a secret key — copy it</li>
|
||
<li>Paste the secret key into the Webhook Secret field above</li>
|
||
<li>Save the webhook</li>
|
||
</ol>
|
||
<p class="mt-2.5 text-xs text-blue-600 dark:text-blue-400 ml-5">
|
||
{t(
|
||
"Once configured, Towerops will receive real-time updates when accounts, subscriptions, or inventory items change in Gaiia."
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
<% end %>
|
||
<%!-- Collapsed group for locked billing providers --%>
|
||
<%= if @active_billing_provider != nil and provider.id in @billing_provider_ids and provider.id == @active_billing_provider do %>
|
||
<% locked_providers =
|
||
Enum.filter(@providers, fn p ->
|
||
p.id in @billing_provider_ids and p.id != @active_billing_provider
|
||
end) %>
|
||
<%= if locked_providers != [] do %>
|
||
<details class="group rounded-lg border border-dashed border-gray-200 dark:border-white/10">
|
||
<summary class="flex cursor-pointer items-center gap-2 px-4 py-2.5 text-xs font-medium text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 select-none list-none [&::-webkit-details-marker]:hidden">
|
||
<.icon
|
||
name="hero-chevron-right"
|
||
class="h-3.5 w-3.5 transition-transform group-open:rotate-90"
|
||
/>
|
||
{length(locked_providers)} other billing {ngettext(
|
||
"provider",
|
||
"providers",
|
||
length(locked_providers)
|
||
)} available
|
||
</summary>
|
||
<div class="border-t border-dashed border-gray-200 px-4 py-2 space-y-1 dark:border-white/10">
|
||
<%= for lp <- locked_providers do %>
|
||
<div class="flex items-center gap-2.5 py-1.5 text-sm text-gray-400 dark:text-gray-500">
|
||
<.icon name={lp.icon} class="h-4 w-4" />
|
||
<span class="font-medium">{lp.name}</span>
|
||
<span class="text-xs">— {lp.description}</span>
|
||
</div>
|
||
<% end %>
|
||
<p class="pt-1 pb-1 text-xs text-gray-400 dark:text-gray-500">
|
||
Disable {Enum.find_value(@providers, fn p ->
|
||
if p.id == @active_billing_provider, do: p.name
|
||
end)} to switch to a different billing provider.
|
||
</p>
|
||
</div>
|
||
</details>
|
||
<% end %>
|
||
<% end %>
|
||
<% end %>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @active_tab == "billing" do %>
|
||
<div class="space-y-6 px-4 py-6 sm:p-6 lg:p-8">
|
||
<div class="border rounded-lg p-6 dark:border-white/10">
|
||
<h2 class="text-xl font-semibold mb-4 dark:text-white">{t("Subscription & Billing")}</h2>
|
||
|
||
<dl class="space-y-3">
|
||
<div>
|
||
<dt class="text-sm text-gray-600 dark:text-gray-400">{t("Current Plan")}</dt>
|
||
<dd class="text-lg font-semibold capitalize dark:text-white">
|
||
{@organization.subscription_plan || "free"}
|
||
</dd>
|
||
</div>
|
||
|
||
<div>
|
||
<dt class="text-sm text-gray-600 dark:text-gray-400">{t("Device Usage")}</dt>
|
||
<dd class="text-lg dark:text-white">
|
||
{@current_devices} {t("devices")}
|
||
<%= if @device_limit != :unlimited do %>
|
||
<span class="text-gray-500 dark:text-gray-400">
|
||
/ {@device_limit} {t("limit")}
|
||
</span>
|
||
<% end %>
|
||
</dd>
|
||
</div>
|
||
|
||
<%= if @organization.subscription_plan == "paid" do %>
|
||
<div>
|
||
<dt class="text-sm text-gray-600 dark:text-gray-400">
|
||
{t("Estimated Monthly Cost")}
|
||
</dt>
|
||
<dd class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||
${@estimated_cost.cost_usd}
|
||
</dd>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||
{@estimated_cost.billable} {t("billable devices")} × ${@estimated_cost.price_per_device}
|
||
</p>
|
||
</div>
|
||
|
||
<%= if @organization.subscription_status == "active" do %>
|
||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50 rounded p-3">
|
||
<p class="text-sm text-green-800 dark:text-green-300">
|
||
✓ {t("Subscription Active")}
|
||
</p>
|
||
<%= if @organization.subscription_current_period_end do %>
|
||
<p class="text-xs text-green-600 dark:text-green-400 mt-1">
|
||
{t("Next billing")}: {Calendar.strftime(
|
||
@organization.subscription_current_period_end,
|
||
"%B %d, %Y"
|
||
)}
|
||
</p>
|
||
<% end %>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if @organization.subscription_status == "past_due" do %>
|
||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800/50 rounded p-3">
|
||
<p class="text-sm text-yellow-800 dark:text-yellow-300">
|
||
⚠ {t("Payment Past Due")}
|
||
</p>
|
||
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
|
||
{t("Please update your payment method to avoid service interruption.")}
|
||
</p>
|
||
</div>
|
||
<% end %>
|
||
<% else %>
|
||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 rounded p-3">
|
||
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||
{t("Free Plan - First %{count} devices included",
|
||
count: @estimated_cost.free_included
|
||
)}
|
||
</p>
|
||
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
||
{t("Upgrade to monitor unlimited devices at $%{price}/device/month",
|
||
price: @estimated_cost.price_per_device
|
||
)}
|
||
</p>
|
||
</div>
|
||
<% end %>
|
||
</dl>
|
||
|
||
<div class="mt-6 flex gap-3">
|
||
<%= if @organization.subscription_plan == "free" do %>
|
||
<.button phx-click="upgrade_to_paid" class="btn-primary">
|
||
{t("Upgrade to Paid Plan")}
|
||
</.button>
|
||
<% else %>
|
||
<.button phx-click="manage_billing" class="btn-secondary">
|
||
{t("Manage Billing")}
|
||
</.button>
|
||
<% end %>
|
||
</div>
|
||
</div>
|
||
|
||
<%= if @organization.subscription_plan == "paid" do %>
|
||
<div class="border rounded-lg p-6 dark:border-white/10">
|
||
<h3 class="text-lg font-semibold mb-3 dark:text-white">{t("Billing Information")}</h3>
|
||
<dl class="space-y-2 text-sm">
|
||
<div class="flex justify-between">
|
||
<dt class="text-gray-600 dark:text-gray-400">{t("Subscription Status")}</dt>
|
||
<dd class="font-medium capitalize dark:text-white">
|
||
{@organization.subscription_status}
|
||
</dd>
|
||
</div>
|
||
<%= if @organization.subscription_current_period_start do %>
|
||
<div class="flex justify-between">
|
||
<dt class="text-gray-600 dark:text-gray-400">{t("Current Period Start")}</dt>
|
||
<dd class="font-medium dark:text-white">
|
||
{Calendar.strftime(@organization.subscription_current_period_start, "%B %d, %Y")}
|
||
</dd>
|
||
</div>
|
||
<% end %>
|
||
<%= if @organization.subscription_current_period_end do %>
|
||
<div class="flex justify-between">
|
||
<dt class="text-gray-600 dark:text-gray-400">{t("Current Period End")}</dt>
|
||
<dd class="font-medium dark:text-white">
|
||
{Calendar.strftime(@organization.subscription_current_period_end, "%B %d, %Y")}
|
||
</dd>
|
||
</div>
|
||
<% end %>
|
||
<%= if @organization.payment_method_status do %>
|
||
<div class="flex justify-between">
|
||
<dt class="text-gray-600 dark:text-gray-400">{t("Payment Method")}</dt>
|
||
<dd class={[
|
||
"font-medium capitalize",
|
||
if(@organization.payment_method_status == "valid",
|
||
do: "text-green-600 dark:text-green-400",
|
||
else: "text-yellow-600 dark:text-yellow-400"
|
||
)
|
||
]}>
|
||
{String.replace(@organization.payment_method_status, "_", " ")}
|
||
</dd>
|
||
</div>
|
||
<% end %>
|
||
</dl>
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
<% end %>
|
||
</Layouts.authenticated>
|