feat(devices): auto-resolve active alerts when monitoring is disabled

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
This commit is contained in:
Graham McIntire 2026-05-10 11:04:55 -05:00
parent 8f45057ba5
commit 4937570c2a
3 changed files with 71 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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}))