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:
parent
fd6d4a8394
commit
d857a38ca3
2 changed files with 168 additions and 34 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue