- Show sender's full name in invitation emails - Use relative time for "Sent" column (more readable) - Use date format for "Expires" column (shorter) - Make invitation table horizontally scrollable Fixes display cropping issue where long timestamps caused the pending invitations table to overflow its container.
845 lines
28 KiB
Elixir
845 lines
28 KiB
Elixir
defmodule ToweropsWeb.Org.SettingsLive do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Accounts.UserNotifier
|
|
alias Towerops.Admin.AuditLogger
|
|
alias Towerops.Agents
|
|
alias Towerops.Billing
|
|
alias Towerops.Gaiia.Client, as: GaiiaClient
|
|
alias Towerops.Integrations
|
|
alias Towerops.Integrations.Integration
|
|
alias Towerops.Organizations
|
|
alias Towerops.Organizations.SubscriptionLimits
|
|
alias Towerops.Preseem.Client, as: PreseemClient
|
|
alias Towerops.Sonar.Client, as: SonarClient
|
|
alias Towerops.Splynx.Client, as: SplynxClient
|
|
alias Towerops.Visp.Client, as: VispClient
|
|
|
|
require Logger
|
|
|
|
@billing_provider_ids ~w(gaiia sonar splynx visp)
|
|
|
|
@providers [
|
|
# Billing providers (mutually exclusive — grouped first)
|
|
%{
|
|
id: "gaiia",
|
|
name: "Gaiia",
|
|
description:
|
|
"Billing and subscriber management. Enables outage impact analysis, inventory reconciliation, and subscriber-aware monitoring.",
|
|
icon: "hero-user-group"
|
|
},
|
|
%{
|
|
id: "sonar",
|
|
name: "Sonar",
|
|
description:
|
|
"Billing and subscriber management. Sync accounts, network sites, and inventory from your Sonar instance.",
|
|
icon: "hero-currency-dollar"
|
|
},
|
|
%{
|
|
id: "splynx",
|
|
name: "Splynx",
|
|
description:
|
|
"ISP billing and management platform. Sync customers, internet services, and network routers from Splynx.",
|
|
icon: "hero-banknotes"
|
|
},
|
|
%{
|
|
id: "visp",
|
|
name: "VISP",
|
|
description: "Cloud-based ISP management. Sync subscribers, sites, equipment, and MRR data from VISP.",
|
|
icon: "hero-cloud"
|
|
},
|
|
# Quality of Experience
|
|
%{
|
|
id: "preseem",
|
|
name: "Preseem",
|
|
description: "QoE monitoring and subscriber experience analytics for wireless ISPs.",
|
|
icon: "hero-signal",
|
|
category: "Quality of Experience"
|
|
},
|
|
# Incident Management
|
|
%{
|
|
id: "pagerduty",
|
|
name: "PagerDuty",
|
|
description:
|
|
"Incident management and on-call alerting. Automatically triggers, acknowledges, and resolves PagerDuty incidents from TowerOps alerts.",
|
|
icon: "hero-bell-alert",
|
|
category: "Incident Management"
|
|
},
|
|
# Infrastructure & IPAM
|
|
%{
|
|
id: "netbox",
|
|
name: "NetBox",
|
|
description:
|
|
"Infrastructure source of truth. Sync devices, sites, IP addresses, and interfaces between TowerOps and your NetBox instance.",
|
|
icon: "hero-server-stack",
|
|
category: "Infrastructure & IPAM"
|
|
}
|
|
]
|
|
|
|
@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)
|
|
assignment_breakdown = Agents.Stats.get_device_assignment_breakdown(organization.id)
|
|
membership = Organizations.get_membership(organization.id, user.id)
|
|
user_orgs_count = user.id |> Organizations.list_user_organizations() |> length()
|
|
|
|
AuditLogger.log_org_data_accessed(nil, user.id, organization.id, "view_settings")
|
|
|
|
changeset = Organizations.change_organization(organization)
|
|
|
|
# Billing information
|
|
{current_devices, device_limit} = SubscriptionLimits.device_quota(organization)
|
|
{:ok, cost_info} = Billing.estimated_monthly_cost(organization)
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:organization, organization)
|
|
|> assign(:membership, membership)
|
|
|> assign(:user_orgs_count, user_orgs_count)
|
|
|> assign(:available_agents, available_agents)
|
|
|> assign(:assignment_breakdown, assignment_breakdown)
|
|
|> assign(:form, to_form(changeset))
|
|
|> assign(:active_tab, "general")
|
|
# Billing tab assigns
|
|
|> assign(:current_devices, current_devices)
|
|
|> assign(:device_limit, device_limit)
|
|
|> assign(:estimated_cost, cost_info)
|
|
# Members tab assigns - loaded lazily
|
|
|> assign(:members, [])
|
|
|> assign(:pending_invitations, [])
|
|
|> assign(:invite_form, to_form(%{"email" => "", "role" => "technician"}))
|
|
# Integration assigns - loaded lazily when tab is selected
|
|
|> assign(:providers, @providers)
|
|
|> assign(:billing_provider_ids, @billing_provider_ids)
|
|
|> assign(:integrations, %{})
|
|
|> assign(:configuring, nil)
|
|
|> assign(:integration_form, nil)
|
|
|> assign(:test_result, nil)
|
|
|> assign(:netbox_config, default_netbox_config())
|
|
|> 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, "members") do
|
|
org_id = socket.assigns.organization.id
|
|
|
|
socket
|
|
|> assign(:members, Organizations.list_organization_members(org_id))
|
|
|> assign(:pending_invitations, Organizations.list_pending_invitations(org_id))
|
|
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 =
|
|
socket.assigns.organization
|
|
|> Organizations.change_organization(org_params)
|
|
|> Map.put(:action, :validate)
|
|
|
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("save", %{"organization" => org_params}, socket) do
|
|
case Organizations.update_organization(socket.assigns.organization, org_params) do
|
|
{:ok, organization} ->
|
|
changeset = Organizations.change_organization(organization)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Settings saved successfully"))
|
|
|> assign(:organization, organization)
|
|
|> assign(:form, to_form(changeset))}
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("apply_snmp_to_all", _params, socket) do
|
|
{count, _} = Organizations.apply_snmp_config_to_all_equipment(socket.assigns.organization.id)
|
|
|
|
{:noreply,
|
|
put_flash(socket, :info, t("Applied SNMP configuration to %{count} device records across all sites", count: count))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("apply_agent_to_all", _params, socket) do
|
|
{count, _} = Organizations.apply_agent_to_all_equipment(socket.assigns.organization.id)
|
|
|
|
{:noreply,
|
|
put_flash(socket, :info, t("Applied default agent to %{count} device records across all sites", count: count))}
|
|
end
|
|
|
|
# === Members tab events ===
|
|
|
|
@impl true
|
|
def handle_event("send_invitation", %{"email" => email, "role" => role}, socket) do
|
|
organization = socket.assigns.organization
|
|
user = socket.assigns.current_scope.user
|
|
|
|
attrs = %{
|
|
email: String.trim(email),
|
|
role: role,
|
|
organization_id: organization.id,
|
|
invited_by_id: user.id
|
|
}
|
|
|
|
case Organizations.create_invitation(attrs) do
|
|
{:ok, invitation} ->
|
|
invitation =
|
|
invitation
|
|
|> Towerops.Repo.preload(:invited_by)
|
|
|> Map.put(:organization, organization)
|
|
|
|
accept_url = url(~p"/invitations/#{invitation.token}")
|
|
UserNotifier.deliver_invitation_email(invitation, accept_url)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Invitation sent to %{email}", email: email))
|
|
|> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id))
|
|
|> assign(:invite_form, to_form(%{"email" => "", "role" => "technician"}))}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply,
|
|
put_flash(socket, :error, t("Failed to send invitation: %{errors}", errors: error_messages(changeset)))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("cancel_invitation", %{"id" => id}, socket) do
|
|
organization = socket.assigns.organization
|
|
invitation = Towerops.Repo.get!(Towerops.Organizations.Invitation, id)
|
|
|
|
if invitation.organization_id == organization.id do
|
|
Organizations.delete_invitation(invitation)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Invitation cancelled"))
|
|
|> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id))}
|
|
else
|
|
{:noreply, put_flash(socket, :error, t("Invitation not found"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("remove_member", %{"user-id" => user_id}, socket) do
|
|
organization = socket.assigns.organization
|
|
|
|
case Organizations.remove_member(organization.id, user_id) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Member removed"))
|
|
|> assign(:members, Organizations.list_organization_members(organization.id))}
|
|
|
|
{:error, :cannot_remove_owner} ->
|
|
{:noreply, put_flash(socket, :error, t("Cannot remove the organization owner"))}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to remove member"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("change_role", %{"user-id" => user_id, "role" => role}, socket) do
|
|
organization = socket.assigns.organization
|
|
|
|
case Organizations.update_member_role(organization.id, user_id, role) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Role updated"))
|
|
|> assign(:members, Organizations.list_organization_members(organization.id))}
|
|
|
|
{:error, :cannot_change_owner_role} ->
|
|
{:noreply, put_flash(socket, :error, t("Cannot change the owner's role"))}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to update role"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_default_org", _params, socket) do
|
|
user = socket.assigns.current_scope.user
|
|
organization = socket.assigns.organization
|
|
membership = socket.assigns.membership
|
|
|
|
if membership.is_default do
|
|
{:noreply, put_flash(socket, :error, t("This is already your default organization"))}
|
|
else
|
|
case Organizations.set_default_organization(user.id, organization.id) do
|
|
{:ok, _} ->
|
|
updated_membership = Organizations.get_membership(organization.id, user.id)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, "#{organization.name} is now your default organization")
|
|
|> assign(:membership, updated_membership)}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to set default organization"))}
|
|
end
|
|
end
|
|
end
|
|
|
|
# === Integration events ===
|
|
|
|
@impl true
|
|
def handle_event("configure", %{"provider" => provider}, socket) do
|
|
if socket.assigns.configuring == provider do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:configuring, nil)
|
|
|> assign(:integration_form, nil)
|
|
|> assign(:test_result, nil)}
|
|
else
|
|
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
|
|
|
|
socket =
|
|
if provider == "netbox" do
|
|
assign(socket, :netbox_config, load_netbox_config(integration))
|
|
else
|
|
socket
|
|
end
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:configuring, provider)
|
|
|> assign(:integration_form, form)
|
|
|> assign(:test_result, nil)}
|
|
end
|
|
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("set_netbox_sync_direction", %{"direction" => direction}, socket) do
|
|
netbox_config = Map.put(socket.assigns.netbox_config, "sync_direction", direction)
|
|
{:noreply, assign(socket, :netbox_config, netbox_config)}
|
|
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)
|
|
|
|
socket =
|
|
if socket.assigns.configuring == "netbox" do
|
|
assign(socket, :netbox_config, %{
|
|
"sync_direction" => Map.get(params, "sync_direction", "pull"),
|
|
"sync_devices" => Map.get(params, "sync_devices", "true") == "true",
|
|
"sync_sites" => Map.get(params, "sync_sites", "true") == "true",
|
|
"sync_ip_addresses" => Map.get(params, "sync_ip_addresses", "false") == "true",
|
|
"sync_interfaces" => Map.get(params, "sync_interfaces", "false") == "true",
|
|
"device_role_filter" => Map.get(params, "device_role_filter", ""),
|
|
"site_filter" => Map.get(params, "site_filter", ""),
|
|
"tag_filter" => Map.get(params, "tag_filter", "")
|
|
})
|
|
else
|
|
socket
|
|
end
|
|
|
|
{: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, t("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
|
|
result = do_test_connection(socket.assigns.configuring, socket)
|
|
{:noreply, assign(socket, :test_result, result)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("save_webhook_secret", %{"value" => secret}, socket) do
|
|
case Map.get(socket.assigns.integrations, "gaiia") do
|
|
nil ->
|
|
{:noreply, socket}
|
|
|
|
integration ->
|
|
updated_credentials =
|
|
Map.put(integration.credentials, "webhook_secret", String.trim(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, t("Webhook secret saved"))}
|
|
|
|
{:error, _changeset} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to save 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, t("Integration updated"))}
|
|
|
|
{:error, _changeset} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to update integration"))}
|
|
end
|
|
end
|
|
end
|
|
|
|
# === Billing events ===
|
|
|
|
@impl true
|
|
def handle_event("upgrade_to_paid", _params, socket) do
|
|
org = socket.assigns.organization
|
|
|
|
success_url = url(~p"/orgs/#{org.slug}/settings?tab=billing&status=success")
|
|
cancel_url = url(~p"/orgs/#{org.slug}/settings?tab=billing&status=cancelled")
|
|
|
|
case Billing.create_checkout_session(org,
|
|
success_url: success_url,
|
|
cancel_url: cancel_url
|
|
) do
|
|
{:ok, checkout_url} ->
|
|
{:noreply, redirect(socket, external: checkout_url)}
|
|
|
|
{:error, reason} ->
|
|
Logger.error("Failed to create checkout session: #{inspect(reason)}")
|
|
|
|
{:noreply, put_flash(socket, :error, t("Failed to start checkout. Please try again later."))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("manage_billing", _params, socket) do
|
|
org = socket.assigns.organization
|
|
return_url = url(~p"/orgs/#{org.slug}/settings?tab=billing")
|
|
|
|
case Billing.create_portal_session(org, return_url) do
|
|
{:ok, portal_url} ->
|
|
{:noreply, redirect(socket, external: portal_url)}
|
|
|
|
{:error, :no_stripe_customer} ->
|
|
{:noreply,
|
|
put_flash(
|
|
socket,
|
|
:error,
|
|
t("No billing account found. Please upgrade to a paid plan first.")
|
|
)}
|
|
|
|
{:error, reason} ->
|
|
Logger.error("Failed to create portal session: #{inspect(reason)}")
|
|
|
|
{:noreply, put_flash(socket, :error, t("Failed to open billing portal. Please try again later."))}
|
|
end
|
|
end
|
|
|
|
# === Private helpers (integrations) ===
|
|
|
|
defp do_test_connection("netbox", socket) do
|
|
{url, token} = extract_netbox_credentials(socket)
|
|
|
|
with :ok <- validate_present(url, t("Please enter your NetBox URL first")),
|
|
:ok <- validate_present(token, t("Please enter your NetBox API token first")) do
|
|
format_connection_result(Towerops.NetBox.Client.test_connection(url, token))
|
|
end
|
|
end
|
|
|
|
defp do_test_connection("sonar", socket) do
|
|
{instance_url, api_token} = extract_sonar_credentials(socket)
|
|
|
|
with :ok <- validate_present(instance_url, t("Please enter your Sonar instance URL first")),
|
|
:ok <- validate_present(api_token, t("Please enter your Sonar API token first")) do
|
|
format_connection_result(SonarClient.test_connection(instance_url, api_token))
|
|
end
|
|
end
|
|
|
|
defp do_test_connection("splynx", socket) do
|
|
{instance_url, api_key, api_secret} = extract_splynx_credentials(socket)
|
|
|
|
with :ok <- validate_present(instance_url, t("Please enter your Splynx instance URL first")),
|
|
:ok <- validate_present(api_key, t("Please enter your Splynx API key first")),
|
|
:ok <- validate_present(api_secret, t("Please enter your Splynx API secret first")) do
|
|
format_connection_result(SplynxClient.test_connection(instance_url, api_key, api_secret))
|
|
end
|
|
end
|
|
|
|
defp do_test_connection(provider, socket) do
|
|
api_key = extract_api_key(socket)
|
|
|
|
with :ok <- validate_present(api_key, t("Please enter an API key first")) do
|
|
format_connection_result(test_provider_connection(provider, api_key))
|
|
end
|
|
end
|
|
|
|
defp validate_present(nil, msg), do: {:error, msg}
|
|
defp validate_present("", msg), do: {:error, msg}
|
|
defp validate_present(_, _msg), do: :ok
|
|
|
|
# === 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("pagerduty", api_key), do: Towerops.PagerDuty.Client.test_connection(api_key)
|
|
|
|
defp test_provider_connection("visp", api_key), do: VispClient.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
|
|
provider =
|
|
case existing_integration do
|
|
%Integration{provider: p} -> p
|
|
_ -> Map.get(params, "provider", "")
|
|
end
|
|
|
|
case provider do
|
|
"netbox" -> normalize_netbox_params(params, existing_integration)
|
|
"sonar" -> normalize_sonar_params(params)
|
|
"splynx" -> normalize_splynx_params(params)
|
|
_ -> normalize_standard_params(params, existing_integration)
|
|
end
|
|
end
|
|
|
|
defp normalize_standard_params(params, existing_integration) do
|
|
api_key = Map.get(params, "api_key", "")
|
|
sync_interval = Map.get(params, "sync_interval_minutes")
|
|
|
|
# Use form value if provided, otherwise preserve existing
|
|
webhook_secret =
|
|
case Map.get(params, "webhook_secret") do
|
|
secret when is_binary(secret) and secret != "" ->
|
|
secret
|
|
|
|
_ ->
|
|
case existing_integration do
|
|
%Integration{credentials: %{"webhook_secret" => secret}} when is_binary(secret) ->
|
|
secret
|
|
|
|
_ ->
|
|
""
|
|
end
|
|
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 normalize_netbox_params(params, _existing_integration) do
|
|
sync_interval = Map.get(params, "sync_interval_minutes")
|
|
|
|
credentials = %{
|
|
"url" => String.trim(Map.get(params, "url", "")),
|
|
"api_token" => Map.get(params, "api_token", ""),
|
|
"sync_direction" => Map.get(params, "sync_direction", "pull"),
|
|
"sync_devices" => Map.get(params, "sync_devices", "true") == "true",
|
|
"sync_sites" => Map.get(params, "sync_sites", "true") == "true",
|
|
"sync_ip_addresses" => Map.get(params, "sync_ip_addresses", "false") == "true",
|
|
"sync_interfaces" => Map.get(params, "sync_interfaces", "false") == "true",
|
|
"device_role_filter" => String.trim(Map.get(params, "device_role_filter", "")),
|
|
"site_filter" => String.trim(Map.get(params, "site_filter", "")),
|
|
"tag_filter" => String.trim(Map.get(params, "tag_filter", ""))
|
|
}
|
|
|
|
attrs = %{credentials: credentials}
|
|
|
|
if sync_interval && sync_interval != "" do
|
|
Map.put(attrs, :sync_interval_minutes, sync_interval)
|
|
else
|
|
attrs
|
|
end
|
|
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 current_integration(socket) do
|
|
provider = socket.assigns.configuring
|
|
Map.get(socket.assigns.integrations, provider)
|
|
end
|
|
|
|
defp extract_netbox_credentials(socket) do
|
|
# Try form changeset first (user may have typed but not saved)
|
|
changeset = socket.assigns.integration_form.source
|
|
form_creds = Ecto.Changeset.get_field(changeset, :credentials) || %{}
|
|
form_url = Map.get(form_creds, "url", "")
|
|
form_token = Map.get(form_creds, "api_token", "")
|
|
|
|
# Fall back to saved integration
|
|
integration = current_integration(socket)
|
|
saved_url = get_credential(integration, "url")
|
|
saved_token = get_credential(integration, "api_token")
|
|
|
|
url = if form_url == "", do: saved_url, else: form_url
|
|
token = if form_token == "", do: saved_token, else: form_token
|
|
|
|
{url, token}
|
|
end
|
|
|
|
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 normalize_sonar_params(params) do
|
|
sync_interval = Map.get(params, "sync_interval_minutes")
|
|
|
|
attrs = %{
|
|
credentials: %{
|
|
"instance_url" => String.trim(Map.get(params, "instance_url", "")),
|
|
"api_token" => Map.get(params, "api_token", "")
|
|
}
|
|
}
|
|
|
|
if sync_interval && sync_interval != "" do
|
|
Map.put(attrs, :sync_interval_minutes, sync_interval)
|
|
else
|
|
attrs
|
|
end
|
|
end
|
|
|
|
defp normalize_splynx_params(params) do
|
|
sync_interval = Map.get(params, "sync_interval_minutes")
|
|
|
|
attrs = %{
|
|
credentials: %{
|
|
"instance_url" => String.trim(Map.get(params, "instance_url", "")),
|
|
"api_key" => Map.get(params, "api_key", ""),
|
|
"api_secret" => Map.get(params, "api_secret", "")
|
|
}
|
|
}
|
|
|
|
if sync_interval && sync_interval != "" do
|
|
Map.put(attrs, :sync_interval_minutes, sync_interval)
|
|
else
|
|
attrs
|
|
end
|
|
end
|
|
|
|
defp extract_sonar_credentials(socket) do
|
|
changeset = socket.assigns.integration_form.source
|
|
creds = Ecto.Changeset.get_field(changeset, :credentials) || %{}
|
|
integration = current_integration(socket)
|
|
|
|
instance_url = creds |> Map.get("instance_url", "") |> non_empty(get_credential(integration, "instance_url"))
|
|
api_token = creds |> Map.get("api_token", "") |> non_empty(get_credential(integration, "api_token"))
|
|
|
|
{instance_url, api_token}
|
|
end
|
|
|
|
defp extract_splynx_credentials(socket) do
|
|
changeset = socket.assigns.integration_form.source
|
|
creds = Ecto.Changeset.get_field(changeset, :credentials) || %{}
|
|
integration = current_integration(socket)
|
|
|
|
instance_url = creds |> Map.get("instance_url", "") |> non_empty(get_credential(integration, "instance_url"))
|
|
api_key = creds |> Map.get("api_key", "") |> non_empty(get_credential(integration, "api_key"))
|
|
api_secret = creds |> Map.get("api_secret", "") |> non_empty(get_credential(integration, "api_secret"))
|
|
|
|
{instance_url, api_key, api_secret}
|
|
end
|
|
|
|
defp non_empty("", fallback), do: fallback
|
|
defp non_empty(nil, fallback), do: fallback
|
|
defp non_empty(value, _fallback), do: value
|
|
|
|
defp error_messages(changeset) do
|
|
changeset
|
|
|> Ecto.Changeset.traverse_errors(fn {msg, opts} ->
|
|
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
|
|
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
|
end)
|
|
end)
|
|
|> Enum.map_join(", ", fn {field, msgs} -> "#{field} #{Enum.join(msgs, ", ")}" 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
|
|
|
|
defp default_netbox_config do
|
|
%{
|
|
"sync_direction" => "pull",
|
|
"sync_devices" => true,
|
|
"sync_sites" => true,
|
|
"sync_ip_addresses" => false,
|
|
"sync_interfaces" => false,
|
|
"device_role_filter" => "",
|
|
"site_filter" => "",
|
|
"tag_filter" => ""
|
|
}
|
|
end
|
|
|
|
defp load_netbox_config(nil), do: default_netbox_config()
|
|
|
|
defp load_netbox_config(%Integration{credentials: creds}) when is_map(creds) do
|
|
%{
|
|
"sync_direction" => Map.get(creds, "sync_direction", "pull"),
|
|
"sync_devices" => Map.get(creds, "sync_devices", true),
|
|
"sync_sites" => Map.get(creds, "sync_sites", true),
|
|
"sync_ip_addresses" => Map.get(creds, "sync_ip_addresses", false),
|
|
"sync_interfaces" => Map.get(creds, "sync_interfaces", false),
|
|
"device_role_filter" => Map.get(creds, "device_role_filter", ""),
|
|
"site_filter" => Map.get(creds, "site_filter", ""),
|
|
"tag_filter" => Map.get(creds, "tag_filter", "")
|
|
}
|
|
end
|
|
|
|
defp load_netbox_config(_), do: default_netbox_config()
|
|
end
|