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