From 9ab0380c87656e3231eef0e36d8565dc33ed66cb Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 17:02:26 -0600 Subject: [PATCH] add integrations settings page with preseem configuration --- .../live/org/integrations_live.ex | 200 ++++++++++++++++++ .../live/org/integrations_live.html.heex | 199 +++++++++++++++++ .../live/org/settings_live.html.heex | 8 + lib/towerops_web/router.ex | 1 + .../live/org/integrations_live_test.exs | 82 +++++++ 5 files changed, 490 insertions(+) create mode 100644 lib/towerops_web/live/org/integrations_live.ex create mode 100644 lib/towerops_web/live/org/integrations_live.html.heex create mode 100644 test/towerops_web/live/org/integrations_live_test.exs diff --git a/lib/towerops_web/live/org/integrations_live.ex b/lib/towerops_web/live/org/integrations_live.ex new file mode 100644 index 00000000..3a6346ad --- /dev/null +++ b/lib/towerops_web/live/org/integrations_live.ex @@ -0,0 +1,200 @@ +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(: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 + case PreseemClient.test_connection(api_key) do + {:ok, _body} -> + {:noreply, assign(socket, :test_result, {:ok, "Connection successful"})} + + {:error, :unauthorized} -> + {:noreply, assign(socket, :test_result, {:error, "Invalid API key"})} + + {:error, :forbidden} -> + {:noreply, assign(socket, :test_result, {:error, "Access forbidden"})} + + {:error, reason} -> + {:noreply, assign(socket, :test_result, {:error, "Connection failed: #{inspect(reason)}"})} + 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 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", "") + base_url = Map.get(params, "base_url", "") + + credentials = + then(%{"api_key" => api_key}, fn creds -> + if base_url == "", do: creds, else: Map.put(creds, "base_url", base_url) + end) + + %{credentials: credentials} + 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 +end diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex new file mode 100644 index 00000000..765a03a8 --- /dev/null +++ b/lib/towerops_web/live/org/integrations_live.html.heex @@ -0,0 +1,199 @@ + +
+
+ <.link + navigate={~p"/orgs/#{@organization.slug}/settings"} + class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white" + > + <.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Settings + +
+

+ Integrations +

+

+ Connect third-party services to enhance your monitoring capabilities. +

+
+ +
+
+
+
+
+ <.icon name={provider.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" /> +
+
+

+ {provider.name} +

+

+ {provider.description} +

+ + <%= if integration = @integrations[provider.id] do %> +
+ + {if integration.enabled, do: "Enabled", else: "Disabled"} + + + <%= if integration.last_synced_at do %> + + Last synced: {Calendar.strftime( + integration.last_synced_at, + "%Y-%m-%d %H:%M UTC" + )} + + <% end %> + + <%= if integration.last_sync_status && integration.last_sync_status != "never" do %> + + "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" + + "partial" -> + "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" + + "failed" -> + "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" + + _ -> + "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" + end + ]}> + {String.capitalize(integration.last_sync_status)} + + <% end %> +
+ <% end %> +
+
+ +
+ <%= if integration = @integrations[provider.id] do %> + + <% end %> + + +
+
+ + <%= if @configuring == provider.id do %> +
+ <.form + for={@form} + id={"#{provider.id}-form"} + phx-change="validate" + phx-submit="save" + > +
+ <.input + field={@form[:api_key]} + type="password" + label="API Key" + placeholder="Enter your Preseem API key" + autocomplete="off" + value={get_credential(@integrations[provider.id], "api_key")} + /> + + <.input + field={@form[:base_url]} + type="text" + label="Base URL (optional)" + placeholder="https://apidocs.preseem.com/model/v1" + value={get_credential(@integrations[provider.id], "base_url")} + /> + + <%= if @test_result do %> +
"bg-green-50 dark:bg-green-900/20" + {:error, _} -> "bg-red-50 dark:bg-red-900/20" + end + ]}> +

"text-green-800 dark:text-green-200" + {:error, _} -> "text-red-800 dark:text-red-200" + end + ]}> + {elem(@test_result, 1)} +

+
+ <% end %> + +
+ + +
+ + +
+
+
+ +
+ <% end %> +
+
+
diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index 83c96647..fd51a558 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -17,6 +17,14 @@

Manage organization defaults for SNMP, agents, and MikroTik API configuration

+
+ <.link + navigate={~p"/orgs/#{@organization.slug}/settings/integrations"} + class="inline-flex items-center gap-2 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20" + > + <.icon name="hero-puzzle-piece" class="h-4 w-4" /> Integrations + +
<.form diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 4f61a60a..cd77ce0c 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -309,6 +309,7 @@ defmodule ToweropsWeb.Router do pipe_through [:browser, :require_authenticated_user, :load_current_organization] live "/settings", Org.SettingsLive, :index + live "/settings/integrations", Org.IntegrationsLive, :index end end diff --git a/test/towerops_web/live/org/integrations_live_test.exs b/test/towerops_web/live/org/integrations_live_test.exs new file mode 100644 index 00000000..0e94d330 --- /dev/null +++ b/test/towerops_web/live/org/integrations_live_test.exs @@ -0,0 +1,82 @@ +defmodule ToweropsWeb.Org.IntegrationsLiveTest do + use ToweropsWeb.ConnCase + + import Phoenix.LiveViewTest + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.Client, as: PreseemClient + + setup do + user = user_fixture() + organization = organization_fixture(user.id) + %{user: user, organization: organization} + end + + describe "index" do + test "renders integrations page", %{conn: conn, user: user, organization: org} do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + assert html =~ "Integrations" + assert html =~ "Preseem" + end + + test "shows preseem description", %{conn: conn, user: user, organization: org} do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + assert html =~ "QoE monitoring" + end + + test "can open preseem configuration", %{conn: conn, user: user, organization: org} do + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + html = view |> element("#configure-preseem") |> render_click() + assert html =~ "API Key" + end + + test "can save preseem credentials", %{conn: conn, user: user, organization: org} do + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + view |> element("#configure-preseem") |> render_click() + + view + |> form("#preseem-form", %{integration: %{api_key: "test-key-123"}}) + |> render_submit() + + assert {:ok, integration} = Towerops.Integrations.get_integration(org.id, "preseem") + assert integration.credentials["api_key"] == "test-key-123" + end + + test "can test preseem connection successfully", %{conn: conn, user: user, organization: org} do + Req.Test.stub(PreseemClient, fn conn -> + Req.Test.json(conn, %{"status" => "ok"}) + end) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + view |> element("#configure-preseem") |> render_click() + + view + |> form("#preseem-form", %{integration: %{api_key: "test-key"}}) + |> render_change() + + html = view |> element("#test-connection") |> render_click() + assert html =~ "Connection successful" + end + end +end