- Splynx client: replace broken Basic Auth with proper token flow (POST /api/2.0/admin/auth/tokens → Splynx-EA bearer header) - Support both api_key and admin login auth types with fallback - Add MRR calculation to Splynx sync using mrr_total field - Integrations UI: per-provider credential fields (instance_url, api_key, api_secret for Splynx; instance_url, api_token for Sonar) - Wire up test_connection for Splynx, Sonar, and VISP providers - Verified against live Splynx demo (demo.splynx.com): auth, customers, routers, services all working - All 7350 tests passing
475 lines
14 KiB
Elixir
475 lines
14 KiB
Elixir
defmodule ToweropsWeb.Org.IntegrationsLive do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Gaiia.Client, as: GaiiaClient
|
|
alias Towerops.Integrations
|
|
alias Towerops.Integrations.Integration
|
|
alias Towerops.Preseem.Client, as: PreseemClient
|
|
alias Towerops.Sonar.Client, as: SonarClient
|
|
alias Towerops.Splynx.Client, as: SplynxClient
|
|
alias Towerops.Visp.Client, as: VispClient
|
|
|
|
require Logger
|
|
|
|
@provider_categories [
|
|
%{
|
|
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: "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: "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
|
|
integrations = load_integrations(organization.id)
|
|
|
|
{:ok,
|
|
socket
|
|
|> 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)
|
|
|> assign(:test_result, nil)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("configure", %{"provider" => provider}, socket) do
|
|
integration = Map.get(socket.assigns.integrations, provider)
|
|
|
|
form =
|
|
case integration do
|
|
nil ->
|
|
%Integration{}
|
|
|> Integrations.change_integration(%{provider: provider})
|
|
|> to_form()
|
|
|
|
existing ->
|
|
existing
|
|
|> Integrations.change_integration(%{})
|
|
|> to_form()
|
|
end
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:configuring, provider)
|
|
|> assign(:form, form)
|
|
|> assign(:test_result, nil)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("close_config", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:configuring, nil)
|
|
|> assign(:form, nil)
|
|
|> assign(:test_result, nil)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("validate", %{"integration" => params}, socket) do
|
|
integration = get_current_integration(socket)
|
|
|
|
provider = socket.assigns.configuring
|
|
|
|
changeset =
|
|
integration
|
|
|> Integrations.change_integration(normalize_params(provider, params, integration))
|
|
|> Map.put(:action, :validate)
|
|
|
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("save", %{"integration" => params}, socket) do
|
|
organization = socket.assigns.organization
|
|
provider = socket.assigns.configuring
|
|
existing = Map.get(socket.assigns.integrations, provider)
|
|
attrs = normalize_params(provider, params, existing)
|
|
|
|
result =
|
|
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)
|
|
end
|
|
end
|
|
|
|
case result do
|
|
{:ok, _integration} ->
|
|
integrations = load_integrations(organization.id)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:integrations, integrations)
|
|
|> assign(:configuring, nil)
|
|
|> assign(:form, nil)
|
|
|> 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
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("test_connection", _params, socket) do
|
|
credentials = extract_credentials(socket)
|
|
provider = socket.assigns.configuring
|
|
|
|
if missing_required_credentials?(provider, credentials) do
|
|
{:noreply, assign(socket, :test_result, {:error, t("Please fill in all required fields first")})}
|
|
else
|
|
result = test_provider_connection(provider, credentials)
|
|
{:noreply, assign(socket, :test_result, format_connection_result(result))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("regenerate_webhook_secret", _params, socket) do
|
|
case Map.get(socket.assigns.integrations, "gaiia") do
|
|
nil ->
|
|
{:noreply, socket}
|
|
|
|
integration ->
|
|
new_secret = generate_webhook_secret()
|
|
|
|
updated_credentials =
|
|
Map.put(integration.credentials, "webhook_secret", new_secret)
|
|
|
|
case Integrations.update_integration(integration, %{credentials: updated_credentials}) do
|
|
{:ok, _updated} ->
|
|
integrations = load_integrations(socket.assigns.organization.id)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:integrations, integrations)
|
|
|> put_flash(:info, t("Webhook secret regenerated"))}
|
|
|
|
{:error, _changeset} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to regenerate webhook secret"))}
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_enabled", %{"provider" => provider}, socket) do
|
|
integration = Map.get(socket.assigns.integrations, provider)
|
|
|
|
cond do
|
|
is_nil(integration) ->
|
|
{:noreply, socket}
|
|
|
|
!integration.enabled && exclusive_conflict?(provider, socket.assigns.integrations) ->
|
|
category = find_category(provider)
|
|
active = active_exclusive_provider(category, socket.assigns.integrations)
|
|
|
|
{:noreply,
|
|
put_flash(
|
|
socket,
|
|
:error,
|
|
t("Only one %{category} integration can be active. Disable %{provider} first.",
|
|
category: category.name,
|
|
provider: active
|
|
)
|
|
)}
|
|
|
|
true ->
|
|
toggle_integration(socket, integration)
|
|
end
|
|
end
|
|
|
|
defp toggle_integration(socket, integration) do
|
|
case Integrations.update_integration(integration, %{enabled: !integration.enabled}) do
|
|
{:ok, _updated} ->
|
|
integrations = load_integrations(socket.assigns.organization.id)
|
|
|
|
{: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
|
|
|
|
defp find_category(provider_id) do
|
|
Enum.find(@provider_categories, fn cat ->
|
|
Enum.any?(cat.providers, &(&1.id == provider_id))
|
|
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 ->
|
|
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", credentials), do: PreseemClient.test_connection(credentials["api_key"])
|
|
defp test_provider_connection("gaiia", credentials), do: GaiiaClient.test_connection(credentials["api_key"])
|
|
defp test_provider_connection("visp", credentials), do: VispClient.test_connection(credentials["api_key"])
|
|
|
|
defp test_provider_connection("sonar", credentials) do
|
|
SonarClient.test_connection(credentials["instance_url"], credentials["api_token"])
|
|
end
|
|
|
|
defp test_provider_connection("splynx", credentials) do
|
|
SplynxClient.test_connection(credentials["instance_url"], credentials["api_key"], credentials["api_secret"])
|
|
end
|
|
|
|
defp test_provider_connection(_, _credentials), do: {:error, "Unknown provider"}
|
|
|
|
defp load_integrations(organization_id) do
|
|
organization_id
|
|
|> Integrations.list_integrations()
|
|
|> Map.new(fn integration -> {integration.provider, integration} end)
|
|
end
|
|
|
|
defp get_current_integration(socket) do
|
|
provider = socket.assigns.configuring
|
|
|
|
case Map.get(socket.assigns.integrations, provider) do
|
|
nil -> %Integration{}
|
|
integration -> integration
|
|
end
|
|
end
|
|
|
|
defp next_sync_minutes(nil, _interval), do: nil
|
|
|
|
defp next_sync_minutes(last_synced_at, interval) do
|
|
next_sync = DateTime.add(last_synced_at, interval * 60, :second)
|
|
diff = DateTime.diff(next_sync, DateTime.utc_now(), :second)
|
|
max(0, div(diff, 60))
|
|
end
|
|
|
|
defp normalize_params(provider, params, existing_integration) do
|
|
sync_interval = Map.get(params, "sync_interval_minutes")
|
|
|
|
credentials = build_credentials(provider, params, existing_integration)
|
|
|
|
attrs = %{credentials: credentials}
|
|
|
|
if sync_interval && sync_interval != "" do
|
|
Map.put(attrs, :sync_interval_minutes, sync_interval)
|
|
else
|
|
attrs
|
|
end
|
|
end
|
|
|
|
defp build_credentials("splynx", params, _existing) do
|
|
%{
|
|
"instance_url" => Map.get(params, "instance_url", ""),
|
|
"api_key" => Map.get(params, "api_key", ""),
|
|
"api_secret" => Map.get(params, "api_secret", "")
|
|
}
|
|
end
|
|
|
|
defp build_credentials("sonar", params, _existing) do
|
|
%{
|
|
"instance_url" => Map.get(params, "instance_url", ""),
|
|
"api_token" => Map.get(params, "api_token", "")
|
|
}
|
|
end
|
|
|
|
defp build_credentials("gaiia", params, existing) do
|
|
webhook_secret =
|
|
case existing do
|
|
%Integration{credentials: %{"webhook_secret" => secret}} when secret != "" ->
|
|
secret
|
|
|
|
_ ->
|
|
generate_webhook_secret()
|
|
end
|
|
|
|
%{
|
|
"api_key" => Map.get(params, "api_key", ""),
|
|
"webhook_secret" => webhook_secret
|
|
}
|
|
end
|
|
|
|
defp build_credentials(_provider, params, _existing) do
|
|
%{"api_key" => Map.get(params, "api_key", "")}
|
|
end
|
|
|
|
defp generate_webhook_secret do
|
|
32 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)
|
|
end
|
|
|
|
defp webhook_url(organization_id) do
|
|
ToweropsWeb.Endpoint.url() <> "/api/v1/webhooks/gaiia/#{organization_id}"
|
|
end
|
|
|
|
defp get_credential(nil, _key), do: ""
|
|
|
|
defp get_credential(%Integration{credentials: credentials}, key) when is_map(credentials) do
|
|
Map.get(credentials, key, "")
|
|
end
|
|
|
|
defp get_credential(_integration, _key), do: ""
|
|
|
|
defp extract_credentials(socket) do
|
|
changeset = socket.assigns.form.source
|
|
data = Ecto.Changeset.apply_changes(changeset)
|
|
data.credentials || %{}
|
|
end
|
|
|
|
defp missing_required_credentials?(provider, credentials) when provider in ["splynx"] do
|
|
blank?(credentials["instance_url"]) or blank?(credentials["api_key"]) or
|
|
blank?(credentials["api_secret"])
|
|
end
|
|
|
|
defp missing_required_credentials?(provider, credentials) when provider in ["sonar"] do
|
|
blank?(credentials["instance_url"]) or blank?(credentials["api_token"])
|
|
end
|
|
|
|
defp missing_required_credentials?(_provider, credentials) do
|
|
blank?(credentials["api_key"])
|
|
end
|
|
|
|
defp blank?(nil), do: true
|
|
defp blank?(""), do: true
|
|
defp blank?(_), do: false
|
|
|
|
defp format_connection_result({:ok, _body}), do: {:ok, "Connection successful"}
|
|
defp format_connection_result({:error, :unauthorized}), do: {:error, "Invalid API key"}
|
|
defp format_connection_result({:error, :forbidden}), do: {:error, "Access forbidden"}
|
|
|
|
defp format_connection_result({:error, {:unexpected_status, status}}),
|
|
do: {:error, "API returned unexpected status: #{status}"}
|
|
|
|
defp format_connection_result({:error, %{reason: :timeout}}), do: {:error, "Connection timeout - unable to reach API"}
|
|
|
|
defp format_connection_result({:error, %{reason: :econnrefused}}),
|
|
do: {:error, "Connection refused - check API endpoint"}
|
|
|
|
defp format_connection_result({:error, %{reason: :nxdomain}}), do: {:error, "DNS lookup failed - check API endpoint"}
|
|
|
|
defp format_connection_result({:error, {:graphql_errors, errors}}),
|
|
do: {:error, "API query failed: #{inspect(Enum.map(errors, & &1["message"]))}"}
|
|
|
|
# Never expose internal error details to users
|
|
defp format_connection_result({:error, reason}) do
|
|
Logger.warning("Integration connection test failed: #{inspect(reason)}")
|
|
{:error, "Connection failed - please check your API credentials and try again"}
|
|
end
|
|
end
|