defmodule Towerops.Alerts do @moduledoc """ The Alerts context. """ import Ecto.Query alias Towerops.Alerts.Alert alias Towerops.Gaiia.ImpactAnalysis alias Towerops.PagerDuty.Notifier alias Towerops.Repo @doc """ Creates an alert for device status change. For device_down alerts, automatically computes and stores Gaiia impact analysis if the device belongs to an organization with a Gaiia integration. """ @spec create_alert(map()) :: {:ok, Alert.t()} | {:error, Ecto.Changeset.t()} def create_alert(attrs) do with {:ok, alert} <- Repo.insert(Alert.changeset(%Alert{}, attrs)) do maybe_compute_gaiia_impact(alert) {:ok, alert} end end defp maybe_compute_gaiia_impact(%Alert{alert_type: :device_down, device_id: device_id} = alert) when not is_nil(device_id) do device = Towerops.Devices.get_device(device_id) if device do impact = ImpactAnalysis.analyze_device_impact(device.organization_id, device_id) if impact.total_subscribers > 0 do store_gaiia_impact(alert, ImpactAnalysis.to_json(impact)) end end rescue _ -> :ok end defp maybe_compute_gaiia_impact(_alert), do: :ok @doc """ Returns the list of alerts for an device. """ @spec list_devices_alerts(String.t(), integer()) :: [Alert.t()] def list_devices_alerts(device_id, limit \\ 100) do Repo.all( from(a in Alert, where: a.device_id == ^device_id, order_by: [desc: a.triggered_at], limit: ^limit, preload: [:device, :acknowledged_by] ) ) end @doc """ Returns the list of active (unresolved) alerts for an organization. Only returns equipment_down alerts, as equipment_up alerts are informational. """ @spec list_organization_active_alerts(String.t()) :: [Alert.t()] def list_organization_active_alerts(organization_id) do Repo.all( from(a in Alert, join: e in assoc(a, :device), join: s in assoc(e, :site), where: s.organization_id == ^organization_id, where: a.alert_type == :device_down, where: is_nil(a.resolved_at), order_by: [desc: a.triggered_at], preload: [:acknowledged_by, device: {e, site: s}] ) ) end @doc """ Returns the count of active (unresolved) alerts for an organization. """ @spec count_active_alerts(String.t()) :: integer() def count_active_alerts(organization_id) do Repo.aggregate( from(a in Alert, join: e in assoc(a, :device), join: s in assoc(e, :site), where: s.organization_id == ^organization_id, where: a.alert_type == :device_down, where: is_nil(a.resolved_at) ), :count ) end @doc "Returns the count of active alerts for devices at a specific site." def count_site_active_alerts(site_id) do Repo.aggregate( from(a in Alert, join: e in assoc(a, :device), where: e.site_id == ^site_id, where: a.alert_type == :device_down, where: is_nil(a.resolved_at) ), :count ) end @doc "Returns active (unresolved) alerts for devices at a specific site." def list_site_active_alerts(site_id) do Repo.all( from(a in Alert, join: e in assoc(a, :device), where: e.site_id == ^site_id, where: a.alert_type == :device_down, where: is_nil(a.resolved_at), order_by: [desc: a.triggered_at], preload: [device: e] ) ) end @doc """ Returns the list of all alerts for an organization. Accepts either an integer limit or a map of filters: - limit (integer): Max number of alerts to return - filters (map): - status: Filter by status ("active", "acknowledged", "resolved") - limit: Max number of alerts to return """ @spec list_organization_alerts(String.t(), integer() | map()) :: [Alert.t()] def list_organization_alerts(organization_id, limit) when is_integer(limit) do list_organization_alerts(organization_id, %{"limit" => limit}) end def list_organization_alerts(organization_id, filters) when is_map(filters) do limit = filters["limit"] || 100 query = from(a in Alert, join: e in assoc(a, :device), join: s in assoc(e, :site), where: s.organization_id == ^organization_id, order_by: [desc: a.triggered_at], limit: ^limit, preload: [device: {e, site: s}, acknowledged_by: []] ) query = case filters["status"] do "active" -> where(query, [a], is_nil(a.resolved_at) and is_nil(a.acknowledged_at)) "acknowledged" -> where(query, [a], is_nil(a.resolved_at) and not is_nil(a.acknowledged_at)) "resolved" -> where(query, [a], not is_nil(a.resolved_at)) _ -> query end Repo.all(query) end @doc """ Returns alerts for multiple organizations (for GDPR data access). Accepts a `since` option to filter alerts created after a specific date. """ def list_alerts_for_organizations(organization_ids, opts \\ []) when is_list(organization_ids) do since = Keyword.get(opts, :since) query = from(a in Alert, join: e in assoc(a, :device), join: s in assoc(e, :site), where: s.organization_id in ^organization_ids, order_by: [desc: a.triggered_at], preload: [device: {e, site: s}] ) query = if since do where(query, [a], a.inserted_at >= ^since) else query end Repo.all(query) end @doc """ Gets a single alert. where: s.organization_id == ^organization_id, order_by: [desc: a.triggered_at], limit: ^limit, preload: [device: {e, site: s}, acknowledged_by: []] ) ) end @doc \""" Gets a single alert. """ def get_alert!(id) do Alert |> Repo.get!(id) |> Repo.preload([:device, :acknowledged_by]) end @doc """ Gets a single alert. Returns nil if not found. """ def get_alert(id) do case Repo.get(Alert, id) do nil -> nil alert -> Repo.preload(alert, [:device, :acknowledged_by]) end end @doc """ Acknowledges an alert. """ def acknowledge_alert(alert, user_id) do result = alert |> Alert.changeset(%{ acknowledged_at: DateTime.truncate(DateTime.utc_now(), :second), acknowledged_by_id: user_id }) |> Repo.update() case result do {:ok, updated_alert} -> Task.start(fn -> Notifier.notify_acknowledge(updated_alert) end) {:ok, updated_alert} error -> error end end @doc """ Resolves an alert. """ def resolve_alert(alert) do result = alert |> Alert.changeset(%{resolved_at: DateTime.truncate(DateTime.utc_now(), :second)}) |> Repo.update() case result do {:ok, updated_alert} -> Task.start(fn -> Notifier.notify_resolve(updated_alert) end) {:ok, updated_alert} error -> error end end @doc """ Resolves an alert without notifying PagerDuty (used when PagerDuty is the source). """ def resolve_alert_silent(alert) do alert |> Alert.changeset(%{resolved_at: DateTime.truncate(DateTime.utc_now(), :second)}) |> Repo.update() end @doc """ Acknowledges an alert without notifying PagerDuty (used when PagerDuty is the source). """ def acknowledge_alert_silent(alert) do alert |> Alert.changeset(%{acknowledged_at: DateTime.truncate(DateTime.utc_now(), :second)}) |> Repo.update() end @doc """ Stores Gaiia impact analysis data on an alert record. """ def store_gaiia_impact(%Alert{} = alert, impact_data) when is_map(impact_data) do alert |> Alert.changeset(%{gaiia_impact: impact_data}) |> Repo.update() end @doc """ Checks if there's an active alert of the same type for the device. """ def has_active_alert?(device_id, alert_type) do Repo.exists?( from(a in Alert, where: a.device_id == ^device_id, where: a.alert_type == ^alert_type, where: is_nil(a.resolved_at) ) ) end @doc """ Gets the latest active alert of a specific type for device. """ def get_active_alert(device_id, alert_type) do Repo.one( from(a in Alert, where: a.device_id == ^device_id, where: a.alert_type == ^alert_type, where: is_nil(a.resolved_at), order_by: [desc: a.triggered_at], limit: 1 ) ) end @doc """ Checks if there's an active alert for a check. """ def has_active_check_alert?(check_id) do Repo.exists?( from(a in Alert, where: a.check_id == ^check_id, where: is_nil(a.resolved_at) ) ) end @doc """ Resolves all active alerts for a check. """ def resolve_check_alerts(check_id) do now = DateTime.truncate(DateTime.utc_now(), :second) Repo.update_all(from(a in Alert, where: a.check_id == ^check_id, where: is_nil(a.resolved_at)), set: [resolved_at: now] ) end end