add integrations settings page with preseem configuration
This commit is contained in:
parent
06f822dd50
commit
9ab0380c87
5 changed files with 490 additions and 0 deletions
200
lib/towerops_web/live/org/integrations_live.ex
Normal file
200
lib/towerops_web/live/org/integrations_live.ex
Normal 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
|
||||
199
lib/towerops_web/live/org/integrations_live.html.heex
Normal file
199
lib/towerops_web/live/org/integrations_live.html.heex
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
82
test/towerops_web/live/org/integrations_live_test.exs
Normal file
82
test/towerops_web/live/org/integrations_live_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue