add integrations settings page with preseem configuration

This commit is contained in:
Graham McIntire 2026-02-12 17:02:26 -06:00
parent 06f822dd50
commit 9ab0380c87
No known key found for this signature in database
5 changed files with 490 additions and 0 deletions

View file

@ -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

View file

@ -0,0 +1,199 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<div class="mb-4">
<.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
</.link>
</div>
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
Integrations
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Connect third-party services to enhance your monitoring capabilities.
</p>
</div>
<div class="mt-8 space-y-6">
<div
:for={provider <- @providers}
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-white/10 dark:bg-white/5"
>
<div class="flex items-start justify-between">
<div class="flex items-start gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-50 dark:bg-indigo-900/30">
<.icon name={provider.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{provider.name}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{provider.description}
</p>
<%= if integration = @integrations[provider.id] do %>
<div class="mt-3 flex items-center gap-4">
<span class={[
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
if(integration.enabled,
do: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
else: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
)
]}>
{if integration.enabled, do: "Enabled", else: "Disabled"}
</span>
<%= if integration.last_synced_at do %>
<span class="text-xs text-gray-500 dark:text-gray-400">
Last synced: {Calendar.strftime(
integration.last_synced_at,
"%Y-%m-%d %H:%M UTC"
)}
</span>
<% end %>
<%= if integration.last_sync_status && integration.last_sync_status != "never" do %>
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
case integration.last_sync_status do
"success" ->
"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)}
</span>
<% end %>
</div>
<% end %>
</div>
</div>
<div class="flex items-center gap-3">
<%= if integration = @integrations[provider.id] do %>
<button
type="button"
phx-click="toggle_enabled"
phx-value-provider={provider.id}
class={[
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2",
if(integration.enabled,
do: "bg-indigo-600",
else: "bg-gray-200 dark:bg-gray-700"
)
]}
>
<span class={[
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
if(integration.enabled, do: "translate-x-5", else: "translate-x-0")
]}>
</span>
</button>
<% end %>
<button
type="button"
id={"configure-#{provider.id}"}
phx-click="configure"
phx-value-provider={provider.id}
class="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"
>
Configure
</button>
</div>
</div>
<%= if @configuring == provider.id do %>
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-white/10">
<.form
for={@form}
id={"#{provider.id}-form"}
phx-change="validate"
phx-submit="save"
>
<div class="space-y-4">
<.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 %>
<div class={[
"rounded-md p-4",
case @test_result do
{:ok, _} -> "bg-green-50 dark:bg-green-900/20"
{:error, _} -> "bg-red-50 dark:bg-red-900/20"
end
]}>
<p class={[
"text-sm",
case @test_result do
{:ok, _} -> "text-green-800 dark:text-green-200"
{:error, _} -> "text-red-800 dark:text-red-200"
end
]}>
{elem(@test_result, 1)}
</p>
</div>
<% end %>
<div class="flex items-center justify-between pt-4">
<button
type="button"
id="test-connection"
phx-click="test_connection"
class="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"
>
Test Connection
</button>
<div class="flex items-center gap-3">
<button
type="button"
phx-click="close_config"
class="text-sm font-semibold text-gray-900 dark:text-white"
>
Cancel
</button>
<button
type="submit"
phx-disable-with="Saving..."
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
Save
</button>
</div>
</div>
</div>
</.form>
</div>
<% end %>
</div>
</div>
</Layouts.authenticated>

View file

@ -17,6 +17,14 @@
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Manage organization defaults for SNMP, agents, and MikroTik API configuration
</p>
<div class="mt-4">
<.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
</.link>
</div>
</div>
<.form

View file

@ -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

View file

@ -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