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:
parent
880259874c
commit
5917190efe
4 changed files with 697 additions and 97 deletions
|
|
@ -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
|
||||
|
|
|
|||
145
lib/towerops/netbox/client.ex
Normal file
145
lib/towerops/netbox/client.ex
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue