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:
parent
8e25034e69
commit
37314bd154
6 changed files with 242 additions and 29 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
47
test/towerops/workers/alert_notification_worker_test.exs
Normal file
47
test/towerops/workers/alert_notification_worker_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue