From 4937570c2aa3f093bfa240c37c4c3859d972729e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 10 May 2026 11:04:55 -0500 Subject: [PATCH] feat(devices): auto-resolve active alerts when monitoring is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user flips monitoring_enabled true → false on a device, any unresolved alerts on that device are now resolved silently — the operator has deliberately stopped monitoring it, so leaving stale "device down" pages around is noise. - Alerts.resolve_active_alerts_for_device/1: bulk-resolves via resolve_alert_silent so PagerDuty/email don't fire - Devices.handle_monitoring_changes hooks it on the disable transition --- lib/towerops/alerts.ex | 17 ++++++++++++ lib/towerops/devices.ex | 5 ++++ test/towerops/devices_test.exs | 49 ++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/lib/towerops/alerts.ex b/lib/towerops/alerts.ex index 136011d6..cde20ec7 100644 --- a/lib/towerops/alerts.ex +++ b/lib/towerops/alerts.ex @@ -370,6 +370,23 @@ defmodule Towerops.Alerts do do_acknowledge_alert(alert, nil) end + @doc """ + Resolves every active (unresolved) alert for the given device. + + Used when the user takes a deliberate action that makes the alerts + meaningless — e.g. disabling monitoring on a device. Resolves silently + so downstream notification channels (PagerDuty, email) don't fire a + spurious "resolved" event. + """ + def resolve_active_alerts_for_device(device_id) do + from(a in Alert, + where: a.device_id == ^device_id, + where: is_nil(a.resolved_at) + ) + |> Repo.all() + |> Enum.each(&resolve_alert_silent/1) + end + # Performs the database update for resolving an alert defp do_resolve_alert(alert) do alert diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index cc0fe416..4ccc5e53 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -6,6 +6,7 @@ defmodule Towerops.Devices do import Ecto.Query alias Towerops.Agents + alias Towerops.Alerts alias Towerops.Devices.CredentialResolver alias Towerops.Devices.Device, as: DeviceSchema alias Towerops.Devices.DeviceQuery @@ -982,6 +983,10 @@ defmodule Towerops.Devices do !device.monitoring_enabled && old_monitoring -> _ = DeviceMonitorWorker.stop_monitoring(device.id) + # Active alerts on this device are no longer meaningful — the user + # has deliberately stopped monitoring it. Resolve silently so we + # don't page anyone about the resolution. + _ = Alerts.resolve_active_alerts_for_device(device.id) true -> :ok diff --git a/test/towerops/devices_test.exs b/test/towerops/devices_test.exs index 17f624d4..61438ece 100644 --- a/test/towerops/devices_test.exs +++ b/test/towerops/devices_test.exs @@ -2,6 +2,7 @@ defmodule Towerops.EquipmentTest do use Towerops.DataCase use ExUnitProperties + alias Towerops.Alerts.Alert alias Towerops.Devices alias Towerops.Devices.Device @@ -131,6 +132,54 @@ defmodule Towerops.EquipmentTest do assert device.monitoring_enabled == false end + test "update_device/2 disabling monitoring auto-resolves active alerts on the device", + %{organization: organization, site: site} do + {:ok, device} = + Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id})) + + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + device_id: device.id, + organization_id: organization.id, + alert_type: "device_down", + severity: 2, + message: "Device unreachable", + triggered_at: DateTime.utc_now() + }) + + assert is_nil(alert.resolved_at) + + assert {:ok, updated} = + Devices.update_device(device, %{monitoring_enabled: false}) + + assert updated.monitoring_enabled == false + + reloaded_alert = Towerops.Repo.get!(Alert, alert.id) + assert reloaded_alert.resolved_at, "alert should be auto-resolved" + end + + test "update_device/2 leaves alerts alone when monitoring stays enabled", + %{organization: organization, site: site} do + {:ok, device} = + Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id})) + + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + device_id: device.id, + organization_id: organization.id, + alert_type: "device_down", + severity: 2, + message: "Device unreachable", + triggered_at: DateTime.utc_now() + }) + + # Updating something unrelated should not touch alerts + assert {:ok, _updated} = Devices.update_device(device, %{name: "Renamed"}) + + reloaded_alert = Towerops.Repo.get!(Alert, alert.id) + assert is_nil(reloaded_alert.resolved_at) + end + test "update_device/2 with invalid data returns error changeset", %{organization: organization, site: site} do {:ok, device} = Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))