towerops/lib/towerops_web/live/org/integrations_live.ex
Graham McIntire a4e08b2aca Major codebase cleanup: DRY schemas, remove dead config, consolidate modules
- Created Towerops.Schema base macro (95+ schemas updated to use it)
- Created Towerops.Snmp.Reading macro (7 reading schemas consolidated)
- Created Towerops.Gaiia.BaseSchema macro (4 Gaiia schemas consolidated)
- Created Towerops.SyncLog shared module (UISP + Preseem merge)
- Created Towerops.LogFilters (3 log filter modules merged into 1)
- Created Towerops.OnCall.ChangesetHelpers (9 on-call schemas simplified)
- Created API v1 ResourceController shared helpers (7 controllers)
- Extracted ConnectionHelpers.format_connection_result (2 LiveViews)
- Removed 5 dead config keys (scopes, mib_dirs, stripe_meter_id, etc.)
- Fixed 2 pre-existing broken tests
- All 13,219 tests pass, Credo clean (0 issues)
2026-06-16 15:29:22 -05:00

521 lines
16 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
alias ToweropsWeb.Helpers.ConnectionHelpers
@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"
}
]
}
]
@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(: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(result), do: ConnectionHelpers.format_connection_result(result)
end