towerops/lib/towerops_web/live/org/integrations_live.ex
Graham McIntire 2e9f1660d0
stagger preseem sync across organizations
Replace the thundering-herd cron job with a dispatcher that enqueues
individual per-integration Oban jobs staggered across the 10-minute
window using PollingOffset. Show last synced timestamp in user's
local timezone via the <.timestamp> component.
2026-02-13 10:52:32 -06:00

203 lines
5.9 KiB
Elixir

defmodule ToweropsWeb.Org.IntegrationsLive do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Integrations
alias Towerops.Integrations.Integration
alias Towerops.Preseem.Client, as: PreseemClient
@providers [
%{
id: "preseem",
name: "Preseem",
description: "QoE monitoring and subscriber experience analytics for wireless ISPs.",
icon: "hero-signal"
}
]
@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))
|> 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)
result =
case existing do
nil ->
Integrations.create_integration(organization.id, Map.put(attrs, :provider, provider))
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)
if api_key == "" or is_nil(api_key) do
{:noreply, assign(socket, :test_result, {:error, "Please enter an API key first"})}
else
result = PreseemClient.test_connection(api_key)
{:noreply, assign(socket, :test_result, format_connection_result(result))}
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 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 normalize_params(params) do
api_key = Map.get(params, "api_key", "")
%{credentials: %{"api_key" => api_key}}
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 Preseem 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"}
# Never expose internal error details to users
defp format_connection_result({:error, _reason}),
do: {:error, "Connection failed - please check your API credentials and try again"}
end