Add alert routing setting (builtin/pagerduty/both)

- New 'alert_routing' field on organizations (default: builtin)
- AlertNotificationWorker respects the setting:
  - builtin: only built-in escalation policies
  - pagerduty: only PagerDuty events
  - both: fire both (useful during migration)
- Radio button UI in Settings → General with visual cards
- Warning shown when PagerDuty selected but not configured
- Migration, schema validation, 6 tests
This commit is contained in:
Graham McIntire 2026-03-13 15:26:25 -05:00
parent 8e25034e69
commit 37314bd154
6 changed files with 242 additions and 29 deletions

View file

@ -27,6 +27,9 @@ defmodule Towerops.Organizations.Organization do
field :subscription_plan, :string, default: "free"
field :use_sites, :boolean, default: false
# Alert routing: "builtin" (schedules/escalation), "pagerduty", or "both"
field :alert_routing, :string, default: "builtin"
# device)
field :snmp_version, :string, default: "2c"
field :snmp_community, :string
@ -151,11 +154,15 @@ defmodule Towerops.Organizations.Organization do
:payment_method_status,
:last_billing_sync_at,
:last_synced_device_count,
:default_escalation_policy_id
:default_escalation_policy_id,
:alert_routing
])
|> validate_required([:name])
|> validate_length(:name, min: 2, max: 100)
|> validate_inclusion(:subscription_plan, ["free", "paid"])
|> validate_inclusion(:alert_routing, ["builtin", "pagerduty", "both"],
message: "must be builtin, pagerduty, or both"
)
|> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3")
|> validate_number(:snmp_port, greater_than: 0, less_than: 65_536)
|> validate_snmpv3_fields()

View file

@ -21,9 +21,27 @@ defmodule Towerops.Workers.AlertNotificationWorker do
def perform(%Oban.Job{args: %{"action" => "trigger", "alert_id" => alert_id}}) do
with {:ok, alert} <- fetch_alert(alert_id),
{:ok, device} <- fetch_device(alert.device_id) do
pagerduty_result = Notifier.notify_trigger(alert, device)
maybe_start_escalation(alert, device)
pagerduty_result
routing = alert_routing(device)
pd_result =
if routing in ["pagerduty", "both"] do
case Notifier.notify_trigger(alert, device) do
{:error, :not_configured} ->
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping")
:ok
result ->
result
end
else
:ok
end
if routing in ["builtin", "both"] do
maybe_start_escalation(alert, device)
end
pd_result
else
{:error, :alert_not_found} ->
Logger.warning("Alert #{alert_id} not found for notification")
@ -33,17 +51,6 @@ defmodule Towerops.Workers.AlertNotificationWorker do
Logger.warning("Device not found for alert #{alert_id}")
:ok
{:error, :not_configured} ->
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping notification")
# Still try built-in escalation even if PagerDuty isn't configured
with {:ok, alert} <- fetch_alert(alert_id),
{:ok, device} <- fetch_device(alert.device_id) do
maybe_start_escalation(alert, device)
end
:ok
{:error, reason} ->
Logger.error("Failed to send trigger notification for alert #{alert_id}: #{inspect(reason)}")
{:error, reason}
@ -53,15 +60,21 @@ defmodule Towerops.Workers.AlertNotificationWorker do
def perform(%Oban.Job{args: %{"action" => "acknowledge", "alert_id" => alert_id}}) do
case fetch_alert(alert_id) do
{:ok, alert} ->
maybe_acknowledge_incident(alert)
routing = alert_routing_for_alert(alert)
case Notifier.notify_acknowledge(alert) do
{:error, :not_configured} ->
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping notification")
:ok
if routing in ["builtin", "both"], do: maybe_acknowledge_incident(alert)
result ->
result
if routing in ["pagerduty", "both"] do
case Notifier.notify_acknowledge(alert) do
{:error, :not_configured} ->
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping")
:ok
result ->
result
end
else
:ok
end
{:error, :alert_not_found} ->
@ -77,15 +90,21 @@ defmodule Towerops.Workers.AlertNotificationWorker do
def perform(%Oban.Job{args: %{"action" => "resolve", "alert_id" => alert_id}}) do
case fetch_alert(alert_id) do
{:ok, alert} ->
maybe_resolve_incident(alert)
routing = alert_routing_for_alert(alert)
case Notifier.notify_resolve(alert) do
{:error, :not_configured} ->
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping notification")
:ok
if routing in ["builtin", "both"], do: maybe_resolve_incident(alert)
result ->
result
if routing in ["pagerduty", "both"] do
case Notifier.notify_resolve(alert) do
{:error, :not_configured} ->
Logger.debug("PagerDuty not configured for alert #{alert_id}, skipping")
:ok
result ->
result
end
else
:ok
end
{:error, :alert_not_found} ->
@ -157,6 +176,18 @@ defmodule Towerops.Workers.AlertNotificationWorker do
end
end
# Get alert routing from the device's organization (preloaded)
defp alert_routing(%{organization: %{alert_routing: routing}}) when is_binary(routing), do: routing
defp alert_routing(_device), do: "builtin"
# Get alert routing for acknowledge/resolve (need to look up from alert's device)
defp alert_routing_for_alert(alert) do
case fetch_device(alert.device_id) do
{:ok, device} -> alert_routing(device)
_ -> "builtin"
end
end
defp fetch_alert(alert_id) do
case Alerts.get_alert(alert_id) do
nil -> {:error, :alert_not_found}

View file

@ -197,6 +197,11 @@ defmodule ToweropsWeb.Org.SettingsLive do
{:noreply, socket}
end
defp maybe_load_integrations(socket, "general") do
integrations = load_integrations(socket.assigns.organization.id)
assign(socket, :integrations, integrations)
end
defp maybe_load_integrations(socket, "members") do
org_id = socket.assigns.organization.id

View file

@ -227,6 +227,120 @@
<% end %>
</div>
</div>
<!-- Alert Routing Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8 border-t border-gray-200 dark:border-white/5">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
{t("Alert Routing")}
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{t("Choose how alerts are routed for on-call notifications.")}
</p>
</div>
<div class="md:col-span-2">
<fieldset>
<legend class="sr-only">{t("Alert routing method")}</legend>
<div class="space-y-3">
<% routing_value = to_string(@form[:alert_routing].value || "builtin") %>
<!-- Built-in option -->
<label class={[
"relative flex cursor-pointer rounded-lg border p-4 transition-colors",
if(routing_value == "builtin",
do: "border-blue-500 bg-blue-50/50 ring-1 ring-blue-500 dark:bg-blue-900/10 dark:border-blue-400",
else: "border-gray-200 hover:border-gray-300 dark:border-white/10 dark:hover:border-white/20"
)
]}>
<input
type="radio"
name="organization[alert_routing]"
value="builtin"
checked={routing_value == "builtin"}
class="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600 dark:border-gray-600 dark:bg-gray-800"
/>
<div class="ml-3">
<span class="block text-sm font-semibold text-gray-900 dark:text-white">
<.icon name="hero-bell-alert" class="h-4 w-4 inline -mt-0.5" />
{t("TowerOps Built-in")}
</span>
<span class="block text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{t("Use TowerOps schedules and escalation policies to notify on-call staff.")}
</span>
</div>
</label>
<!-- PagerDuty option -->
<label class={[
"relative flex cursor-pointer rounded-lg border p-4 transition-colors",
if(routing_value == "pagerduty",
do: "border-blue-500 bg-blue-50/50 ring-1 ring-blue-500 dark:bg-blue-900/10 dark:border-blue-400",
else: "border-gray-200 hover:border-gray-300 dark:border-white/10 dark:hover:border-white/20"
)
]}>
<input
type="radio"
name="organization[alert_routing]"
value="pagerduty"
checked={routing_value == "pagerduty"}
class="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600 dark:border-gray-600 dark:bg-gray-800"
/>
<div class="ml-3">
<span class="block text-sm font-semibold text-gray-900 dark:text-white">
<.icon name="hero-arrow-top-right-on-square" class="h-4 w-4 inline -mt-0.5" />
{t("PagerDuty")}
</span>
<span class="block text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{t("Route alerts to PagerDuty for incident management and on-call notifications.")}
</span>
<%= if routing_value == "pagerduty" && !@integrations["pagerduty"] do %>
<div class="mt-2 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-2.5">
<div class="flex items-center gap-2">
<.icon name="hero-exclamation-triangle" class="h-4 w-4 text-amber-500 shrink-0" />
<span class="text-xs text-amber-800 dark:text-amber-200">
{t("PagerDuty integration not configured yet.")}
<.link
navigate={~p"/orgs/#{@organization.slug}/settings?tab=integrations"}
class="font-semibold underline"
>
{t("Set it up →")}
</.link>
</span>
</div>
</div>
<% end %>
</div>
</label>
<!-- Both option -->
<label class={[
"relative flex cursor-pointer rounded-lg border p-4 transition-colors",
if(routing_value == "both",
do: "border-blue-500 bg-blue-50/50 ring-1 ring-blue-500 dark:bg-blue-900/10 dark:border-blue-400",
else: "border-gray-200 hover:border-gray-300 dark:border-white/10 dark:hover:border-white/20"
)
]}>
<input
type="radio"
name="organization[alert_routing]"
value="both"
checked={routing_value == "both"}
class="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-600 dark:border-gray-600 dark:bg-gray-800"
/>
<div class="ml-3">
<span class="block text-sm font-semibold text-gray-900 dark:text-white">
<.icon name="hero-arrows-right-left" class="h-4 w-4 inline -mt-0.5" />
{t("Both")}
</span>
<span class="block text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{t("Send alerts to both PagerDuty and built-in escalation. Useful during migration.")}
</span>
</div>
</label>
</div>
</fieldset>
</div>
</div>
<% end %>
<%= if @active_tab == "snmp" do %>

View file

@ -0,0 +1,9 @@
defmodule Towerops.Repo.Migrations.AddAlertRoutingToOrganizations do
use Ecto.Migration
def change do
alter table(:organizations) do
add :alert_routing, :string, default: "builtin", null: false
end
end
end

View file

@ -0,0 +1,47 @@
defmodule Towerops.Workers.AlertNotificationWorkerTest do
use Towerops.DataCase, async: false
alias Towerops.Organizations
setup do
user = Towerops.AccountsFixtures.user_fixture()
{:ok, organization} =
Organizations.create_organization(%{name: "Test Org"}, user.id)
%{organization: organization, user: user}
end
describe "alert_routing organization setting" do
test "defaults to builtin routing", %{organization: org} do
assert org.alert_routing == "builtin"
end
test "can be set to pagerduty", %{organization: org} do
{:ok, updated} = Organizations.update_organization(org, %{alert_routing: "pagerduty"})
assert updated.alert_routing == "pagerduty"
end
test "can be set to both", %{organization: org} do
{:ok, updated} = Organizations.update_organization(org, %{alert_routing: "both"})
assert updated.alert_routing == "both"
end
test "can be set back to builtin", %{organization: org} do
{:ok, pd} = Organizations.update_organization(org, %{alert_routing: "pagerduty"})
{:ok, builtin} = Organizations.update_organization(pd, %{alert_routing: "builtin"})
assert builtin.alert_routing == "builtin"
end
test "rejects invalid routing values", %{organization: org} do
{:error, changeset} = Organizations.update_organization(org, %{alert_routing: "invalid"})
assert {"must be builtin, pagerduty, or both", _} = changeset.errors[:alert_routing]
end
test "persists through reload", %{organization: org} do
{:ok, _} = Organizations.update_organization(org, %{alert_routing: "pagerduty"})
reloaded = Towerops.Repo.get!(Towerops.Organizations.Organization, org.id)
assert reloaded.alert_routing == "pagerduty"
end
end
end