feat: add NetBox integration with sync direction, entity selection, and filtering

- Three sync modes: NetBox→TowerOps (pull), TowerOps→NetBox (push), Bidirectional
- Configurable entity sync: devices, sites, IP addresses, interfaces
- Optional filters by device role, site, and tag (comma-separated)
- NetBox client with test connection, CRUD operations
- Radio card UI for sync direction, checkbox cards for entity selection
- Credentials stored encrypted (URL, API token, sync config)
This commit is contained in:
Graham McIntire 2026-02-14 14:09:00 -06:00
parent 880259874c
commit 5917190efe
4 changed files with 697 additions and 97 deletions

View file

@ -14,7 +14,7 @@ defmodule Towerops.Integrations.Integration do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@valid_providers ~w(preseem gaiia pagerduty)
@valid_providers ~w(preseem gaiia pagerduty netbox)
@valid_sync_statuses ~w(never success partial failed)
schema "integrations" do

View file

@ -0,0 +1,145 @@
defmodule Towerops.NetBox.Client do
@moduledoc """
HTTP client for the NetBox REST API.
Handles authentication, pagination, and common CRUD operations
against a user-provided NetBox instance.
"""
require Logger
@doc """
Test connectivity and authentication against a NetBox instance.
Returns `{:ok, info}` with the NetBox version or `{:error, reason}`.
"""
def test_connection(url, token) do
case get(url, "/api/status/", token) do
{:ok, %{"netbox-version" => version}} ->
{:ok, "Connected to NetBox #{version}"}
{:ok, %{"django-version" => _}} ->
{:ok, "Connected to NetBox"}
{:ok, _body} ->
{:ok, "Connected to NetBox"}
{:error, :unauthorized} ->
{:error, "Authentication failed — check your API token"}
{:error, :not_found} ->
{:error, "NetBox API not found at this URL — check the URL"}
{:error, reason} ->
{:error, "Connection failed: #{reason}"}
end
end
@doc """
List devices from NetBox with optional filters.
"""
def list_devices(url, token, params \\ %{}) do
get(url, "/api/dcim/devices/", token, params)
end
@doc """
List sites from NetBox with optional filters.
"""
def list_sites(url, token, params \\ %{}) do
get(url, "/api/dcim/sites/", token, params)
end
@doc """
List IP addresses from NetBox with optional filters.
"""
def list_ip_addresses(url, token, params \\ %{}) do
get(url, "/api/ipam/ip-addresses/", token, params)
end
@doc """
List interfaces from NetBox with optional filters.
"""
def list_interfaces(url, token, params \\ %{}) do
get(url, "/api/dcim/interfaces/", token, params)
end
@doc """
Create or update a device in NetBox.
"""
def upsert_device(url, token, attrs) do
post(url, "/api/dcim/devices/", token, attrs)
end
@doc """
Create or update a site in NetBox.
"""
def upsert_site(url, token, attrs) do
post(url, "/api/dcim/sites/", token, attrs)
end
# -- HTTP helpers --
defp get(base_url, path, token, params \\ %{}) do
url = normalize_url(base_url) <> path
case Req.get(url,
headers: auth_headers(token),
params: params,
connect_options: [timeout: 10_000],
receive_timeout: 15_000
) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body}
{:ok, %Req.Response{status: 401}} ->
{:error, :unauthorized}
{:ok, %Req.Response{status: 403}} ->
{:error, :unauthorized}
{:ok, %Req.Response{status: 404}} ->
{:error, :not_found}
{:ok, %Req.Response{status: status}} ->
{:error, "HTTP #{status}"}
{:error, %Req.TransportError{reason: reason}} ->
{:error, "#{reason}"}
{:error, reason} ->
{:error, inspect(reason)}
end
end
defp post(base_url, path, token, body) do
url = normalize_url(base_url) <> path
case Req.post(url,
headers: auth_headers(token),
json: body,
connect_options: [timeout: 10_000],
receive_timeout: 15_000
) do
{:ok, %Req.Response{status: status, body: body}} when status in [200, 201] ->
{:ok, body}
{:ok, %Req.Response{status: 401}} ->
{:error, :unauthorized}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, "HTTP #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, inspect(reason)}
end
end
defp auth_headers(token) do
[{"authorization", "Token #{token}"}, {"accept", "application/json"}]
end
defp normalize_url(url) do
url
|> String.trim()
|> String.trim_trailing("/")
end
end

View file

@ -33,6 +33,13 @@ defmodule ToweropsWeb.Org.SettingsLive do
description:
"Incident management and on-call alerting. Automatically triggers, acknowledges, and resolves PagerDuty incidents from TowerOps alerts.",
icon: "hero-bell-alert"
},
%{
id: "netbox",
name: "NetBox",
description:
"Infrastructure source of truth. Sync devices, sites, IP addresses, and interfaces between TowerOps and your NetBox instance.",
icon: "hero-server-stack"
}
]
@ -344,14 +351,33 @@ defmodule ToweropsWeb.Org.SettingsLive do
@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"})}
if provider == "netbox" do
integration = current_integration(socket)
url = get_credential(integration, "url")
token = get_credential(integration, "api_token")
cond do
url == "" or is_nil(url) ->
{:noreply, assign(socket, :test_result, {:error, "Please enter your NetBox URL 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)
{:noreply, assign(socket, :test_result, format_connection_result(result))}
end
else
result = test_provider_connection(provider, api_key)
{:noreply, assign(socket, :test_result, format_connection_result(result))}
api_key = extract_api_key(socket)
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
end
@ -435,6 +461,20 @@ defmodule ToweropsWeb.Org.SettingsLive do
end
defp normalize_params(params, existing_integration) do
provider =
case existing_integration do
%Integration{provider: p} -> p
_ -> Map.get(params, "provider", "")
end
if provider == "netbox" do
normalize_netbox_params(params, existing_integration)
else
normalize_standard_params(params, existing_integration)
end
end
defp normalize_standard_params(params, existing_integration) do
api_key = Map.get(params, "api_key", "")
sync_interval = Map.get(params, "sync_interval_minutes")
@ -456,6 +496,31 @@ defmodule ToweropsWeb.Org.SettingsLive do
end
end
defp normalize_netbox_params(params, _existing_integration) do
sync_interval = Map.get(params, "sync_interval_minutes")
credentials = %{
"url" => String.trim(Map.get(params, "url", "")),
"api_token" => Map.get(params, "api_token", ""),
"sync_direction" => Map.get(params, "sync_direction", "pull"),
"sync_devices" => Map.get(params, "sync_devices", "true") == "true",
"sync_sites" => Map.get(params, "sync_sites", "true") == "true",
"sync_ip_addresses" => Map.get(params, "sync_ip_addresses", "false") == "true",
"sync_interfaces" => Map.get(params, "sync_interfaces", "false") == "true",
"device_role_filter" => String.trim(Map.get(params, "device_role_filter", "")),
"site_filter" => String.trim(Map.get(params, "site_filter", "")),
"tag_filter" => String.trim(Map.get(params, "tag_filter", ""))
}
attrs = %{credentials: credentials}
if sync_interval && sync_interval != "" do
Map.put(attrs, :sync_interval_minutes, sync_interval)
else
attrs
end
end
defp webhook_url(organization_id) do
ToweropsWeb.Endpoint.url() <> "/api/v1/webhooks/gaiia/#{organization_id}"
end
@ -468,6 +533,11 @@ defmodule ToweropsWeb.Org.SettingsLive do
defp get_credential(_integration, _key), do: ""
defp current_integration(socket) do
provider = socket.assigns.configuring
Map.get(socket.assigns.integrations, provider)
end
defp extract_api_key(socket) do
changeset = socket.assigns.integration_form.source
data = Ecto.Changeset.apply_changes(changeset)

View file

@ -945,6 +945,17 @@
</li>
</ul>
<% end %>
<%= if provider.id == "netbox" do %>
<% direction = get_credential(integration, "sync_direction") %> Syncs every {integration.sync_interval_minutes} minutes.
<%= case direction do %>
<% "push" -> %>
TowerOps → NetBox (TowerOps is source of truth)
<% "both" -> %>
Bidirectional sync (merge from both systems)
<% _ -> %>
NetBox → TowerOps (NetBox is source of truth)
<% end %>
<% end %>
</p>
</div>
<% end %>
@ -989,107 +1000,367 @@
<%= if @configuring == provider.id do %>
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-white/10">
<.form
for={@integration_form}
id={"#{provider.id}-form"}
phx-change="validate_integration"
phx-submit="save_integration"
>
<div class="space-y-4">
<.input
field={@integration_form[:api_key]}
type="password"
label={
if(provider.id == "pagerduty",
do: "Integration Key (Routing Key)",
else: "API Key"
)
}
placeholder={
if(provider.id == "pagerduty",
do: "Enter your PagerDuty integration key",
else: "Enter your #{provider.name} API key"
)
}
autocomplete="off"
value={get_credential(@integrations[provider.id], "api_key")}
/>
<%= if provider.id == "pagerduty" do %>
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-4 -mt-1">
<h4 class="text-xs font-semibold text-blue-900 dark:text-blue-300 mb-2">
Where to find your Integration Key
<%= if provider.id == "netbox" do %>
<.form
for={@integration_form}
id="netbox-form"
phx-change="validate_integration"
phx-submit="save_integration"
>
<div class="space-y-6">
<%!-- Connection Section --%>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Connection
</h4>
<ol class="list-decimal list-inside space-y-1 text-xs text-blue-800 dark:text-blue-300">
<li>
In PagerDuty, go to <strong>Services</strong>
→ select your service (or create one)
</li>
<li>Click the <strong>Integrations</strong> tab</li>
<li>
Click <strong>Add Integration</strong>
→ select <strong>Events API v2</strong>
</li>
<li>Copy the <strong>Integration Key</strong> and paste it above</li>
</ol>
<p class="mt-2 text-xs text-blue-700 dark:text-blue-400">
When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts.
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
Your NetBox instance URL and API token. The token needs read permission at minimum.
Write permission is required if you want to push data to NetBox.
</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<.input
field={@integration_form[:url]}
type="url"
label="NetBox URL"
placeholder="https://netbox.example.com"
autocomplete="off"
value={get_credential(@integrations["netbox"], "url")}
/>
<.input
field={@integration_form[:api_token]}
type="password"
label="API Token"
placeholder="Enter your NetBox API token"
autocomplete="off"
value={get_credential(@integrations["netbox"], "api_token")}
/>
</div>
</div>
<% end %>
<%= if provider.id != "pagerduty" do %>
<.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)
)
}
/>
<% end %>
<%!-- Sync Direction Section --%>
<div class="border-t border-gray-200 pt-6 dark:border-white/10">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Sync Direction
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
Choose how data flows between TowerOps and NetBox. You can change this later.
</p>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<label class={[
"relative flex cursor-pointer rounded-lg border p-4 shadow-xs focus:outline-none",
if(
get_credential(@integrations["netbox"], "sync_direction") in [
"pull",
"",
nil
],
do:
"border-indigo-600 ring-2 ring-indigo-600 bg-indigo-50 dark:bg-indigo-900/20",
else:
"border-gray-300 dark:border-white/10 hover:border-gray-400 dark:hover:border-white/20"
)
]}>
<input
type="radio"
name="integration[sync_direction]"
value="pull"
checked={
get_credential(@integrations["netbox"], "sync_direction") in [
"pull",
"",
nil
]
}
class="sr-only"
/>
<div class="flex flex-col">
<span class="flex items-center gap-1.5 text-sm font-semibold text-gray-900 dark:text-white">
<.icon name="hero-arrow-down-tray" class="h-4 w-4 text-indigo-600" />
NetBox → TowerOps
</span>
<span class="mt-1 text-xs text-gray-500 dark:text-gray-400">
NetBox is the source of truth. Import devices, sites, and IPs from NetBox into TowerOps.
</span>
</div>
</label>
<label class={[
"relative flex cursor-pointer rounded-lg border p-4 shadow-xs focus:outline-none",
if(
get_credential(@integrations["netbox"], "sync_direction") == "push",
do:
"border-indigo-600 ring-2 ring-indigo-600 bg-indigo-50 dark:bg-indigo-900/20",
else:
"border-gray-300 dark:border-white/10 hover:border-gray-400 dark:hover:border-white/20"
)
]}>
<input
type="radio"
name="integration[sync_direction]"
value="push"
class="sr-only"
checked={
get_credential(@integrations["netbox"], "sync_direction") == "push"
}
/>
<div class="flex flex-col">
<span class="flex items-center gap-1.5 text-sm font-semibold text-gray-900 dark:text-white">
<.icon name="hero-arrow-up-tray" class="h-4 w-4 text-orange-600" />
TowerOps → NetBox
</span>
<span class="mt-1 text-xs text-gray-500 dark:text-gray-400">
TowerOps is the source of truth. Push discovered devices and monitoring data to NetBox.
</span>
</div>
</label>
<label class={[
"relative flex cursor-pointer rounded-lg border p-4 shadow-xs focus:outline-none",
if(
get_credential(@integrations["netbox"], "sync_direction") == "both",
do:
"border-indigo-600 ring-2 ring-indigo-600 bg-indigo-50 dark:bg-indigo-900/20",
else:
"border-gray-300 dark:border-white/10 hover:border-gray-400 dark:hover:border-white/20"
)
]}>
<input
type="radio"
name="integration[sync_direction]"
value="both"
class="sr-only"
checked={
get_credential(@integrations["netbox"], "sync_direction") == "both"
}
/>
<div class="flex flex-col">
<span class="flex items-center gap-1.5 text-sm font-semibold text-gray-900 dark:text-white">
<.icon
name="hero-arrows-right-left"
class="h-4 w-4 text-green-600"
/> Bidirectional
</span>
<span class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Merge data from both systems. Conflicts resolved by most-recently-updated. Requires write token.
</span>
</div>
</label>
</div>
</div>
<%= if @test_result do %>
<div class={[
"rounded-md 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",
<%!-- What to Sync Section --%>
<div class="border-t border-gray-200 pt-6 dark:border-white/10">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">
What to Sync
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
Choose which NetBox objects participate in sync. Devices and sites are recommended at minimum.
</p>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label class="flex items-start gap-3 rounded-lg border border-gray-200 p-3 dark:border-white/10">
<input
type="hidden"
name="integration[sync_devices]"
value="false"
/>
<input
type="checkbox"
name="integration[sync_devices]"
value="true"
checked={
get_credential(@integrations["netbox"], "sync_devices") != "false"
}
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
/>
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">
Devices
</span>
<p class="text-xs text-gray-500 dark:text-gray-400">
Name, IP, role, platform, status, serial number
</p>
</div>
</label>
<label class="flex items-start gap-3 rounded-lg border border-gray-200 p-3 dark:border-white/10">
<input
type="hidden"
name="integration[sync_sites]"
value="false"
/>
<input
type="checkbox"
name="integration[sync_sites]"
value="true"
checked={
get_credential(@integrations["netbox"], "sync_sites") != "false"
}
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
/>
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">
Sites
</span>
<p class="text-xs text-gray-500 dark:text-gray-400">
Site names, locations, addresses, and coordinates
</p>
</div>
</label>
<label class="flex items-start gap-3 rounded-lg border border-gray-200 p-3 dark:border-white/10">
<input
type="hidden"
name="integration[sync_ip_addresses]"
value="false"
/>
<input
type="checkbox"
name="integration[sync_ip_addresses]"
value="true"
checked={
get_credential(@integrations["netbox"], "sync_ip_addresses") == "true"
}
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
/>
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">
IP Addresses
</span>
<p class="text-xs text-gray-500 dark:text-gray-400">
IPAM data — assigned IPs, prefixes, VRFs
</p>
</div>
</label>
<label class="flex items-start gap-3 rounded-lg border border-gray-200 p-3 dark:border-white/10">
<input
type="hidden"
name="integration[sync_interfaces]"
value="false"
/>
<input
type="checkbox"
name="integration[sync_interfaces]"
value="true"
checked={
get_credential(@integrations["netbox"], "sync_interfaces") == "true"
}
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
/>
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">
Interfaces
</span>
<p class="text-xs text-gray-500 dark:text-gray-400">
Physical and virtual interfaces, LAGs, connections
</p>
</div>
</label>
</div>
</div>
<%!-- Filtering Section --%>
<div class="border-t border-gray-200 pt-6 dark:border-white/10">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Filters
<span class="text-xs font-normal text-gray-400 dark:text-gray-500">
(optional)
</span>
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
Narrow the sync scope. Leave blank to sync everything. Comma-separated for multiple values.
</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Device Role
</label>
<input
type="text"
name="integration[device_role_filter]"
value={get_credential(@integrations["netbox"], "device_role_filter")}
placeholder="e.g. access-point, router"
class="block w-full rounded-md border-gray-300 py-1.5 text-sm shadow-xs dark:border-white/10 dark:bg-white/5 dark:text-white"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Site
</label>
<input
type="text"
name="integration[site_filter]"
value={get_credential(@integrations["netbox"], "site_filter")}
placeholder="e.g. tower-north, main-pop"
class="block w-full rounded-md border-gray-300 py-1.5 text-sm shadow-xs dark:border-white/10 dark:bg-white/5 dark:text-white"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Tag
</label>
<input
type="text"
name="integration[tag_filter]"
value={get_credential(@integrations["netbox"], "tag_filter")}
placeholder="e.g. monitored, production"
class="block w-full rounded-md border-gray-300 py-1.5 text-sm shadow-xs dark:border-white/10 dark:bg-white/5 dark:text-white"
/>
</div>
</div>
</div>
<%!-- Sync Interval --%>
<div class="border-t border-gray-200 pt-6 dark:border-white/10">
<div class="max-w-xs">
<.input
field={@integration_form[:sync_interval_minutes]}
type="number"
label="Sync interval (minutes)"
min="5"
value={
if(@integrations["netbox"],
do: @integrations["netbox"].sync_interval_minutes,
else: 30
)
}
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
How often TowerOps checks NetBox for changes. 30 minutes is recommended for most setups.
</p>
</div>
</div>
<%!-- Test & Save --%>
<%= if @test_result do %>
<div class={[
"rounded-md p-4",
case @test_result do
{:ok, _} -> "text-green-800 dark:text-green-200"
{:error, _} -> "text-red-800 dark:text-red-200"
{: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",
case @test_result do
{:ok, _} -> "text-green-700 dark:text-green-400"
{:error, _} -> "text-red-700 dark:text-red-400"
end
]}>
<%= case @test_result do %>
<% {:ok, msg} -> %>
<.icon name="hero-check-circle" class="h-4 w-4 inline" />
{msg}
<% {:error, msg} -> %>
<.icon name="hero-x-circle" class="h-4 w-4 inline" />
{msg}
<% end %>
</p>
</div>
<% end %>
<div class="flex items-center justify-between pt-4">
<button
type="button"
id="test-connection"
phx-click="test_connection"
class="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"
>
Test Connection
</button>
<div class="flex items-center gap-3">
<div class="flex items-center justify-end gap-x-4 border-t border-gray-200 pt-4 dark:border-white/10">
<button
type="button"
phx-click="close_config"
class="text-sm font-semibold text-gray-900 dark:text-white"
phx-click="test_connection"
class="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"
>
Cancel
Test Connection
</button>
<button
type="submit"
@ -1100,8 +1371,122 @@
</button>
</div>
</div>
</div>
</.form>
</.form>
<% else %>
<.form
for={@integration_form}
id={"#{provider.id}-form"}
phx-change="validate_integration"
phx-submit="save_integration"
>
<div class="space-y-4">
<.input
field={@integration_form[:api_key]}
type="password"
label={
if(provider.id == "pagerduty",
do: "Integration Key (Routing Key)",
else: "API Key"
)
}
placeholder={
if(provider.id == "pagerduty",
do: "Enter your PagerDuty integration key",
else: "Enter your #{provider.name} API key"
)
}
autocomplete="off"
value={get_credential(@integrations[provider.id], "api_key")}
/>
<%= if provider.id == "pagerduty" do %>
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-4 -mt-1">
<h4 class="text-xs font-semibold text-blue-900 dark:text-blue-300 mb-2">
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">
<li>
In PagerDuty, go to <strong>Services</strong>
→ select your service (or create one)
</li>
<li>Click the <strong>Integrations</strong> tab</li>
<li>
Click <strong>Add Integration</strong>
→ select <strong>Events API v2</strong>
</li>
<li>Copy the <strong>Integration Key</strong> and paste it above</li>
</ol>
<p class="mt-2 text-xs text-blue-700 dark:text-blue-400">
When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts.
</p>
</div>
<% end %>
<%= if provider.id != "pagerduty" do %>
<.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)
)
}
/>
<% end %>
<%= if @test_result do %>
<div class={[
"rounded-md 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",
case @test_result do
{:ok, _} -> "text-green-800 dark:text-green-200"
{:error, _} -> "text-red-800 dark:text-red-200"
end
]}>
{elem(@test_result, 1)}
</p>
</div>
<% end %>
<div class="flex items-center justify-between pt-4">
<button
type="button"
id="test-connection"
phx-click="test_connection"
class="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"
>
Test Connection
</button>
<div class="flex items-center gap-3">
<button
type="button"
phx-click="close_config"
class="text-sm font-semibold text-gray-900 dark:text-white"
>
Cancel
</button>
<button
type="submit"
phx-disable-with="Saving..."
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
Save
</button>
</div>
</div>
</div>
</.form>
<% end %>
</div>
<% end %>