Removes unreachable catch-all clauses, drops unused `require Logger` lines, adds pin operators to bitstring size patterns, reorders function heads for correct dispatch, and replaces deprecated LoggerBackends calls.
546 lines
17 KiB
Elixir
546 lines
17 KiB
Elixir
defmodule ToweropsWeb.Org.IntegrationsLive do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.CnMaestro.Client, as: CnMaestroClient
|
|
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.Uisp.Client, as: UispClient
|
|
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:
|
|
"Sync subscribers, inventory, and billing data to enable outage impact analysis and equipment reconciliation.",
|
|
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"
|
|
},
|
|
%{
|
|
id: "uisp",
|
|
name: "UISP",
|
|
description: "Ubiquiti UISP network management — auto-import sites and devices.",
|
|
icon: "hero-signal"
|
|
},
|
|
%{
|
|
id: "cn_maestro",
|
|
name: "cnMaestro",
|
|
description: "Cambium cnMaestro — sync devices, networks, and statistics via OAuth 2.0.",
|
|
icon: "hero-wifi"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
|
|
@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(:page_title, t("Integrations"))
|
|
|> 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("cn_maestro", credentials),
|
|
do: CnMaestroClient.test_connection(credentials["url"], credentials["client_id"], credentials["client_secret"])
|
|
|
|
defp test_provider_connection("uisp", credentials),
|
|
do: UispClient.test_connection(credentials["url"], credentials["api_key"])
|
|
|
|
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(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 humanize_interval(minutes) when minutes >= 60 do
|
|
hours = div(minutes, 60)
|
|
rem_min = rem(minutes, 60)
|
|
|
|
if rem_min == 0 do
|
|
"#{hours}h"
|
|
else
|
|
"#{hours}h #{rem_min}min"
|
|
end
|
|
end
|
|
|
|
defp humanize_interval(minutes), do: "#{minutes}min"
|
|
|
|
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}
|
|
|
|
attrs =
|
|
if credentials_changed?(existing_integration, credentials) do
|
|
Map.merge(attrs, %{last_sync_status: "never", last_sync_message: nil, last_synced_at: nil})
|
|
else
|
|
attrs
|
|
end
|
|
|
|
if sync_interval && sync_interval != "" do
|
|
Map.put(attrs, :sync_interval_minutes, sync_interval)
|
|
else
|
|
attrs
|
|
end
|
|
end
|
|
|
|
defp credentials_changed?(nil, _new_credentials), do: false
|
|
|
|
defp credentials_changed?(%Integration{credentials: old}, new) when is_map(old) and is_map(new) do
|
|
old != new
|
|
end
|
|
|
|
defp credentials_changed?(_existing, _new), do: true
|
|
|
|
defp build_credentials("cn_maestro", params, _existing) do
|
|
%{
|
|
"url" => Map.get(params, "url", ""),
|
|
"client_id" => Map.get(params, "client_id", ""),
|
|
"client_secret" => Map.get(params, "client_secret", "")
|
|
}
|
|
end
|
|
|
|
defp build_credentials("uisp", params, _existing) do
|
|
%{
|
|
"url" => Map.get(params, "url", ""),
|
|
"api_key" => Map.get(params, "api_key", "")
|
|
}
|
|
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 ["cn_maestro"] do
|
|
blank?(credentials["url"]) or blank?(credentials["client_id"]) or blank?(credentials["client_secret"])
|
|
end
|
|
|
|
defp missing_required_credentials?(provider, credentials) when provider in ["uisp"] do
|
|
blank?(credentials["url"]) or blank?(credentials["api_key"])
|
|
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
|