From 95ce9ad06df4f685d08044bbfa3db5d570927957 Mon Sep 17 00:00:00 2001 From: Graham McIntie Date: Sat, 14 Feb 2026 10:21:04 -0600 Subject: [PATCH] refactor: org settings into tabbed interface with integrations --- lib/towerops_web/live/org/settings_live.ex | 305 +++- .../live/org/settings_live.html.heex | 1264 +++++++++++------ 2 files changed, 1112 insertions(+), 457 deletions(-) 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

-
- <.link - navigate={~p"/orgs/#{@organization.slug}/settings/integrations"} - class="inline-flex items-center gap-2 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20" - > - <.icon name="hero-puzzle-piece" class="h-4 w-4" /> Integrations - -
- <.form - for={@form} - id="organization-form" - phx-change="validate" - phx-submit="save" - data-1p-ignore - data-form-type="other" - > -
- -
-
-

- Organization Name -

-

- Update the name of your organization. -

-
+
+ +
-
- <.input - field={@form[:name]} - type="text" - label="Organization Name" - required - /> -
-
+ <%= if @active_tab != "integrations" do %> + <.form + for={@form} + id="organization-form" + phx-change="validate" + phx-submit="save" + data-1p-ignore + data-form-type="other" + > +
+ <%= if @active_tab == "general" do %> + +
+
+

+ Organization Name +

+

+ Update the name of your organization. +

+
- <%= if @user_orgs_count > 1 do %> - -
-
-

- Default Organization -

-

- Set this organization as your default. When you log in, you'll be directed to your default organization. -

+
+ <.input + field={@form[:name]} + type="text" + label="Organization Name" + required + /> +
-
- <%= if @membership.is_default do %> -
-
- <.icon - name="hero-check-circle" - class="h-5 w-5 text-green-600 dark:text-green-400" + <%= if @user_orgs_count > 1 do %> + +
+
+

+ Default Organization +

+

+ Set this organization as your default. When you log in, you'll be directed to your default organization. +

+
+ +
+ <%= if @membership.is_default do %> +
+
+ <.icon + name="hero-check-circle" + class="h-5 w-5 text-green-600 dark:text-green-400" + /> +

+ This is your default organization +

+
+
+ <% else %> + +

+ Click to make this your default organization +

+ <% end %> +
+
+ <% end %> + + +
+
+

+ Site Organization +

+

+ Enable site organization to group devices into physical locations (offices, datacenters, etc.). +

+
+ +
+ <.input + field={@form[:use_sites]} + type="checkbox" + label="Use sites to organize devices" + /> +

+ 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 %> +
+
+
+ <.icon name="hero-exclamation-triangle" class="h-5 w-5 text-amber-400" /> +
+
+

+ Warning: Disabling Sites +

+
+

+ 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. +

+
+
+
+
+ <% end %> +
+
+ <% end %> + + <%= if @active_tab == "snmp" do %> + +
+
+

+ SNMP Configuration +

+

+ 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 +

+
+ +
+ <.input + field={@form[:snmp_version]} + type="select" + label="SNMP Version" + prompt="Select SNMP version" + options={[{"v1", "1"}, {"v2c", "2c"}, {"v3", "3"}]} + /> + + + <%= if @form[:snmp_version].value in ["1", "2c"] do %> + <.input + field={@form[:snmp_community]} + type="text" + label="SNMP Community String" + placeholder="e.g., public" + /> + <% end %> + + + <%= if @form[:snmp_version].value == "3" do %> + <.input + field={@form[:snmpv3_security_level]} + type="select" + label="Security Level" + prompt="Select security level" + options={[ + {"No Auth, No Priv", "noAuthNoPriv"}, + {"Auth, No Priv", "authNoPriv"}, + {"Auth, Priv", "authPriv"} + ]} + /> + + <.input + field={@form[:snmpv3_username]} + type="text" + label="Username" + placeholder="e.g., snmpuser" + /> + + + <%= if @form[:snmpv3_security_level].value in ["authNoPriv", "authPriv"] do %> + <.input + field={@form[:snmpv3_auth_protocol]} + type="select" + label="Auth Protocol" + prompt="Select protocol" + options={[ + {"SHA-256 (recommended)", "SHA-256"}, + {"SHA-512", "SHA-512"}, + {"SHA-384", "SHA-384"}, + {"SHA-224", "SHA-224"}, + {"SHA (SHA-1)", "SHA"}, + {"MD5", "MD5"} + ]} /> -

- 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 %> +

+

+ Force Apply to All Devices +

+

+ This will override SNMP settings for ALL devices across all sites in this organization. +

+ +
+ <% end %> +
+
+ <% end %> + + <%= if @active_tab == "mikrotik" && @current_scope.user.is_superuser do %> + +
+
+

+ MikroTik API Configuration +

+

+ 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 +

+
+ +
+ <.input + field={@form[:mikrotik_enabled]} + type="checkbox" + label="Enable MikroTik API" + /> + +
+ <.input + field={@form[:mikrotik_username]} + type="text" + label="Username" + placeholder="e.g., admin" + autocomplete="off" + /> +
+ +
+ <.input + field={@form[:mikrotik_password]} + type="password" + label="Password" + placeholder="RouterOS API password" + autocomplete="off" + /> +
+ +
+ <.input + field={@form[:mikrotik_port]} + type="number" + label="API Port" + placeholder="8729" + autocomplete="off" + /> +
+ +
+ <.input + field={@form[:mikrotik_use_ssl]} + type="checkbox" + label="Use SSL (API-SSL)" + /> +
+ +
+
+

+ 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.

- <% else %> + +
+
+

+ Security Warning: + Plain API (port 8728) sends credentials unencrypted. Use SSL (port 8729) whenever possible. +

+
+
+
+
+ <% end %> + + <%= if @active_tab == "agents" do %> + + <%= if @available_agents != [] do %> +
+
+

+ Default Agent +

+

+ 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 +

+
+ +
+ <.input + field={@form[:default_agent_token_id]} + type="select" + label="Default Remote Agent" + prompt="No default agent - cloud polling" + options={Enum.map(@available_agents, &{&1.name, &1.id})} + /> + +
+

+ Current Device Assignment: +

+
+
+ <.icon name="hero-server" class="h-4 w-4" /> + + {@assignment_breakdown.direct} device-level override + +
+
+ <.icon name="hero-building-office" class="h-4 w-4" /> + + {@assignment_breakdown.site} inherited from site + +
+
+ <.icon name="hero-building-office-2" class="h-4 w-4" /> + + {@assignment_breakdown.organization} + inheriting organization default + +
+
+ <.icon name="hero-cloud" class="h-4 w-4" /> + + {@assignment_breakdown.cloud} cloud polling (no agent) + +
+
+
+ + <%= if @organization.default_agent_token_id do %> +
+

+ Force Apply to All Devices +

+

+ This will assign the default agent to ALL devices across all sites in this organization. +

+ +
+ <% end %> +
+
+ <% else %> +
+
+

+ Default Agent +

+

+ 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. +

+
+ +
+ +
+
+ <% end %> + <% end %> +
+ + +
+ <.link + navigate={~p"/dashboard"} + class="text-sm font-semibold text-gray-900 dark:text-white" + > + Cancel + + +
+ + <% end %> + + <%= if @active_tab == "integrations" do %> +
+
+
+
+
+ <.icon name={provider.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" /> +
+
+

+ {provider.name} +

+

+ {provider.description} +

+ + <%= if integration = @integrations[provider.id] do %> +
+ + {if integration.enabled, do: "Enabled", else: "Disabled"} + + + <%= if integration.last_synced_at do %> + + Last synced: + <.timestamp + datetime={integration.last_synced_at} + timezone={@timezone} + format="absolute" + /> + + <% end %> + + <%= if integration.last_sync_status && integration.last_sync_status != "never" do %> + + "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" + + "partial" -> + "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" + + "failed" -> + "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" + + _ -> + "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" + end + ]}> + {String.capitalize(integration.last_sync_status)} + + <% end %> + + <%= if integration.last_synced_at do %> + + · Next sync in ~{next_sync_minutes( + integration.last_synced_at, + integration.sync_interval_minutes + )}m + + <% end %> + + <%= if provider.id == "preseem" do %> + <.link + navigate={ + ~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices" + } + class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" + > + Manage Devices → + + <.link + navigate={~p"/insights?source=preseem"} + class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" + > + Network Insights → + + <% end %> + <%= if provider.id == "gaiia" do %> + <.link + navigate={~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"} + class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" + > + Entity Mapping → + + <.link + navigate={ + ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation" + } + class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" + > + Reconciliation → + + <% end %> + <%!-- Sync schedule info --%> + <%= if integration.enabled do %> +
+

+ <.icon name="hero-clock" class="h-3.5 w-3.5 inline" /> Sync Schedule +

+

+ <%= 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 %> +

+
+ <% end %> +
+ <% end %> +
+
+ +
+ <%= if integration = @integrations[provider.id] do %> -

- Click to make this your default organization -

<% end %> + +
- <% end %> - - -
-
-

- Site Organization -

-

- Enable site organization to group devices into physical locations (offices, datacenters, etc.). -

-
-
- <.input - field={@form[:use_sites]} - type="checkbox" - label="Use sites to organize devices" - /> -

- When enabled, you can organize devices into sites. When disabled, all devices belong directly to the organization. -

+ <%= if @configuring == provider.id do %> +
+ <.form + for={@integration_form} + id={"#{provider.id}-form"} + phx-change="validate_integration" + phx-submit="save_integration" + > +
+ <.input + field={@integration_form[:api_key]} + type="password" + label="API Key" + placeholder={"Enter your #{provider.name} API key"} + autocomplete="off" + value={get_credential(@integrations[provider.id], "api_key")} + /> - <%= if @organization.use_sites && @form[:use_sites].value == false do %> -
-
-
- <.icon name="hero-exclamation-triangle" class="h-5 w-5 text-amber-400" /> -
-
-

- Warning: Disabling Sites -

-
-

- 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 %> +

"bg-green-50 dark:bg-green-900/20" + {:error, _} -> "bg-red-50 dark:bg-red-900/20" + end + ]}> +

"text-green-800 dark:text-green-200" + {:error, _} -> "text-red-800 dark:text-red-200" + end + ]}> + {elem(@test_result, 1)}

+ <% end %> + +
+ + +
+ + +
+ +
+ <% end %> + + <%= if provider.id == "gaiia" && @integrations["gaiia"] do %> +
+

+ Webhook Configuration +

+

+ Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change. +

+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+
+ +
+
+ Setup Instructions +
+
    +
  1. In your Gaiia admin panel, go to Settings → Webhooks
  2. +
  3. Click "Add Webhook"
  4. +
  5. Paste the Webhook URL above
  6. +
  7. Paste the Webhook Secret above into the "Secret" field
  8. +
  9. Select events: Account, Billing Subscription, Inventory Item
  10. +
  11. Save the webhook
  12. +
+

+ Once configured, Towerops will receive real-time updates when accounts, subscriptions, or inventory items change in Gaiia. +

+
- <% end %> -
+
+ <% end %>
- - -
-
-

- SNMP Configuration -

-

- 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 -

-
- -
- <.input - field={@form[:snmp_version]} - type="select" - label="SNMP Version" - prompt="Select SNMP version" - options={[{"v1", "1"}, {"v2c", "2c"}, {"v3", "3"}]} - /> - - - <%= if @form[:snmp_version].value in ["1", "2c"] do %> - <.input - field={@form[:snmp_community]} - type="text" - label="SNMP Community String" - placeholder="e.g., public" - /> - <% end %> - - - <%= if @form[:snmp_version].value == "3" do %> - <.input - field={@form[:snmpv3_security_level]} - type="select" - label="Security Level" - prompt="Select security level" - options={[ - {"No Auth, No Priv", "noAuthNoPriv"}, - {"Auth, No Priv", "authNoPriv"}, - {"Auth, Priv", "authPriv"} - ]} - /> - - <.input - field={@form[:snmpv3_username]} - type="text" - label="Username" - placeholder="e.g., snmpuser" - /> - - - <%= if @form[:snmpv3_security_level].value in ["authNoPriv", "authPriv"] do %> - <.input - field={@form[:snmpv3_auth_protocol]} - type="select" - label="Auth Protocol" - prompt="Select protocol" - options={[ - {"SHA-256 (recommended)", "SHA-256"}, - {"SHA-512", "SHA-512"}, - {"SHA-384", "SHA-384"}, - {"SHA-224", "SHA-224"}, - {"SHA (SHA-1)", "SHA"}, - {"MD5", "MD5"} - ]} - /> - - <.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 %> -
-

- Force Apply to All Devices -

-

- This will override SNMP settings for ALL devices across all sites in this organization. -

- -
- <% end %> -
-
- - <%= if @current_scope.user.is_superuser do %> - -
-
-

- MikroTik API Configuration -

-

- 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 -

-
- -
- <.input - field={@form[:mikrotik_enabled]} - type="checkbox" - label="Enable MikroTik API" - /> - -
- <.input - field={@form[:mikrotik_username]} - type="text" - label="Username" - placeholder="e.g., admin" - autocomplete="off" - /> -
- -
- <.input - field={@form[:mikrotik_password]} - type="password" - label="Password" - placeholder="RouterOS API password" - autocomplete="off" - /> -
- -
- <.input - field={@form[:mikrotik_port]} - type="number" - label="API Port" - placeholder="8729" - autocomplete="off" - /> -
- -
- <.input - field={@form[:mikrotik_use_ssl]} - type="checkbox" - label="Use SSL (API-SSL)" - /> -
- -
-
-

- 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. -

-
-
-
-
- <% end %> - - - <%= if @available_agents != [] do %> -
-
-

- Default Agent -

-

- 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 -

-
- -
- <.input - field={@form[:default_agent_token_id]} - type="select" - label="Default Remote Agent" - prompt="No default agent - cloud polling" - options={Enum.map(@available_agents, &{&1.name, &1.id})} - /> - -
-

- Current Device Assignment: -

-
-
- <.icon name="hero-server" class="h-4 w-4" /> - - {@assignment_breakdown.direct} device-level override - -
-
- <.icon name="hero-building-office" class="h-4 w-4" /> - - {@assignment_breakdown.site} inherited from site - -
-
- <.icon name="hero-building-office-2" class="h-4 w-4" /> - - {@assignment_breakdown.organization} - inheriting organization default - -
-
- <.icon name="hero-cloud" class="h-4 w-4" /> - - {@assignment_breakdown.cloud} cloud polling (no agent) - -
-
-
- - <%= if @organization.default_agent_token_id do %> -
-

- Force Apply to All Devices -

-

- This will assign the default agent to ALL devices across all sites in this organization. -

- -
- <% end %> -
-
- <% else %> -
-
-

- Default Agent -

-

- 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. -

-
- -
- -
-
- <% end %>
- - -
- <.link - navigate={~p"/dashboard"} - class="text-sm font-semibold text-gray-900 dark:text-white" - > - Cancel - - -
- + <% end %>