diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex index 6d848ce2..3e117a1f 100644 --- a/lib/towerops_web/live/org/settings_live.ex +++ b/lib/towerops_web/live/org/settings_live.ex @@ -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 diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index fd51a558..fb104c24 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -15,479 +15,847 @@ Organization Settings
- Manage organization defaults for SNMP, agents, and MikroTik API configuration + Manage organization defaults, integrations, and configuration
-- Update the name of your organization. -
-+ Update the name of your organization. +
+- Set this organization as your default. When you log in, you'll be directed to your default organization. -
++ Set this organization as your default. When you log in, you'll be directed to your default organization. +
++ This is your default organization +
++ Click to make this your default organization +
+ <% end %> ++ Enable site organization to group devices into physical locations (offices, datacenters, etc.). +
++ When enabled, you can organize devices into sites. When disabled, all devices belong directly to the organization. +
+ + <%= if @organization.use_sites && @form[:use_sites].value == false do %> ++ All devices will be removed from their current sites and assigned directly to the organization. + Site assignments will be lost, but devices will remain in the organization. +
++ Set default SNMP settings for all devices in this organization. These can be overridden at the site or device level. +
++ <.icon name="hero-information-circle" class="h-4 w-4 inline" /> + Hierarchy: Device > Site > Organization +
+- This is your default organization + + <.input + field={@form[:snmpv3_auth_password]} + type="password" + label="Auth Password" + placeholder="Min 8 characters" + autocomplete="off" + /> + <% end %> + + + <%= if @form[:snmpv3_security_level].value == "authPriv" do %> + <.input + field={@form[:snmpv3_priv_protocol]} + type="select" + label="Privacy Protocol" + prompt="Select protocol" + options={[ + {"AES-128 (recommended)", "AES"}, + {"AES-256", "AES-256"}, + {"AES-192", "AES-192"}, + {"AES-256-C", "AES-256-C"}, + {"DES (legacy)", "DES"} + ]} + /> + + <.input + field={@form[:snmpv3_priv_password]} + type="password" + label="Privacy Password" + placeholder="Min 8 characters" + autocomplete="off" + /> + <% end %> + <% end %> + + <.input + field={@form[:snmp_port]} + type="number" + label="SNMP Port" + placeholder="161" + /> + + <%= if @organization.snmp_community do %> +
+ This will override SNMP settings for ALL devices across all sites in this organization. +
+ ++ Set default MikroTik RouterOS API credentials for all devices in this organization. Only applies to devices detected as MikroTik. +
++ <.icon name="hero-beaker" class="h-4 w-4 inline" /> + Experimental Feature: + MikroTik API integration is under active development. +
++ <.icon name="hero-information-circle" class="h-4 w-4 inline" /> + Hierarchy: Device > Site > Organization +
++ Critical Security Warning: + Plain API (port 8728) sends credentials unencrypted over the network. This setting is + blocked for devices using cloud pollers + and should only be used with local agents on trusted networks.
+ Security Warning: + Plain API (port 8728) sends credentials unencrypted. Use SSL (port 8729) whenever possible. +
++ Select a default agent for SNMP polling. This will be used for all devices in this organization unless overridden at the site or device level. +
++ <.icon name="hero-information-circle" class="h-4 w-4 inline" /> + Hierarchy: Device > Site > Organization +
++ Current Device Assignment: +
++ This will assign the default agent to ALL devices across all sites in this organization. +
+ ++ No agents configured yet. + <.link + navigate={~p"/agents"} + class="text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300" + > + Create an agent + + to enable remote polling. +
++ {provider.description} +
+ + <%= if integration = @integrations[provider.id] do %> ++ <%= if provider.id == "preseem" do %> + Syncs every {integration.sync_interval_minutes} minutes. Baselines and fleet profiles computed nightly. + <% end %> + <%= if provider.id == "gaiia" do %> + Syncs every {integration.sync_interval_minutes} minutes. Reconciliation runs nightly. Also receives real-time webhook updates. + <% end %> +
+- Click to make this your default organization -
<% end %> + +- Enable site organization to group devices into physical locations (offices, datacenters, etc.). -
-- When enabled, you can organize devices into sites. When disabled, all devices belong directly to the organization. -
+ <%= if @configuring == provider.id do %> +- All devices will be removed from their current sites and assigned directly to the organization. - Site assignments will be lost, but devices will remain in the organization. + <.input + field={@integration_form[:sync_interval_minutes]} + type="number" + label="Sync interval (minutes)" + min="5" + value={ + if(@integrations[provider.id], + do: @integrations[provider.id].sync_interval_minutes, + else: if(provider.id == "gaiia", do: 15, else: 10) + ) + } + /> + + <%= if @test_result do %> +
"text-green-800 dark:text-green-200" + {:error, _} -> "text-red-800 dark:text-red-200" + end + ]}> + {elem(@test_result, 1)}
+ Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change. +
+ ++ Once configured, Towerops will receive real-time updates when accounts, subscriptions, or inventory items change in Gaiia. +
+- Set default SNMP settings for all devices in this organization. These can be overridden at the site or device level. -
-- <.icon name="hero-information-circle" class="h-4 w-4 inline" /> - Hierarchy: Device > Site > Organization -
-- This will override SNMP settings for ALL devices across all sites in this organization. -
- -- Set default MikroTik RouterOS API credentials for all devices in this organization. Only applies to devices detected as MikroTik. -
-- <.icon name="hero-beaker" class="h-4 w-4 inline" /> - Experimental Feature: - MikroTik API integration is under active development. -
-- <.icon name="hero-information-circle" class="h-4 w-4 inline" /> - Hierarchy: Device > Site > Organization -
-- Critical Security Warning: - Plain API (port 8728) sends credentials unencrypted over the network. This setting is - blocked for devices using cloud pollers - and should only be used with local agents on trusted networks. -
-- Security Warning: - Plain API (port 8728) sends credentials unencrypted. Use SSL (port 8729) whenever possible. -
-- Select a default agent for SNMP polling. This will be used for all devices in this organization unless overridden at the site or device level. -
-- <.icon name="hero-information-circle" class="h-4 w-4 inline" /> - Hierarchy: Device > Site > Organization -
-- Current Device Assignment: -
-- This will assign the default agent to ALL devices across all sites in this organization. -
- -- No agents configured yet. - <.link - navigate={~p"/agents"} - class="text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300" - > - Create an agent - - to enable remote polling. -
-