refactor: org settings into tabbed interface with integrations
This commit is contained in:
parent
6fd643a380
commit
95ce9ad06d
2 changed files with 1112 additions and 457 deletions
|
|
@ -4,24 +4,39 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
|
||||
alias Towerops.Admin.AuditLogger
|
||||
alias Towerops.Agents
|
||||
alias Towerops.Gaiia.Client, as: GaiiaClient
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Integrations.Integration
|
||||
alias Towerops.Organizations
|
||||
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"
|
||||
}
|
||||
]
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
user = socket.assigns.current_scope.user
|
||||
available_agents = Agents.list_organization_agent_tokens(organization.id)
|
||||
|
||||
# Get assignment breakdown to show impact
|
||||
assignment_breakdown = Agents.Stats.get_device_assignment_breakdown(organization.id)
|
||||
|
||||
# Get user's membership for this org to check if it's default
|
||||
membership = Organizations.get_membership(organization.id, user.id)
|
||||
|
||||
# Count user's total organizations
|
||||
user_orgs_count = user.id |> Organizations.list_user_organizations() |> length()
|
||||
|
||||
# Log organization data access
|
||||
AuditLogger.log_org_data_accessed(nil, user.id, organization.id, "view_settings")
|
||||
|
||||
changeset = Organizations.change_organization(organization)
|
||||
|
|
@ -33,9 +48,38 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
|> assign(:user_orgs_count, user_orgs_count)
|
||||
|> assign(:available_agents, available_agents)
|
||||
|> assign(:assignment_breakdown, assignment_breakdown)
|
||||
|> assign(:form, to_form(changeset))}
|
||||
|> assign(:form, to_form(changeset))
|
||||
|> assign(:active_tab, "general")
|
||||
# Integration assigns - loaded lazily when tab is selected
|
||||
|> assign(:providers, @providers)
|
||||
|> assign(:integrations, %{})
|
||||
|> assign(:configuring, nil)
|
||||
|> assign(:integration_form, nil)
|
||||
|> assign(:test_result, nil)
|
||||
|> assign(:timezone, socket.assigns.current_scope.timezone)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
tab = Map.get(params, "tab", "general")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:active_tab, tab)
|
||||
|> maybe_load_integrations(tab)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp maybe_load_integrations(socket, "integrations") do
|
||||
integrations = load_integrations(socket.assigns.organization.id)
|
||||
assign(socket, :integrations, integrations)
|
||||
end
|
||||
|
||||
defp maybe_load_integrations(socket, _tab), do: socket
|
||||
|
||||
# === Org settings events ===
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"organization" => org_params}, socket) do
|
||||
changeset =
|
||||
|
|
@ -88,7 +132,6 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
else
|
||||
case Organizations.set_default_organization(user.id, organization.id) do
|
||||
{:ok, _} ->
|
||||
# Refresh membership to reflect change
|
||||
updated_membership = Organizations.get_membership(organization.id, user.id)
|
||||
|
||||
{:noreply,
|
||||
|
|
@ -101,4 +144,248 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
# === Integration events ===
|
||||
|
||||
@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(:integration_form, form)
|
||||
|> assign(:test_result, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("close_config", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:configuring, nil)
|
||||
|> assign(:integration_form, nil)
|
||||
|> assign(:test_result, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate_integration", %{"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, :integration_form, to_form(changeset))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save_integration", %{"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(:integration_form, nil)
|
||||
|> assign(:test_result, nil)
|
||||
|> put_flash(:info, "Integration saved successfully")}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :integration_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
|
||||
|
||||
# === Private helpers (integrations) ===
|
||||
|
||||
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.integration_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"]))}"}
|
||||
|
||||
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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue