feat: add Sonar, Splynx, and VISP billing integrations
This commit is contained in:
parent
d80ece27c3
commit
66bf61887f
3 changed files with 544 additions and 181 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} ->
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</div>
|
||||
</.form>
|
||||
<% else %>
|
||||
<.form
|
||||
for={@integration_form}
|
||||
id={"#{provider.id}-form"}
|
||||
phx-change="validate_integration"
|
||||
phx-submit="save_integration"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<.icon name="hero-key" class="h-4 w-4 text-gray-400" />
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Connection Settings")}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<%= if provider.id == "sonar" do %>
|
||||
<.form
|
||||
for={@integration_form}
|
||||
id="sonar-form"
|
||||
phx-change="validate_integration"
|
||||
phx-submit="save_integration"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{if(provider.id == "pagerduty",
|
||||
do: "Integration Key (Routing Key)",
|
||||
else: "API Key"
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="integration[api_key]"
|
||||
value={get_credential(@integrations[provider.id], "api_key")}
|
||||
placeholder={
|
||||
if(provider.id == "pagerduty",
|
||||
do: "Enter your PagerDuty integration key",
|
||||
else: "Enter your #{provider.name} API key"
|
||||
)
|
||||
}
|
||||
autocomplete="off"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if provider.id == "pagerduty" do %>
|
||||
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 p-4">
|
||||
<h4 class="flex items-center gap-1.5 text-xs font-semibold text-blue-900 dark:text-blue-300 mb-2">
|
||||
<.icon name="hero-information-circle" class="h-4 w-4" />
|
||||
{t("Where to find your Integration Key")}
|
||||
</h4>
|
||||
<ol class="list-decimal list-inside space-y-1 text-xs text-blue-800 dark:text-blue-300 ml-5">
|
||||
<li>
|
||||
In PagerDuty, go to <strong>{t("Services")}</strong>
|
||||
→ select your service (or create one)
|
||||
</li>
|
||||
<li>Click the <strong>{t("Integrations")}</strong> tab</li>
|
||||
<li>
|
||||
Click <strong>{t("Add Integration")}</strong>
|
||||
→ select <strong>{t("Events API v2")}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Copy the <strong>{t("Integration Key")}</strong> and paste it above
|
||||
</li>
|
||||
</ol>
|
||||
<p class="mt-2.5 text-xs text-blue-700 dark:text-blue-400 ml-5">
|
||||
{t(
|
||||
"When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-5 mt-5 dark:border-white/10">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<.icon name="hero-arrow-path" class="h-4 w-4 text-gray-400" />
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<.icon name="hero-link" class="h-4 w-4 text-gray-400" />
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Two-Way Sync (Webhooks)")}
|
||||
{t("Connection")}
|
||||
</h4>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{t(
|
||||
"When someone resolves or acknowledges an incident directly in PagerDuty,"
|
||||
)}
|
||||
{t("TowerOps will automatically update the corresponding alert.")}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4 ml-6">
|
||||
{t("Your Sonar instance URL and API token from the Sonar admin panel.")}
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("Webhook URL")}
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-xs bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 px-2.5 py-1.5 rounded-md break-all">
|
||||
{ToweropsWeb.Endpoint.url()}/api/v1/webhooks/pagerduty/{@current_scope.organization.id}
|
||||
</code>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t("Instance URL")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="integration[instance_url]"
|
||||
value={get_credential(@integrations["sonar"], "instance_url")}
|
||||
placeholder="https://myisp.sonar.software"
|
||||
autocomplete="off"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t("API Token")}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="integration[api_token]"
|
||||
value={get_credential(@integrations["sonar"], "api_token")}
|
||||
placeholder={t("Enter your Sonar API token")}
|
||||
autocomplete="off"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<.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")}
|
||||
/>
|
||||
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 p-3 mt-3">
|
||||
<ol class="list-decimal list-inside space-y-1 text-xs text-blue-800 dark:text-blue-300 ml-1">
|
||||
<li>
|
||||
In PagerDuty, go to <strong>{t("Integrations")}</strong>
|
||||
→ <strong>Generic Webhooks (v3)</strong>
|
||||
</li>
|
||||
<li>
|
||||
{t("Add a subscription with the Webhook URL above")}
|
||||
</li>
|
||||
<li>
|
||||
Select events: <strong>incident.resolved</strong>
|
||||
and <strong>incident.acknowledged</strong>
|
||||
</li>
|
||||
<li>
|
||||
Copy the <strong>{t("Signing Secret")}</strong> and paste it above
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if provider.id != "pagerduty" do %>
|
||||
<div class="border-t border-gray-200 pt-5 dark:border-white/10">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<.icon name="hero-clock" class="h-4 w-4 text-gray-400" />
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Sync Settings")}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="max-w-xs">
|
||||
<.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
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @test_result do %>
|
||||
<div class={[
|
||||
"flex items-start gap-3 rounded-lg p-4",
|
||||
case @test_result do
|
||||
{:ok, _} -> "bg-green-50 dark:bg-green-900/20"
|
||||
{:error, _} -> "bg-red-50 dark:bg-red-900/20"
|
||||
end
|
||||
]}>
|
||||
<p class={[
|
||||
"text-sm font-medium",
|
||||
<%= if @test_result do %>
|
||||
<div class={[
|
||||
"flex items-start gap-3 rounded-lg p-4",
|
||||
case @test_result do
|
||||
{:ok, _} -> "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)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<p class={[
|
||||
"text-sm font-medium",
|
||||
case @test_result do
|
||||
{:ok, _} -> "text-green-800 dark:text-green-300"
|
||||
{:error, _} -> "text-red-800 dark:text-red-300"
|
||||
end
|
||||
]}>
|
||||
{elem(@test_result, 1)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-gray-200 pt-5 dark:border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
id="test-connection"
|
||||
phx-click="test_connection"
|
||||
class="rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 transition-colors hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
<.icon name="hero-signal" class="h-4 w-4 inline -mt-0.5" /> Test Connection
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-end gap-x-3 border-t border-gray-200 pt-5 dark:border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="close_config"
|
||||
class="rounded-lg px-4 py-2.5 text-sm font-semibold text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
phx-click="test_connection"
|
||||
class="rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 transition-colors hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
{t("Cancel")}
|
||||
<.icon name="hero-signal" class="h-4 w-4 inline -mt-0.5" />
|
||||
Test Connection
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
|
|
@ -1692,8 +1606,332 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</.form>
|
||||
<% else %>
|
||||
<%= if provider.id == "splynx" do %>
|
||||
<.form
|
||||
for={@integration_form}
|
||||
id="splynx-form"
|
||||
phx-change="validate_integration"
|
||||
phx-submit="save_integration"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<.icon name="hero-link" class="h-4 w-4 text-gray-400" />
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Connection")}
|
||||
</h4>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4 ml-6">
|
||||
{t(
|
||||
"Your Splynx instance URL, API key, and API secret. Find these in Splynx under Administration → API Keys."
|
||||
)}
|
||||
</p>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t("Instance URL")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="integration[instance_url]"
|
||||
value={get_credential(@integrations["splynx"], "instance_url")}
|
||||
placeholder="https://myisp.splynx.app"
|
||||
autocomplete="off"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t("API Key")}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="integration[api_key]"
|
||||
value={get_credential(@integrations["splynx"], "api_key")}
|
||||
placeholder={t("Enter your Splynx API key")}
|
||||
autocomplete="off"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{t("API Secret")}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="integration[api_secret]"
|
||||
value={get_credential(@integrations["splynx"], "api_secret")}
|
||||
placeholder={t("Enter your Splynx API secret")}
|
||||
autocomplete="off"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-5 dark:border-white/10">
|
||||
<div class="max-w-xs">
|
||||
<.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
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @test_result do %>
|
||||
<div class={[
|
||||
"flex items-start gap-3 rounded-lg p-4",
|
||||
case @test_result do
|
||||
{:ok, _} -> "bg-green-50 dark:bg-green-900/20"
|
||||
{:error, _} -> "bg-red-50 dark:bg-red-900/20"
|
||||
end
|
||||
]}>
|
||||
<p class={[
|
||||
"text-sm font-medium",
|
||||
case @test_result do
|
||||
{:ok, _} -> "text-green-800 dark:text-green-300"
|
||||
{:error, _} -> "text-red-800 dark:text-red-300"
|
||||
end
|
||||
]}>
|
||||
{elem(@test_result, 1)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center justify-end gap-x-3 border-t border-gray-200 pt-5 dark:border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="test_connection"
|
||||
class="rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 transition-colors hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
<.icon name="hero-signal" class="h-4 w-4 inline -mt-0.5" />
|
||||
Test Connection
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
phx-disable-with={t("Saving...")}
|
||||
class="rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-xs transition-colors hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
>
|
||||
{t("Save Integration")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
<% else %>
|
||||
<.form
|
||||
for={@integration_form}
|
||||
id={"#{provider.id}-form"}
|
||||
phx-change="validate_integration"
|
||||
phx-submit="save_integration"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<.icon name="hero-key" class="h-4 w-4 text-gray-400" />
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Connection Settings")}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{if(provider.id == "pagerduty",
|
||||
do: "Integration Key (Routing Key)",
|
||||
else: "API Key"
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="integration[api_key]"
|
||||
value={get_credential(@integrations[provider.id], "api_key")}
|
||||
placeholder={
|
||||
if(provider.id == "pagerduty",
|
||||
do: "Enter your PagerDuty integration key",
|
||||
else: "Enter your #{provider.name} API key"
|
||||
)
|
||||
}
|
||||
autocomplete="off"
|
||||
class="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if provider.id == "pagerduty" do %>
|
||||
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 p-4">
|
||||
<h4 class="flex items-center gap-1.5 text-xs font-semibold text-blue-900 dark:text-blue-300 mb-2">
|
||||
<.icon name="hero-information-circle" class="h-4 w-4" />
|
||||
{t("Where to find your Integration Key")}
|
||||
</h4>
|
||||
<ol class="list-decimal list-inside space-y-1 text-xs text-blue-800 dark:text-blue-300 ml-5">
|
||||
<li>
|
||||
In PagerDuty, go to <strong>{t("Services")}</strong>
|
||||
→ select your service (or create one)
|
||||
</li>
|
||||
<li>Click the <strong>{t("Integrations")}</strong> tab</li>
|
||||
<li>
|
||||
Click <strong>{t("Add Integration")}</strong>
|
||||
→ select <strong>{t("Events API v2")}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Copy the <strong>{t("Integration Key")}</strong> and paste it above
|
||||
</li>
|
||||
</ol>
|
||||
<p class="mt-2.5 text-xs text-blue-700 dark:text-blue-400 ml-5">
|
||||
{t(
|
||||
"When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-5 mt-5 dark:border-white/10">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<.icon name="hero-arrow-path" class="h-4 w-4 text-gray-400" />
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Two-Way Sync (Webhooks)")}
|
||||
</h4>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{t(
|
||||
"When someone resolves or acknowledges an incident directly in PagerDuty,"
|
||||
)}
|
||||
{t("TowerOps will automatically update the corresponding alert.")}
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("Webhook URL")}
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-xs bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 px-2.5 py-1.5 rounded-md break-all">
|
||||
{ToweropsWeb.Endpoint.url()}/api/v1/webhooks/pagerduty/{@current_scope.organization.id}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<.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")}
|
||||
/>
|
||||
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50 p-3 mt-3">
|
||||
<ol class="list-decimal list-inside space-y-1 text-xs text-blue-800 dark:text-blue-300 ml-1">
|
||||
<li>
|
||||
In PagerDuty, go to <strong>{t("Integrations")}</strong>
|
||||
→ <strong>Generic Webhooks (v3)</strong>
|
||||
</li>
|
||||
<li>
|
||||
{t("Add a subscription with the Webhook URL above")}
|
||||
</li>
|
||||
<li>
|
||||
Select events: <strong>incident.resolved</strong>
|
||||
and <strong>incident.acknowledged</strong>
|
||||
</li>
|
||||
<li>
|
||||
Copy the <strong>{t("Signing Secret")}</strong> and paste it above
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if provider.id != "pagerduty" do %>
|
||||
<div class="border-t border-gray-200 pt-5 dark:border-white/10">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<.icon name="hero-clock" class="h-4 w-4 text-gray-400" />
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Sync Settings")}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="max-w-xs">
|
||||
<.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)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @test_result do %>
|
||||
<div class={[
|
||||
"flex items-start gap-3 rounded-lg p-4",
|
||||
case @test_result do
|
||||
{:ok, _} -> "bg-green-50 dark:bg-green-900/20"
|
||||
{:error, _} -> "bg-red-50 dark:bg-red-900/20"
|
||||
end
|
||||
]}>
|
||||
<p class={[
|
||||
"text-sm font-medium",
|
||||
case @test_result do
|
||||
{:ok, _} -> "text-green-800 dark:text-green-300"
|
||||
{:error, _} -> "text-red-800 dark:text-red-300"
|
||||
end
|
||||
]}>
|
||||
{elem(@test_result, 1)}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-gray-200 pt-5 dark:border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
id="test-connection"
|
||||
phx-click="test_connection"
|
||||
class="rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 transition-colors hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
<.icon name="hero-signal" class="h-4 w-4 inline -mt-0.5" />
|
||||
Test Connection
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="close_config"
|
||||
class="rounded-lg px-4 py-2.5 text-sm font-semibold text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
phx-disable-with={t("Saving...")}
|
||||
class="rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-xs transition-colors hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
>
|
||||
{t("Save Integration")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%!-- Gaiia Webhook Section --%>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue