towerops/lib/towerops_web/live/org/settings_live.ex
Graham McIntire f2608e8f16
fix: status emoji accumulating in page title and add missing page titles
Fix JS regex in StatusTitle hook to use unicode flag so emoji are
properly matched and replaced instead of accumulating. Add page_title
to 8 LiveViews that were missing it, which caused "TowerOps | TowerOps"
duplication from the live_title suffix.
2026-03-11 09:54:18 -05:00

910 lines
31 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",
setup_help: %{
url_pattern: "https://app.gaiia.com/{your-org}/admin/apiKeys",
steps: [
"Log into Gaiia and go to **Admin** → **API Keys**",
"Click **Create API Key**",
"Give it a descriptive name (e.g. \"TowerOps\")",
"Copy the generated API key and paste it below"
],
permissions:
"Read-only access. The key only needs permission to read accounts, subscriptions, and inventory data."
}
},
%{
id: "sonar",
name: "Sonar",
description:
"Billing and subscriber management. Sync accounts, network sites, and inventory from your Sonar instance.",
icon: "hero-currency-dollar",
setup_help: %{
url_pattern: "https://{your-instance}.sonar.software/admin/users/{your-user-id}",
steps: [
"Log into Sonar and go to **Settings** → **Security** → **API Tokens** (or your user profile → API Tokens)",
"Click **Create Token**",
"Name it (e.g. \"TowerOps\") and copy the generated token"
],
permissions:
"Requires a **read-only** personal access token. The token needs access to: Accounts, Network Sites, Inventory Items, and Address data."
}
},
%{
id: "splynx",
name: "Splynx",
description:
"ISP billing and management platform. Sync customers, internet services, and network routers from Splynx.",
icon: "hero-banknotes",
setup_help: %{
url_pattern: "https://{your-instance}.splynx.app/admin/administration/api-keys",
steps: [
"Log into Splynx and go to **Administration** → **API Keys**",
"Click **Add** to create a new API key",
"Copy both the **API Key** and **API Secret**"
],
permissions:
"Requires read access to **Customers**, **Internet Services**, and **Routers**. No write permissions needed."
}
},
%{
id: "visp",
name: "VISP",
description: "Cloud-based ISP management. Sync subscribers, sites, equipment, and MRR data from VISP.",
icon: "hero-cloud",
setup_help: %{
url_pattern: "https://app.visp.net/settings/api",
steps: [
"Log into VISP and go to **Settings** → **API**",
"Click **Generate New Key**",
"Name it (e.g. \"TowerOps\") and copy the generated API key"
],
permissions: "Requires read-only API access to **Subscribers**, **Sites**, **Equipment**, and **Billing** data."
}
},
# Quality of Experience
%{
id: "preseem",
name: "Preseem",
description: "QoE monitoring and subscriber experience analytics for wireless ISPs.",
icon: "hero-signal",
category: "Quality of Experience",
setup_help: %{
url_pattern: "https://app.preseem.com/settings/api",
steps: [
"Log into Preseem and go to **Settings** → **API**",
"Click **Generate API Key**",
"Copy the generated key and paste it below"
],
permissions: "Read-only access. The key needs permission to read subscriber QoE metrics and access point data."
}
},
# 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",
setup_help: %{
url_pattern: "https://{your-netbox-instance}/user/api-tokens/",
steps: [
"Log into NetBox and go to your **profile** (top right) → **API Tokens**",
"Click **Add a token**",
"Optionally set an expiry date and description (e.g. \"TowerOps\")",
"Copy the generated token"
],
permissions:
"Requires **read** permission at minimum. If you want to push data to NetBox (bidirectional sync), the token also needs **write** permission."
}
}
]
@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(:page_title, t("Organization Settings"))
|> 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 format_help_step(text) do
String.replace(text, ~r/\*\*(.+?)\*\*/, "<strong>\\1</strong>")
end
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