towerops/lib/towerops/maintenance.ex
Graham McIntire 94f144806a refactor(maintenance): use MaintenanceWindowQuery in active_windows_base
Replaces the last inline 'where: w.organization_id == ^...' clause in
maintenance.ex with composable filters from the existing MaintenanceWindowQuery
module.
2026-04-30 14:26:30 -05:00

217 lines
6.2 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.Maintenance.MaintenanceWindowQuery
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()
organization_id
|> MaintenanceWindowQuery.for_organization()
|> 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)
MaintenanceWindow
|> active_windows_base(device.organization_id, now)
|> scope_to_device(device_id, device.site_id)
|> Repo.all()
end
defp active_windows_base(query, organization_id, now) do
query
|> MaintenanceWindowQuery.for_organization(organization_id)
|> MaintenanceWindowQuery.active(now)
|> where([w], w.suppress_alerts == true)
end
defp scope_to_device(query, device_id, nil) do
from(w in query,
where: w.device_id == ^device_id or (is_nil(w.site_id) and is_nil(w.device_id))
)
end
defp scope_to_device(query, device_id, site_id) do
from(w in query,
where:
w.device_id == ^device_id or
(w.site_id == ^site_id and is_nil(w.device_id)) or
(is_nil(w.site_id) and is_nil(w.device_id))
)
end
@doc """
Returns all active maintenance windows for an organization.
"""
def active_windows_for_org(organization_id) do
now = DateTime.utc_now()
organization_id
|> MaintenanceWindowQuery.for_organization()
|> MaintenanceWindowQuery.active(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())) :: map()
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