Redesign integrations page: billing providers as picker
Instead of showing all 4 billing providers as separate full-width cards, the billing section now shows: - When no billing connected: a compact 4-column grid picker to select your billing platform (Gaiia, Sonar, Splynx, VISP) - When one is active: a single card showing the connected provider with status, sync info, and a note to disable before switching This reduces visual clutter since users only ever use one billing system. Non-exclusive categories (QoE, Incidents, Infrastructure) remain as individual cards since multiple can be active simultaneously.
This commit is contained in:
parent
f307f39631
commit
8fe90670b6
3 changed files with 389 additions and 207 deletions
|
|
@ -293,6 +293,13 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|
|||
end)
|
||||
end
|
||||
|
||||
defp find_category_id(provider_id) do
|
||||
case find_category(provider_id) do
|
||||
%{id: id} -> id
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp exclusive_conflict?(provider_id, integrations) do
|
||||
case find_category(provider_id) do
|
||||
%{exclusive: true} = category ->
|
||||
|
|
|
|||
|
|
@ -27,45 +27,35 @@
|
|||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{category.description}
|
||||
<%= if category.exclusive do %>
|
||||
<span class="ml-1 inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
Choose one
|
||||
</span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
:for={provider <- category.providers}
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-50 dark:bg-indigo-900/30">
|
||||
<.icon name={provider.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{provider.name}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{provider.description}
|
||||
</p>
|
||||
|
||||
<%= if integration = @integrations[provider.id] do %>
|
||||
<div class="mt-3 flex items-center gap-4">
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||
if(integration.enabled,
|
||||
do:
|
||||
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
|
||||
else: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
)
|
||||
]}>
|
||||
{if integration.enabled, do: "Enabled", else: "Disabled"}
|
||||
<%= if category.exclusive do %>
|
||||
<%!-- Exclusive category (billing): show as picker + single config panel --%>
|
||||
<% active_billing = Enum.find(category.providers, fn p -> @integrations[p.id] && @integrations[p.id].enabled end) %>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-white/10 dark:bg-white/5">
|
||||
<%= if active_billing do %>
|
||||
<%!-- Active billing provider --%>
|
||||
<% integration = @integrations[active_billing.id] %>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-50 dark:bg-indigo-900/30">
|
||||
<.icon name={active_billing.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{active_billing.name}
|
||||
</h3>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{active_billing.description}
|
||||
</p>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-4">
|
||||
<%= if integration.last_synced_at do %>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("Last synced:")}
|
||||
|
|
@ -81,17 +71,10 @@
|
|||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
case integration.last_sync_status do
|
||||
"success" ->
|
||||
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
|
||||
"partial" ->
|
||||
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
|
||||
"failed" ->
|
||||
"bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
|
||||
_ ->
|
||||
"bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
"success" -> "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
"partial" -> "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
"failed" -> "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
_ -> "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
end
|
||||
]}>
|
||||
{String.capitalize(integration.last_sync_status)}
|
||||
|
|
@ -100,104 +83,95 @@
|
|||
|
||||
<%= if integration.last_synced_at do %>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||
· Next sync in ~{next_sync_minutes(
|
||||
integration.last_synced_at,
|
||||
integration.sync_interval_minutes
|
||||
)}m
|
||||
· Next sync in ~{next_sync_minutes(integration.last_synced_at, integration.sync_interval_minutes)}m
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%= if provider.id == "preseem" do %>
|
||||
<%= if active_billing.id == "gaiia" do %>
|
||||
<.link
|
||||
navigate={
|
||||
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices"
|
||||
}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Manage Devices →
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/insights?source=preseem"}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Network Insights →
|
||||
</.link>
|
||||
<% end %>
|
||||
<%= if provider.id == "gaiia" do %>
|
||||
<.link
|
||||
navigate={
|
||||
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"
|
||||
}
|
||||
navigate={~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Entity Mapping →
|
||||
</.link>
|
||||
<.link
|
||||
navigate={
|
||||
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation"
|
||||
}
|
||||
navigate={~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation"}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Reconciliation →
|
||||
</.link>
|
||||
<% end %>
|
||||
<%!-- Sync schedule info --%>
|
||||
<%= if integration.enabled do %>
|
||||
<div class="mt-3 rounded-md bg-gray-50 p-3 dark:bg-gray-800/50">
|
||||
<h4 class="text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
<.icon name="hero-clock" class="h-3.5 w-3.5 inline" /> Sync Schedule
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<%= if provider.id == "preseem" do %>
|
||||
Syncs every {integration.sync_interval_minutes} minutes. Baselines and fleet profiles computed nightly.
|
||||
<% end %>
|
||||
<%= if provider.id == "gaiia" do %>
|
||||
Syncs every {integration.sync_interval_minutes} minutes. Reconciliation runs nightly. Also receives real-time webhook updates.
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<%= if integration = @integrations[provider.id] do %>
|
||||
<div class="flex items-center gap-3">
|
||||
<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",
|
||||
if(integration.enabled,
|
||||
do: "bg-indigo-600",
|
||||
else: "bg-gray-200 dark:bg-gray-700"
|
||||
)
|
||||
]}
|
||||
phx-value-provider={active_billing.id}
|
||||
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-indigo-600 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
|
||||
>
|
||||
<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>
|
||||
<span class="pointer-events-none inline-block h-5 w-5 translate-x-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" />
|
||||
</button>
|
||||
<% end %>
|
||||
<button
|
||||
type="button"
|
||||
id={"configure-#{active_billing.id}"}
|
||||
phx-click="configure"
|
||||
phx-value-provider={active_billing.id}
|
||||
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs 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"
|
||||
>
|
||||
{t("Configure")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Switch provider link --%>
|
||||
<div class="mt-4 border-t border-gray-100 pt-3 dark:border-white/5">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Using {active_billing.name}. To switch providers, disable this integration first.
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<%!-- No active billing — show provider picker --%>
|
||||
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("Select your billing platform to get started:")}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<button
|
||||
:for={provider <- category.providers}
|
||||
type="button"
|
||||
id={"configure-#{provider.id}"}
|
||||
phx-click="configure"
|
||||
phx-value-provider={provider.id}
|
||||
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs 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"
|
||||
class={[
|
||||
"group relative flex flex-col items-center rounded-lg border-2 p-4 text-center transition-all hover:border-indigo-300 hover:bg-indigo-50/50 dark:hover:border-indigo-700 dark:hover:bg-indigo-900/10",
|
||||
if(@configuring == provider.id,
|
||||
do: "border-indigo-500 bg-indigo-50/50 dark:border-indigo-600 dark:bg-indigo-900/20",
|
||||
else: "border-gray-200 dark:border-white/10"
|
||||
)
|
||||
]}
|
||||
>
|
||||
{t("Configure")}
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-50 group-hover:bg-indigo-100 dark:bg-indigo-900/30">
|
||||
<.icon name={provider.icon} class="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<span class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{provider.name}
|
||||
</span>
|
||||
<span class="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{provider.description}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @configuring == provider.id do %>
|
||||
<%!-- Config form for whichever billing provider is being configured --%>
|
||||
<%= if @configuring && find_category_id(@configuring) == "billing" do %>
|
||||
<% provider = Enum.find(category.providers, & &1.id == @configuring) %>
|
||||
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-white/10">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Configure {provider.name}
|
||||
</h4>
|
||||
<.form
|
||||
for={@form}
|
||||
id={"#{provider.id}-form"}
|
||||
|
|
@ -256,7 +230,6 @@
|
|||
>
|
||||
{t("Test Connection")}
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -277,106 +250,304 @@
|
|||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if provider.id == "gaiia" && @integrations["gaiia"] do %>
|
||||
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-white/10">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Webhook Configuration")}
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
"Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change."
|
||||
)}
|
||||
</p>
|
||||
<%!-- Gaiia webhook config --%>
|
||||
<%= if @configuring == "gaiia" && @integrations["gaiia"] do %>
|
||||
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-white/10">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Webhook Configuration")}
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{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 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>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("Webhook Secret")}
|
||||
</label>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
id="gaiia-webhook-secret"
|
||||
type="text"
|
||||
readonly
|
||||
value={get_credential(@integrations["gaiia"], "webhook_secret")}
|
||||
class="block w-full rounded-md border-gray-300 bg-gray-50 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
phx-hook="CopyToClipboard"
|
||||
data-target="#gaiia-webhook-secret"
|
||||
id="copy-webhook-secret"
|
||||
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>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("Webhook Secret")}
|
||||
</label>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
id="gaiia-webhook-secret"
|
||||
type="text"
|
||||
readonly
|
||||
value={get_credential(@integrations["gaiia"], "webhook_secret")}
|
||||
class="block w-full rounded-md border-gray-300 bg-gray-50 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
phx-hook="CopyToClipboard"
|
||||
data-target="#gaiia-webhook-secret"
|
||||
id="copy-webhook-secret"
|
||||
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 class="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
id="regenerate-webhook-secret"
|
||||
phx-click="regenerate_webhook_secret"
|
||||
data-confirm={t("Are you sure? Any existing Gaiia webhook using the current secret will stop working.")}
|
||||
class="text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
{t("Regenerate Secret")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
id="regenerate-webhook-secret"
|
||||
phx-click="regenerate_webhook_secret"
|
||||
data-confirm={
|
||||
t(
|
||||
"Are you sure? Any existing Gaiia webhook using the current secret will stop working."
|
||||
)
|
||||
}
|
||||
class="text-xs text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
{t("Regenerate Secret")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<h5 class="text-xs font-medium text-blue-800 dark:text-blue-300">
|
||||
{t("Setup Instructions")}
|
||||
</h5>
|
||||
<ol class="mt-2 list-inside list-decimal space-y-1 text-xs text-blue-700 dark:text-blue-400">
|
||||
<li>In your Gaiia admin panel, go to Settings → Webhooks</li>
|
||||
<li>Click "Add Webhook"</li>
|
||||
<li>Paste the Webhook URL above</li>
|
||||
<li>Paste the Webhook Secret above into the "Secret" field</li>
|
||||
<li>Select events: Account, Billing Subscription, Inventory Item</li>
|
||||
<li>Save the webhook</li>
|
||||
</ol>
|
||||
<p class="mt-2 text-xs text-blue-600 dark:text-blue-400">
|
||||
{t(
|
||||
"Once configured, Towerops will receive real-time updates when accounts, subscriptions, or inventory items change in Gaiia."
|
||||
)}
|
||||
</p>
|
||||
<div class="rounded-md bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<h5 class="text-xs font-medium text-blue-800 dark:text-blue-300">
|
||||
{t("Setup Instructions")}
|
||||
</h5>
|
||||
<ol class="mt-2 list-inside list-decimal space-y-1 text-xs text-blue-700 dark:text-blue-400">
|
||||
<li>In your Gaiia admin panel, go to Settings → Webhooks</li>
|
||||
<li>Click "Add Webhook"</li>
|
||||
<li>Paste the Webhook URL above</li>
|
||||
<li>Paste the Webhook Secret above into the "Secret" field</li>
|
||||
<li>Select events: Account, Billing Subscription, Inventory Item</li>
|
||||
<li>Save the webhook</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<%!-- Non-exclusive categories: one card per provider --%>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
:for={provider <- category.providers}
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-white/10 dark:bg-white/5"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-50 dark:bg-indigo-900/30">
|
||||
<.icon name={provider.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{provider.name}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{provider.description}
|
||||
</p>
|
||||
|
||||
<%= if integration = @integrations[provider.id] do %>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-4">
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||
if(integration.enabled,
|
||||
do: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
|
||||
else: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
)
|
||||
]}>
|
||||
{if integration.enabled, do: "Enabled", else: "Disabled"}
|
||||
</span>
|
||||
|
||||
<%= if integration.last_synced_at do %>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("Last synced:")}
|
||||
<.timestamp
|
||||
datetime={integration.last_synced_at}
|
||||
timezone={@timezone}
|
||||
format="absolute"
|
||||
/>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%= if integration.last_sync_status && integration.last_sync_status != "never" do %>
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
case integration.last_sync_status do
|
||||
"success" -> "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
"partial" -> "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
"failed" -> "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
_ -> "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
end
|
||||
]}>
|
||||
{String.capitalize(integration.last_sync_status)}
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%= if integration.last_synced_at do %>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||
· Next sync in ~{next_sync_minutes(integration.last_synced_at, integration.sync_interval_minutes)}m
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%= if provider.id == "preseem" do %>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices"}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Manage Devices →
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/insights?source=preseem"}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Network Insights →
|
||||
</.link>
|
||||
<% end %>
|
||||
|
||||
<%= if integration.enabled do %>
|
||||
<div class="mt-3 w-full rounded-md bg-gray-50 p-3 dark:bg-gray-800/50">
|
||||
<h4 class="text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
<.icon name="hero-clock" class="h-3.5 w-3.5 inline" /> Sync Schedule
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<%= if provider.id == "preseem" do %>
|
||||
Syncs every {integration.sync_interval_minutes} minutes. Baselines and fleet profiles computed nightly.
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<%= if integration = @integrations[provider.id] do %>
|
||||
<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",
|
||||
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")
|
||||
]} />
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id={"configure-#{provider.id}"}
|
||||
phx-click="configure"
|
||||
phx-value-provider={provider.id}
|
||||
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs 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"
|
||||
>
|
||||
{t("Configure")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @configuring == provider.id do %>
|
||||
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-white/10">
|
||||
<.form
|
||||
for={@form}
|
||||
id={"#{provider.id}-form"}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<.input
|
||||
field={@form[:api_key]}
|
||||
type="password"
|
||||
label={t("API Key")}
|
||||
placeholder={"Enter your #{provider.name} API key"}
|
||||
autocomplete="off"
|
||||
value={get_credential(@integrations[provider.id], "api_key")}
|
||||
/>
|
||||
|
||||
<.input
|
||||
field={@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: 10
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<%= if @test_result do %>
|
||||
<div class={[
|
||||
"rounded-md 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",
|
||||
case @test_result do
|
||||
{:ok, _} -> "text-green-800 dark:text-green-200"
|
||||
{:error, _} -> "text-red-800 dark:text-red-200"
|
||||
end
|
||||
]}>
|
||||
{elem(@test_result, 1)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<button
|
||||
type="button"
|
||||
id="test-connection"
|
||||
phx-click="test_connection"
|
||||
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs 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"
|
||||
>
|
||||
{t("Test Connection")}
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="close_config"
|
||||
class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
<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 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
>
|
||||
{t("Save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ defmodule ToweropsWeb.Org.IntegrationsLiveTest do
|
|||
assert integration.credentials["webhook_secret"] == "existing-secret-abc"
|
||||
end
|
||||
|
||||
test "displays webhook URL when gaiia integration exists", %{
|
||||
test "displays webhook URL when gaiia integration is configured", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: org
|
||||
|
|
@ -77,15 +77,16 @@ defmodule ToweropsWeb.Org.IntegrationsLiveTest do
|
|||
credentials: %{"api_key" => "key", "webhook_secret" => "secret123"}
|
||||
})
|
||||
|
||||
{:ok, _view, html} =
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings/integrations")
|
||||
|
||||
html = view |> element("#configure-gaiia") |> render_click()
|
||||
assert html =~ "/api/v1/webhooks/gaiia/#{org.id}"
|
||||
end
|
||||
|
||||
test "displays webhook secret when gaiia integration exists", %{
|
||||
test "displays webhook secret when gaiia integration is configured", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: org
|
||||
|
|
@ -97,11 +98,12 @@ defmodule ToweropsWeb.Org.IntegrationsLiveTest do
|
|||
credentials: %{"api_key" => "key", "webhook_secret" => "secret123"}
|
||||
})
|
||||
|
||||
{:ok, _view, html} =
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings/integrations")
|
||||
|
||||
html = view |> element("#configure-gaiia") |> render_click()
|
||||
assert html =~ "secret123"
|
||||
end
|
||||
|
||||
|
|
@ -122,6 +124,7 @@ defmodule ToweropsWeb.Org.IntegrationsLiveTest do
|
|||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings/integrations")
|
||||
|
||||
view |> element("#configure-gaiia") |> render_click()
|
||||
view |> element("#regenerate-webhook-secret") |> render_click()
|
||||
|
||||
assert {:ok, integration} = Towerops.Integrations.get_integration(org.id, "gaiia")
|
||||
|
|
@ -130,7 +133,7 @@ defmodule ToweropsWeb.Org.IntegrationsLiveTest do
|
|||
assert integration.credentials["api_key"] == "key"
|
||||
end
|
||||
|
||||
test "shows setup instructions when gaiia integration exists", %{
|
||||
test "shows setup instructions when gaiia is being configured", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
organization: org
|
||||
|
|
@ -142,11 +145,12 @@ defmodule ToweropsWeb.Org.IntegrationsLiveTest do
|
|||
credentials: %{"api_key" => "key", "webhook_secret" => "secret"}
|
||||
})
|
||||
|
||||
{:ok, _view, html} =
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> live(~p"/orgs/#{org.slug}/settings/integrations")
|
||||
|
||||
html = view |> element("#configure-gaiia") |> render_click()
|
||||
assert html =~ "Webhook Configuration"
|
||||
assert html =~ "Webhook URL"
|
||||
assert html =~ "Webhook Secret"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue