towerops/lib/towerops/maintenance.ex
Graham McIntire acf70b3bee
fix: remove duplicate StormDetector child spec
The StormDetector was being added twice:
1. In the main children list (line 127)
2. In background_workers() (line 176)

This caused the supervisor to fail with 'duplicate_child_name' error.

Removed the duplicate from main children list since it's correctly
placed in background_workers() which also handles test env properly.
2026-03-15 16:05:30 -05:00

216 lines
6.3 KiB
Elixir

defmodule Towerops.Maintenance do
@moduledoc """
Context for managing maintenance windows.
Maintenance windows suppress alerts for devices, sites, or entire organizations
during scheduled maintenance periods.
"""
import Ecto.Query
alias Towerops.Devices.Device
alias Towerops.Maintenance.MaintenanceWindow
alias Towerops.Repo
@preloads [:organization, :site, :device, :created_by]
def create_window(attrs) do
%MaintenanceWindow{}
|> MaintenanceWindow.changeset(attrs)
|> Repo.insert()
end
def update_window(%MaintenanceWindow{} = window, attrs) do
window
|> MaintenanceWindow.changeset(attrs)
|> Repo.update()
end
def delete_window(%MaintenanceWindow{} = window) do
Repo.delete(window)
end
def get_window!(id) do
MaintenanceWindow
|> Repo.get!(id)
|> Repo.preload(@preloads)
end
def list_windows(organization_id, opts \\ []) do
now = DateTime.utc_now()
MaintenanceWindow
|> where([w], w.organization_id == ^organization_id)
|> apply_filter(opts[:filter], now)
|> order_by([w], desc: w.starts_at)
|> Repo.all()
|> Repo.preload(@preloads)
end
defp apply_filter(query, :active, now) do
where(query, [w], w.starts_at <= ^now and w.ends_at >= ^now)
end
defp apply_filter(query, :upcoming, now) do
where(query, [w], w.starts_at > ^now)
end
defp apply_filter(query, :past, now) do
where(query, [w], w.ends_at < ^now)
end
defp apply_filter(query, _, _now), do: query
@doc """
Checks if a device is currently in a maintenance window.
Checks direct device windows, site windows, and org-wide windows.
"""
def device_in_maintenance?(device_id) do
case active_windows_for_device(device_id) do
[] -> false
_ -> true
end
end
@doc """
Checks if a site is currently in a maintenance window.
"""
def site_in_maintenance?(site_id) do
now = DateTime.utc_now()
MaintenanceWindow
|> where([w], w.site_id == ^site_id and is_nil(w.device_id))
|> where([w], w.starts_at <= ^now and w.ends_at >= ^now)
|> where([w], w.suppress_alerts == true)
|> Repo.exists?()
end
@doc """
Returns all active maintenance windows affecting a device.
Checks: direct device match, site match, and org-wide windows.
"""
def active_windows_for_device(device_id) do
now = DateTime.utc_now()
device = Repo.get!(Device, device_id)
active_base =
MaintenanceWindow
|> where([w], w.organization_id == ^device.organization_id)
|> where([w], w.starts_at <= ^now and w.ends_at >= ^now)
|> where([w], w.suppress_alerts == true)
# Direct device windows
direct = where(active_base, [w], w.device_id == ^device_id)
# Site windows (if device has a site)
site_query =
if device.site_id do
where(active_base, [w], w.site_id == ^device.site_id and is_nil(w.device_id))
else
where(active_base, [w], false)
end
# Org-wide windows
org_wide = where(active_base, [w], is_nil(w.site_id) and is_nil(w.device_id))
direct_results = Repo.all(direct)
site_results = Repo.all(site_query)
org_results = Repo.all(org_wide)
Enum.uniq_by(direct_results ++ site_results ++ org_results, & &1.id)
end
@doc """
Returns all active maintenance windows for an organization.
"""
def active_windows_for_org(organization_id) do
now = DateTime.utc_now()
MaintenanceWindow
|> where([w], w.organization_id == ^organization_id)
|> where([w], w.starts_at <= ^now and w.ends_at >= ^now)
|> Repo.all()
|> Repo.preload(@preloads)
end
@doc """
Returns the name of the active maintenance window for a device or site, or nil.
Accepts a %Device{} or %Site{} struct.
"""
def maintenance_badge(%Device{} = device) do
case active_windows_for_device(device.id) do
[window | _] -> window.name
[] -> nil
end
end
def maintenance_badge(%Towerops.Sites.Site{} = site) do
now = DateTime.utc_now()
MaintenanceWindow
|> where([w], w.site_id == ^site.id and is_nil(w.device_id))
|> where([w], w.starts_at <= ^now and w.ends_at >= ^now)
|> where([w], w.suppress_alerts == true)
|> limit(1)
|> select([w], w.name)
|> Repo.one()
end
def maintenance_badge(_), do: nil
@doc """
Batch check which device IDs are currently in a maintenance window.
Returns a MapSet of device IDs that are in maintenance.
Much more efficient than calling `device_in_maintenance?/1` for each device
individually (e.g., during an alert storm with 500 devices).
"""
@spec devices_in_maintenance(list(String.t())) :: MapSet.t()
def devices_in_maintenance(device_ids) when is_list(device_ids) do
if Enum.empty?(device_ids) do
MapSet.new()
else
now = DateTime.utc_now()
# Get org_ids and site_ids for all devices in one query
devices_info =
Repo.all(
from(d in Device,
where: d.id in ^device_ids,
select: %{id: d.id, organization_id: d.organization_id, site_id: d.site_id}
)
)
org_ids = devices_info |> Enum.map(& &1.organization_id) |> Enum.uniq()
_site_ids = devices_info |> Enum.map(& &1.site_id) |> Enum.reject(&is_nil/1) |> Enum.uniq()
# Get all active windows for relevant orgs in one query
active_windows =
Repo.all(
from(w in MaintenanceWindow,
where: w.organization_id in ^org_ids,
where: w.starts_at <= ^now and w.ends_at >= ^now,
where: w.suppress_alerts == true,
select: %{device_id: w.device_id, site_id: w.site_id, organization_id: w.organization_id}
)
)
# Separate windows by scope
device_windows = active_windows |> Enum.filter(& &1.device_id) |> MapSet.new(& &1.device_id)
site_windows = active_windows |> Enum.filter(&(&1.site_id && is_nil(&1.device_id))) |> MapSet.new(& &1.site_id)
org_windows =
active_windows |> Enum.filter(&(is_nil(&1.site_id) && is_nil(&1.device_id))) |> MapSet.new(& &1.organization_id)
# Check each device against all window types
devices_info
|> Enum.filter(fn d ->
MapSet.member?(device_windows, d.id) ||
(d.site_id && MapSet.member?(site_windows, d.site_id)) ||
MapSet.member?(org_windows, d.organization_id)
end)
|> MapSet.new(& &1.id)
end
end
end