feat: group integrations by category with exclusive billing provider selection

Billing (Gaiia, Sonar, Splynx, VISP) - choose one
QoE Monitoring (Preseem)
Incident Management (PagerDuty)
Infrastructure & IPAM (NetBox)

Prevents enabling multiple billing providers simultaneously.
This commit is contained in:
Graham McIntire 2026-02-15 08:27:23 -06:00
parent fd6d4a8394
commit d857a38ca3
2 changed files with 168 additions and 34 deletions

View file

@ -9,29 +9,85 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
require Logger
@providers [
@provider_categories [
%{
id: "preseem",
name: "Preseem",
description: "QoE monitoring and subscriber experience analytics for wireless ISPs.",
icon: "hero-signal"
id: "billing",
name: "Billing & Subscriber Management",
description: "Connect your billing platform to enable outage impact analysis, inventory reconciliation, and subscriber-aware monitoring.",
exclusive: true,
providers: [
%{
id: "gaiia",
name: "Gaiia",
description: "Full-featured ISP billing with GraphQL API and real-time webhooks.",
icon: "hero-user-group"
},
%{
id: "sonar",
name: "Sonar",
description: "Cloud-based ISP billing and operations platform.",
icon: "hero-user-group"
},
%{
id: "splynx",
name: "Splynx",
description: "ISP billing, CRM, and network management.",
icon: "hero-user-group"
},
%{
id: "visp",
name: "VISP",
description: "Billing and subscriber management for WISPs.",
icon: "hero-user-group"
}
]
},
%{
id: "gaiia",
name: "Gaiia",
description:
"Billing and subscriber management. Enables outage impact analysis, inventory reconciliation, and subscriber-aware monitoring.",
icon: "hero-user-group"
id: "monitoring",
name: "Quality of Experience",
description: "Monitor subscriber experience and network quality metrics.",
exclusive: false,
providers: [
%{
id: "preseem",
name: "Preseem",
description: "QoE monitoring and subscriber experience analytics for wireless ISPs.",
icon: "hero-signal"
}
]
},
%{
id: "pagerduty",
name: "PagerDuty",
description:
"Incident management and on-call alerting. Automatically triggers, acknowledges, and resolves PagerDuty incidents from TowerOps alerts.",
icon: "hero-bell-alert"
id: "incidents",
name: "Incident Management",
description: "Automate alert routing and on-call notifications.",
exclusive: false,
providers: [
%{
id: "pagerduty",
name: "PagerDuty",
description: "Automatically triggers, acknowledges, and resolves PagerDuty incidents from TowerOps alerts.",
icon: "hero-bell-alert"
}
]
},
%{
id: "infrastructure",
name: "Infrastructure & IPAM",
description: "Track network infrastructure, IP addressing, and device inventory.",
exclusive: false,
providers: [
%{
id: "netbox",
name: "NetBox",
description: "Network source of truth for IPAM, DCIM, and infrastructure documentation.",
icon: "hero-server-stack"
}
]
}
]
@providers Enum.flat_map(@provider_categories, & &1.providers)
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
@ -42,6 +98,7 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|> assign(:organization, organization)
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:providers, @providers)
|> assign(:provider_categories, @provider_categories)
|> assign(:integrations, integrations)
|> assign(:configuring, nil)
|> assign(:form, nil)
@ -101,15 +158,21 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
attrs = normalize_params(params, existing)
result =
case existing do
nil ->
Integrations.create_integration(
organization.id,
attrs |> Map.put(:provider, provider) |> Map.put(:enabled, true)
)
if exclusive_conflict?(provider, socket.assigns.integrations) do
category = find_category(provider)
active = active_exclusive_provider(category, socket.assigns.integrations)
{:error, :exclusive, category.name, active}
else
case existing do
nil ->
Integrations.create_integration(
organization.id,
attrs |> Map.put(:provider, provider) |> Map.put(:enabled, true)
)
integration ->
Integrations.update_integration(integration, attrs)
integration ->
Integrations.update_integration(integration, attrs)
end
end
case result do
@ -124,6 +187,17 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|> assign(:test_result, nil)
|> put_flash(:info, t("Integration saved successfully"))}
{:error, :exclusive, category_name, active_provider} ->
{:noreply,
put_flash(
socket,
:error,
t("Only one %{category} integration can be active. You already have %{provider} configured. Disable it first to switch.",
category: category_name,
provider: active_provider
)
)}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
@ -176,21 +250,63 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
{:noreply, socket}
integration ->
case Integrations.update_integration(integration, %{enabled: !integration.enabled}) do
{:ok, _updated} ->
integrations = load_integrations(socket.assigns.organization.id)
# If enabling, check exclusive category constraints
if !integration.enabled && exclusive_conflict?(provider, socket.assigns.integrations) do
category = find_category(provider)
active = active_exclusive_provider(category, socket.assigns.integrations)
{:noreply,
socket
|> assign(:integrations, integrations)
|> put_flash(:info, t("Integration updated"))}
{:noreply,
put_flash(
socket,
:error,
t("Only one %{category} integration can be active. Disable %{provider} first.",
category: category.name,
provider: active
)
)}
else
case Integrations.update_integration(integration, %{enabled: !integration.enabled}) do
{:ok, _updated} ->
integrations = load_integrations(socket.assigns.organization.id)
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, t("Failed to update integration"))}
{:noreply,
socket
|> assign(:integrations, integrations)
|> put_flash(:info, t("Integration updated"))}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, t("Failed to update integration"))}
end
end
end
end
defp find_category(provider_id) do
Enum.find(@provider_categories, fn cat ->
Enum.any?(cat.providers, &(&1.id == provider_id))
end)
end
defp exclusive_conflict?(provider_id, integrations) do
case find_category(provider_id) do
%{exclusive: true} = category ->
active = active_exclusive_provider(category, integrations)
active != nil && active != provider_id
_ ->
false
end
end
defp active_exclusive_provider(%{providers: providers}, integrations) do
Enum.find_value(providers, fn p ->
case Map.get(integrations, p.id) do
%{enabled: true} -> p.name
_ -> nil
end
end)
end
defp test_provider_connection("preseem", api_key), do: PreseemClient.test_connection(api_key)
defp test_provider_connection("gaiia", api_key), do: GaiiaClient.test_connection(api_key)
defp test_provider_connection(_, _api_key), do: {:error, "Unknown provider"}

View file

@ -19,9 +19,25 @@
</p>
</div>
<div class="mt-8 space-y-6">
<div class="mt-8 space-y-10">
<div :for={category <- @provider_categories}>
<div class="mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{category.name}
</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 <- @providers}
: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">
@ -357,5 +373,7 @@
</div>
<% end %>
</div>
</div>
</div>
</div>
</Layouts.authenticated>