diff --git a/config/runtime.exs b/config/runtime.exs index 12b76b43..e9572c85 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -204,6 +204,12 @@ if config_env() == :prod do {"*/5 * * * *", Towerops.Workers.GaiiaSyncWorker}, # Sync NetBox data every 30 minutes {"*/5 * * * *", Towerops.Workers.NetBoxSyncWorker}, + # Sync Sonar billing data every 5 minutes + {"*/5 * * * *", Towerops.Workers.SonarSyncWorker}, + # Sync Splynx billing data every 5 minutes + {"*/5 * * * *", Towerops.Workers.SplynxSyncWorker}, + # Sync VISP billing data every 5 minutes + {"*/5 * * * *", Towerops.Workers.VispSyncWorker}, # Device health insights nightly at 3:30 AM {"30 3 * * *", Towerops.Workers.DeviceHealthInsightWorker}, # System insights (agent offline detection) every 5 minutes diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex index 564c3650..2497d2d3 100644 --- a/lib/towerops_web/live/org/settings_live.ex +++ b/lib/towerops_web/live/org/settings_live.ex @@ -43,6 +43,26 @@ defmodule ToweropsWeb.Org.SettingsLive do description: "Infrastructure source of truth. Sync devices, sites, IP addresses, and interfaces between TowerOps and your NetBox instance.", icon: "hero-server-stack" + }, + %{ + id: "sonar", + name: "Sonar", + description: + "Billing and subscriber management. Sync accounts, network sites, and inventory from your Sonar instance.", + icon: "hero-currency-dollar" + }, + %{ + id: "splynx", + name: "Splynx", + description: + "ISP billing and management platform. Sync customers, internet services, and network routers from Splynx.", + icon: "hero-banknotes" + }, + %{ + id: "visp", + name: "VISP", + description: "Cloud-based ISP management. Sync subscribers, sites, equipment, and MRR data from VISP.", + icon: "hero-cloud" } ] @@ -388,30 +408,64 @@ defmodule ToweropsWeb.Org.SettingsLive do def handle_event("test_connection", _params, socket) do provider = socket.assigns.configuring - if provider == "netbox" do - # Read from form changeset (unsaved) first, fall back to saved integration - {url, token} = extract_netbox_credentials(socket) + cond do + provider == "netbox" -> + {url, token} = extract_netbox_credentials(socket) - cond do - url == "" or is_nil(url) -> - {:noreply, assign(socket, :test_result, {:error, t("Please enter your NetBox URL first")})} + cond do + url == "" or is_nil(url) -> + {:noreply, assign(socket, :test_result, {:error, t("Please enter your NetBox URL first")})} - token == "" or is_nil(token) -> - {:noreply, assign(socket, :test_result, {:error, "Please enter your NetBox API token 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) + true -> + result = Towerops.NetBox.Client.test_connection(url, token) + {:noreply, assign(socket, :test_result, format_connection_result(result))} + end + + provider == "sonar" -> + {instance_url, api_token} = extract_sonar_credentials(socket) + + cond do + instance_url == "" or is_nil(instance_url) -> + {:noreply, assign(socket, :test_result, {:error, t("Please enter your Sonar instance URL first")})} + + api_token == "" or is_nil(api_token) -> + {:noreply, assign(socket, :test_result, {:error, t("Please enter your Sonar API token first")})} + + true -> + result = SonarClient.test_connection(instance_url, api_token) + {:noreply, assign(socket, :test_result, format_connection_result(result))} + end + + provider == "splynx" -> + {instance_url, api_key, api_secret} = extract_splynx_credentials(socket) + + cond do + instance_url == "" or is_nil(instance_url) -> + {:noreply, assign(socket, :test_result, {:error, t("Please enter your Splynx instance URL first")})} + + api_key == "" or is_nil(api_key) -> + {:noreply, assign(socket, :test_result, {:error, t("Please enter your Splynx API key first")})} + + api_secret == "" or is_nil(api_secret) -> + {:noreply, assign(socket, :test_result, {:error, t("Please enter your Splynx API secret first")})} + + true -> + result = SplynxClient.test_connection(instance_url, api_key, api_secret) + {:noreply, assign(socket, :test_result, format_connection_result(result))} + end + + true -> + api_key = extract_api_key(socket) + + if api_key == "" or is_nil(api_key) do + {:noreply, assign(socket, :test_result, {:error, t("Please enter an API key first")})} + else + result = test_provider_connection(provider, api_key) {: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, t("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 end @@ -469,6 +523,8 @@ defmodule ToweropsWeb.Org.SettingsLive do 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 @@ -501,10 +557,11 @@ defmodule ToweropsWeb.Org.SettingsLive do _ -> Map.get(params, "provider", "") end - if provider == "netbox" do - normalize_netbox_params(params, existing_integration) - else - normalize_standard_params(params, existing_integration) + 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 @@ -607,6 +664,68 @@ defmodule ToweropsWeb.Org.SettingsLive do 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} -> diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index b31950e4..a4a84052 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -876,6 +876,9 @@ "gaiia" -> "bg-purple-100 dark:bg-purple-900/40" "pagerduty" -> "bg-emerald-100 dark:bg-emerald-900/40" "netbox" -> "bg-orange-100 dark:bg-orange-900/40" + "sonar" -> "bg-violet-100 dark:bg-violet-900/40" + "splynx" -> "bg-blue-100 dark:bg-blue-900/40" + "visp" -> "bg-green-100 dark:bg-green-900/40" _ -> "bg-indigo-100 dark:bg-indigo-900/40" end ]}> @@ -888,6 +891,9 @@ "gaiia" -> "text-purple-600 dark:text-purple-400" "pagerduty" -> "text-emerald-600 dark:text-emerald-400" "netbox" -> "text-orange-600 dark:text-orange-400" + "sonar" -> "text-violet-600 dark:text-violet-400" + "splynx" -> "text-blue-600 dark:text-blue-400" + "visp" -> "text-green-600 dark:text-green-400" _ -> "text-indigo-600 dark:text-indigo-400" end ]} @@ -1497,137 +1503,55 @@ <% else %> - <.form - for={@integration_form} - id={"#{provider.id}-form"} - phx-change="validate_integration" - phx-submit="save_integration" - > -
-
-
- <.icon name="hero-key" class="h-4 w-4 text-gray-400" /> -

- {t("Connection Settings")} -

-
- + <%= if provider.id == "sonar" do %> + <.form + for={@integration_form} + id="sonar-form" + phx-change="validate_integration" + phx-submit="save_integration" + > +
- - -
-
- - <%= if provider.id == "pagerduty" do %> -
-

- <.icon name="hero-information-circle" class="h-4 w-4" /> - {t("Where to find your Integration Key")} -

-
    -
  1. - In PagerDuty, go to {t("Services")} - → select your service (or create one) -
  2. -
  3. Click the {t("Integrations")} tab
  4. -
  5. - Click {t("Add Integration")} - → select {t("Events API v2")} -
  6. -
  7. - Copy the {t("Integration Key")} and paste it above -
  8. -
-

- {t( - "When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts." - )} -

-
- -
-
- <.icon name="hero-arrow-path" class="h-4 w-4 text-gray-400" /> +
+ <.icon name="hero-link" class="h-4 w-4 text-gray-400" />

- {t("Two-Way Sync (Webhooks)")} + {t("Connection")}

-

- {t( - "When someone resolves or acknowledges an incident directly in PagerDuty," - )} - {t("TowerOps will automatically update the corresponding alert.")} +

+ {t("Your Sonar instance URL and API token from the Sonar admin panel.")}

-
- -
- - {ToweropsWeb.Endpoint.url()}/api/v1/webhooks/pagerduty/{@current_scope.organization.id} - +
+
+ + +
+
+ +
- <.input - name="integration[webhook_secret]" - type="password" - label={t("Webhook Signing Secret")} - value={ - if(@integrations[provider.id], - do: - get_in(@integrations[provider.id].credentials, ["webhook_secret"]) || - "", - else: "" - ) - } - placeholder={t("Optional — paste from PagerDuty webhook extension")} - /> -
-
    -
  1. - In PagerDuty, go to {t("Integrations")} - → Generic Webhooks (v3) -
  2. -
  3. - {t("Add a subscription with the Webhook URL above")} -
  4. -
  5. - Select events: incident.resolved - and incident.acknowledged -
  6. -
  7. - Copy the {t("Signing Secret")} and paste it above -
  8. -
-
- <% end %> - <%= if provider.id != "pagerduty" do %>
-
- <.icon name="hero-clock" class="h-4 w-4 text-gray-400" /> -

- {t("Sync Settings")} -

-
<.input field={@integration_form[:sync_interval_minutes]} @@ -1635,53 +1559,43 @@ label={t("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(@integrations["sonar"], + do: @integrations["sonar"].sync_interval_minutes, + else: 10 ) } />
- <% end %> - <%= 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-300" - {:error, _} -> "text-red-800 dark:text-red-300" + {:ok, _} -> "bg-green-50 dark:bg-green-900/20" + {:error, _} -> "bg-red-50 dark:bg-red-900/20" end ]}> - {elem(@test_result, 1)} -

-
- <% end %> +

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

+
+ <% end %> -
- - -
+
-
- + + <% else %> + <%= if provider.id == "splynx" do %> + <.form + for={@integration_form} + id="splynx-form" + phx-change="validate_integration" + phx-submit="save_integration" + > +
+
+
+ <.icon name="hero-link" class="h-4 w-4 text-gray-400" /> +

+ {t("Connection")} +

+
+

+ {t( + "Your Splynx instance URL, API key, and API secret. Find these in Splynx under Administration → API Keys." + )} +

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+ <.input + field={@integration_form[:sync_interval_minutes]} + type="number" + label={t("Sync interval (minutes)")} + min="5" + value={ + if(@integrations["splynx"], + do: @integrations["splynx"].sync_interval_minutes, + 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-300" + {:error, _} -> "text-red-800 dark:text-red-300" + end + ]}> + {elem(@test_result, 1)} +

+
+ <% end %> + +
+ + +
+
+ + <% else %> + <.form + for={@integration_form} + id={"#{provider.id}-form"} + phx-change="validate_integration" + phx-submit="save_integration" + > +
+
+
+ <.icon name="hero-key" class="h-4 w-4 text-gray-400" /> +

+ {t("Connection Settings")} +

+
+ +
+ + +
+
+ + <%= if provider.id == "pagerduty" do %> +
+

+ <.icon name="hero-information-circle" class="h-4 w-4" /> + {t("Where to find your Integration Key")} +

+
    +
  1. + In PagerDuty, go to {t("Services")} + → select your service (or create one) +
  2. +
  3. Click the {t("Integrations")} tab
  4. +
  5. + Click {t("Add Integration")} + → select {t("Events API v2")} +
  6. +
  7. + Copy the {t("Integration Key")} and paste it above +
  8. +
+

+ {t( + "When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts." + )} +

+
+ +
+
+ <.icon name="hero-arrow-path" class="h-4 w-4 text-gray-400" /> +

+ {t("Two-Way Sync (Webhooks)")} +

+
+

+ {t( + "When someone resolves or acknowledges an incident directly in PagerDuty," + )} + {t("TowerOps will automatically update the corresponding alert.")} +

+
+ +
+ + {ToweropsWeb.Endpoint.url()}/api/v1/webhooks/pagerduty/{@current_scope.organization.id} + +
+
+ <.input + name="integration[webhook_secret]" + type="password" + label={t("Webhook Signing Secret")} + value={ + if(@integrations[provider.id], + do: + get_in(@integrations[provider.id].credentials, [ + "webhook_secret" + ]) || + "", + else: "" + ) + } + placeholder={t("Optional — paste from PagerDuty webhook extension")} + /> +
+
    +
  1. + In PagerDuty, go to {t("Integrations")} + → Generic Webhooks (v3) +
  2. +
  3. + {t("Add a subscription with the Webhook URL above")} +
  4. +
  5. + Select events: incident.resolved + and incident.acknowledged +
  6. +
  7. + Copy the {t("Signing Secret")} and paste it above +
  8. +
+
+
+ <% end %> + + <%= if provider.id != "pagerduty" do %> +
+
+ <.icon name="hero-clock" class="h-4 w-4 text-gray-400" /> +

+ {t("Sync Settings")} +

+
+
+ <.input + field={@integration_form[:sync_interval_minutes]} + type="number" + label={t("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) + ) + } + /> +
+
+ <% end %> + + <%= 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-300" + {:error, _} -> "text-red-800 dark:text-red-300" + end + ]}> + {elem(@test_result, 1)} +

+
+ <% end %> + +
+ + +
+ + +
+
+
+ + <% end %> + <% end %> <% end %> <%!-- Gaiia Webhook Section --%>