towerops/lib/towerops_web/live/org/integrations_live.ex
Graham McIntire f2608e8f16
fix: status emoji accumulating in page title and add missing page titles
Fix JS regex in StatusTitle hook to use unicode flag so emoji are
properly matched and replaced instead of accumulating. Add page_title
to 8 LiveViews that were missing it, which caused "TowerOps | TowerOps"
duplication from the live_title suffix.
2026-03-11 09:54:18 -05:00

477 lines
15 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:
"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"
}
]
}
]
@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("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