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" - > -
- {t( - "When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts." - )} -
-- {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}
-
+ +
"text-green-800 dark:text-green-300" + {:error, _} -> "text-red-800 dark:text-red-300" + end + ]}> + {elem(@test_result, 1)} +
++ {t( + "Your Splynx instance URL, API key, and API secret. Find these in Splynx under Administration → API Keys." + )} +
+"text-green-800 dark:text-green-300" + {:error, _} -> "text-red-800 dark:text-red-300" + end + ]}> + {elem(@test_result, 1)} +
++ {t( + "When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts." + )} +
++ {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}
+
+ "text-green-800 dark:text-green-300" + {:error, _} -> "text-red-800 dark:text-red-300" + end + ]}> + {elem(@test_result, 1)} +
+