defmodule ToweropsWeb.Org.SettingsLive do @moduledoc false use ToweropsWeb, :live_view alias Towerops.Accounts.UserNotifier 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" }, %{ 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" }, %{ 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" } ] @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) {: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") # Members tab assigns - loaded lazily |> assign(:members, []) |> assign(:pending_invitations, []) |> assign(:invite_form, to_form(%{"email" => "", "role" => "member"})) # 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(: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, "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, "Applied SNMP configuration to #{count} device records across all sites")} 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, "Applied default agent to #{count} device records across all sites")} 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 | organization: organization} accept_url = url(~p"/invitations/#{invitation.token}") UserNotifier.deliver_invitation_email(invitation, accept_url) {:noreply, socket |> put_flash(:info, "Invitation sent to #{email}") |> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id)) |> assign(:invite_form, to_form(%{"email" => "", "role" => "member"}))} {:error, changeset} -> {:noreply, put_flash(socket, :error, "Failed to send invitation: #{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, "Invitation cancelled") |> assign(:pending_invitations, Organizations.list_pending_invitations(organization.id))} else {:noreply, put_flash(socket, :error, "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, "Member removed") |> assign(:members, Organizations.list_organization_members(organization.id))} {:error, :cannot_remove_owner} -> {:noreply, put_flash(socket, :error, "Cannot remove the organization owner")} {:error, _} -> {:noreply, put_flash(socket, :error, "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, "Role updated") |> assign(:members, Organizations.list_organization_members(organization.id))} {:error, :cannot_change_owner_role} -> {:noreply, put_flash(socket, :error, "Cannot change the owner's role")} {:error, _} -> {:noreply, put_flash(socket, :error, "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, "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, "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("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, "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 provider = socket.assigns.configuring if provider == "netbox" do integration = current_integration(socket) url = get_credential(integration, "url") token = get_credential(integration, "api_token") cond do url == "" or is_nil(url) -> {:noreply, assign(socket, :test_result, {:error, "Please enter your NetBox URL first"})} token == "" or is_nil(token) -> {:noreply, assign(socket, :test_result, {:error, "Please enter your NetBox API token first"})} true -> result = Towerops.NetBox.Client.test_connection(url, token) {:noreply, assign(socket, :test_result, format_connection_result(result))} end else 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 result = test_provider_connection(provider, api_key) {:noreply, assign(socket, :test_result, format_connection_result(result))} end end 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, "Webhook secret saved")} {:error, _changeset} -> {:noreply, put_flash(socket, :error, "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, "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("pagerduty", api_key), do: Towerops.PagerDuty.Client.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 if provider == "netbox" do normalize_netbox_params(params, existing_integration) else 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") webhook_secret = case existing_integration do %Integration{credentials: %{"webhook_secret" => secret}} when is_binary(secret) -> 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 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_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 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