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 require Logger @providers [ %{ id: "preseem", name: "Preseem", description: "QoE monitoring and subscriber experience analytics for wireless ISPs.", icon: "hero-signal" }, %{ id: "gaiia", name: "Gaiia", description: "Billing and subscriber management. Enables outage impact analysis, inventory reconciliation, and subscriber-aware monitoring.", icon: "hero-user-group" }, %{ id: "pagerduty", name: "PagerDuty", description: "Incident management and on-call alerting. Automatically triggers, acknowledges, and resolves PagerDuty incidents from TowerOps alerts.", icon: "hero-bell-alert" } ] @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(: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) changeset = integration |> Integrations.change_integration(normalize_params(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(params, existing) result = 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 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, "Integration saved successfully")} {:error, changeset} -> {:noreply, assign(socket, :form, to_form(changeset))} end end @impl true def handle_event("test_connection", _params, socket) do api_key = extract_api_key(socket) provider = socket.assigns.configuring if api_key == "" or is_nil(api_key) do {:noreply, assign(socket, :test_result, {:error, "Please enter an API key first"})} else result = test_provider_connection(provider, api_key) {: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, "Webhook secret regenerated")} {:error, _changeset} -> {:noreply, put_flash(socket, :error, "Failed to regenerate webhook secret")} end end end @impl true def handle_event("toggle_enabled", %{"provider" => provider}, socket) do case Map.get(socket.assigns.integrations, provider) do nil -> {:noreply, socket} integration -> 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, "Integration updated")} {:error, _changeset} -> {:noreply, put_flash(socket, :error, "Failed to update integration")} end end end defp test_provider_connection("preseem", api_key), do: PreseemClient.test_connection(api_key) defp test_provider_connection("gaiia", api_key), do: GaiiaClient.test_connection(api_key) defp test_provider_connection(_, _api_key), 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(params, existing_integration) do api_key = Map.get(params, "api_key", "") sync_interval = Map.get(params, "sync_interval_minutes") webhook_secret = case existing_integration do %Integration{credentials: %{"webhook_secret" => secret}} when secret != "" -> secret _ -> generate_webhook_secret() end attrs = %{credentials: %{"api_key" => api_key, "webhook_secret" => webhook_secret}} if sync_interval && sync_interval != "" do Map.put(attrs, :sync_interval_minutes, sync_interval) else attrs end 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_api_key(socket) do changeset = socket.assigns.form.source data = Ecto.Changeset.apply_changes(changeset) case data.credentials do %{"api_key" => key} -> key _ -> nil end end 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