feat: add Sonar, Splynx, and VISP billing integrations

This commit is contained in:
Graham McIntire 2026-02-14 20:17:21 -06:00
parent d80ece27c3
commit 66bf61887f
3 changed files with 544 additions and 181 deletions

View file

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

View file

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

View file

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