diff --git a/lib/towerops/alerts.ex b/lib/towerops/alerts.ex index 0cedfd6d..89dc366d 100644 --- a/lib/towerops/alerts.ex +++ b/lib/towerops/alerts.ex @@ -6,18 +6,40 @@ defmodule Towerops.Alerts do import Ecto.Query alias Towerops.Alerts.Alert + alias Towerops.Gaiia.ImpactAnalysis 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 - %Alert{} - |> Alert.changeset(attrs) - |> Repo.insert() + 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. """ @@ -183,6 +205,15 @@ defmodule Towerops.Alerts do |> 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. """ diff --git a/lib/towerops/alerts/alert.ex b/lib/towerops/alerts/alert.ex index bc9b343f..788c2fc1 100644 --- a/lib/towerops/alerts/alert.ex +++ b/lib/towerops/alerts/alert.ex @@ -22,6 +22,7 @@ defmodule Towerops.Alerts.Alert do field :resolved_at, :utc_datetime field :email_sent_at, :utc_datetime field :message, :string + field :gaiia_impact, :map belongs_to :device, Device belongs_to :acknowledged_by, User @@ -37,6 +38,7 @@ defmodule Towerops.Alerts.Alert do resolved_at: DateTime.t() | nil, email_sent_at: DateTime.t() | nil, message: String.t() | nil, + gaiia_impact: map() | nil, device_id: Ecto.UUID.t(), device: NotLoaded.t() | Device.t(), acknowledged_by_id: Ecto.UUID.t() | nil, @@ -56,7 +58,8 @@ defmodule Towerops.Alerts.Alert do :acknowledged_by_id, :resolved_at, :email_sent_at, - :message + :message, + :gaiia_impact ]) |> validate_required([:device_id, :alert_type, :triggered_at]) |> foreign_key_constraint(:device_id) diff --git a/lib/towerops/gaiia/impact_analysis.ex b/lib/towerops/gaiia/impact_analysis.ex new file mode 100644 index 00000000..1d2d06f5 --- /dev/null +++ b/lib/towerops/gaiia/impact_analysis.ex @@ -0,0 +1,128 @@ +defmodule Towerops.Gaiia.ImpactAnalysis do + @moduledoc """ + Calculates subscriber impact when a Towerops device goes down. + + Three layers of impact analysis (broadest to most precise): + 1. Site-level: Device → Site → Gaiia Network Site → all subscribers at that site + 2. Device-level: Device → Gaiia Inventory Item → directly assigned subscriber + """ + + alias Towerops.Devices + alias Towerops.Gaiia + + @doc """ + Analyze the subscriber impact of a device going down. + + Returns a map with: + - `:device_level` - direct subscriber impact (from inventory item assignment) + - `:site_level` - broader site-level impact (from network site mapping) + - `:total_subscribers` - broadest subscriber count (site > device) + - `:total_mrr` - broadest MRR estimate + - `:analyzed_at` - timestamp of analysis + """ + @spec analyze_device_impact(String.t(), String.t()) :: map() + def analyze_device_impact(organization_id, device_id) do + device = Devices.get_device(device_id) + device_level = analyze_device_level(organization_id, device_id) + site_level = analyze_site_level(organization_id, device) + + {total_subscribers, total_mrr} = compute_totals(device_level, site_level) + + %{ + device_level: device_level, + site_level: site_level, + total_subscribers: total_subscribers, + total_mrr: total_mrr, + analyzed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + end + + @doc """ + Convert impact analysis result to a JSON-serializable map for storage. + """ + @spec to_json(map()) :: map() + def to_json(impact) do + %{ + "device_level" => encode_level(impact.device_level), + "site_level" => encode_level(impact.site_level), + "total_subscribers" => impact.total_subscribers, + "total_mrr" => to_string(impact.total_mrr), + "analyzed_at" => DateTime.to_iso8601(impact.analyzed_at) + } + end + + defp analyze_device_level(organization_id, device_id) do + case Gaiia.get_inventory_item_for_device(device_id) do + nil -> + nil + + item -> + {subscribers, mrr} = get_item_subscriber_impact(organization_id, item) + + if subscribers > 0 do + %{ + subscriber_count: subscribers, + mrr: mrr, + inventory_item_name: item.name, + confidence: :device_level + } + end + end + end + + defp analyze_site_level(_organization_id, nil), do: nil + + defp analyze_site_level(_organization_id, %{site_id: nil}), do: nil + + defp analyze_site_level(_organization_id, device) do + case Gaiia.get_network_site_for_site(device.site_id) do + nil -> + nil + + network_site -> + count = network_site.account_count || 0 + mrr = network_site.total_mrr || Decimal.new("0") + + %{ + subscriber_count: count, + mrr: mrr, + site_name: network_site.name, + confidence: :site_level + } + end + end + + defp get_item_subscriber_impact(organization_id, item) do + case item.assigned_account_gaiia_id do + nil -> + {0, Decimal.new("0")} + + account_gaiia_id -> + case Gaiia.get_account(organization_id, account_gaiia_id) do + nil -> {1, Decimal.new("0")} + account -> {1, account.mrr || Decimal.new("0")} + end + end + end + + defp compute_totals(nil, nil), do: {0, Decimal.new("0")} + defp compute_totals(nil, site), do: {site.subscriber_count, site.mrr} + defp compute_totals(device, nil), do: {device.subscriber_count, device.mrr} + + defp compute_totals(_device, site) do + # Site-level is the broadest impact estimate + {site.subscriber_count, site.mrr} + end + + defp encode_level(nil), do: nil + + defp encode_level(level) do + level + |> maybe_from_struct() + |> Map.update(:mrr, "0", &to_string/1) + |> Map.update(:confidence, nil, &to_string/1) + end + + defp maybe_from_struct(%_{} = struct), do: Map.from_struct(struct) + defp maybe_from_struct(map) when is_map(map), do: map +end diff --git a/lib/towerops_web/live/alert_live/index.html.heex b/lib/towerops_web/live/alert_live/index.html.heex index c75f06af..81d7b442 100644 --- a/lib/towerops_web/live/alert_live/index.html.heex +++ b/lib/towerops_web/live/alert_live/index.html.heex @@ -107,6 +107,21 @@

{alert.message}

+ <%= if alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] && alert.gaiia_impact["total_subscribers"] > 0 do %> +
+ + <.icon name="hero-users" class="h-3.5 w-3.5" /> + {alert.gaiia_impact["total_subscribers"]} subscribers affected + + <%= if alert.gaiia_impact["total_mrr"] && alert.gaiia_impact["total_mrr"] != "0" do %> + + <.icon name="hero-currency-dollar" class="h-3.5 w-3.5" /> + ${alert.gaiia_impact["total_mrr"]}/mo at risk + + <% end %> +
+ <% end %> +
Triggered: diff --git a/priv/repo/migrations/20260213170408_add_gaiia_impact_to_alerts.exs b/priv/repo/migrations/20260213170408_add_gaiia_impact_to_alerts.exs new file mode 100644 index 00000000..a5ba73a4 --- /dev/null +++ b/priv/repo/migrations/20260213170408_add_gaiia_impact_to_alerts.exs @@ -0,0 +1,9 @@ +defmodule Towerops.Repo.Migrations.AddGaiiaImpactToAlerts do + use Ecto.Migration + + def change do + alter table(:alerts) do + add :gaiia_impact, :map + end + end +end diff --git a/test/towerops/gaiia/impact_analysis_test.exs b/test/towerops/gaiia/impact_analysis_test.exs new file mode 100644 index 00000000..e4fc0a64 --- /dev/null +++ b/test/towerops/gaiia/impact_analysis_test.exs @@ -0,0 +1,148 @@ +defmodule Towerops.Gaiia.ImpactAnalysisTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Gaiia + alias Towerops.Gaiia.ImpactAnalysis + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{org: org} + end + + describe "analyze_device_impact/2" do + test "returns empty impact when device has no gaiia mapping", %{org: org} do + device = device_fixture(%{organization_id: org.id, name: "Unmapped Router"}) + + result = ImpactAnalysis.analyze_device_impact(org.id, device.id) + + assert result.device_level == nil + assert result.site_level == nil + assert result.total_subscribers == 0 + assert result.total_mrr == Decimal.new("0") + end + + test "returns device-level impact when device is mapped to inventory item with account", %{ + org: org + } do + device = device_fixture(%{organization_id: org.id, name: "Mapped Router"}) + + {:ok, _account} = + Gaiia.upsert_account(org.id, %{ + gaiia_id: "gaiia-acct-1", + name: "John Smith", + status: "active", + mrr: Decimal.new("79.99") + }) + + {:ok, _item} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "gaiia-inv-1", + name: "Router One", + assigned_account_gaiia_id: "gaiia-acct-1" + }) + + Gaiia.update_inventory_item_mapping( + Gaiia.get_inventory_item(org.id, "gaiia-inv-1"), + %{device_id: device.id} + ) + + result = ImpactAnalysis.analyze_device_impact(org.id, device.id) + + assert result.device_level + assert result.device_level.subscriber_count == 1 + assert Decimal.equal?(result.device_level.mrr, Decimal.new("79.99")) + assert result.total_subscribers == 1 + end + + test "returns site-level impact when device site is mapped to gaiia network site", %{ + org: org + } do + device = device_fixture(%{organization_id: org.id, name: "Tower Router"}) + device = Towerops.Repo.preload(device, :site) + + {:ok, _network_site} = + Gaiia.upsert_network_site(org.id, %{ + gaiia_id: "gaiia-site-1", + name: "Tower 3", + account_count: 147, + total_mrr: Decimal.new("17640.00") + }) + + Gaiia.update_network_site_mapping( + Gaiia.get_network_site(org.id, "gaiia-site-1"), + %{site_id: device.site_id} + ) + + result = ImpactAnalysis.analyze_device_impact(org.id, device.id) + + assert result.site_level + assert result.site_level.subscriber_count == 147 + assert Decimal.equal?(result.site_level.mrr, Decimal.new("17640.00")) + assert result.site_level.site_name == "Tower 3" + end + + test "combines device and site level impacts", %{org: org} do + device = device_fixture(%{organization_id: org.id, name: "Tower AP"}) + device = Towerops.Repo.preload(device, :site) + + {:ok, _account} = + Gaiia.upsert_account(org.id, %{ + gaiia_id: "gaiia-acct-1", + name: "Direct Subscriber", + status: "active", + mrr: Decimal.new("50.00") + }) + + {:ok, _item} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "gaiia-inv-1", + name: "AP One", + assigned_account_gaiia_id: "gaiia-acct-1" + }) + + Gaiia.update_inventory_item_mapping( + Gaiia.get_inventory_item(org.id, "gaiia-inv-1"), + %{device_id: device.id} + ) + + {:ok, _network_site} = + Gaiia.upsert_network_site(org.id, %{ + gaiia_id: "gaiia-site-1", + name: "Tower 3", + account_count: 50, + total_mrr: Decimal.new("5000.00") + }) + + Gaiia.update_network_site_mapping( + Gaiia.get_network_site(org.id, "gaiia-site-1"), + %{site_id: device.site_id} + ) + + result = ImpactAnalysis.analyze_device_impact(org.id, device.id) + + assert result.device_level + assert result.site_level + # Total should use the broadest (site-level) subscriber count + assert result.total_subscribers == 50 + assert Decimal.equal?(result.total_mrr, Decimal.new("5000.00")) + end + + test "returns analyzable result structure", %{org: org} do + device = device_fixture(%{organization_id: org.id, name: "Test Router"}) + + result = ImpactAnalysis.analyze_device_impact(org.id, device.id) + + assert is_map(result) + assert Map.has_key?(result, :device_level) + assert Map.has_key?(result, :site_level) + assert Map.has_key?(result, :total_subscribers) + assert Map.has_key?(result, :total_mrr) + assert Map.has_key?(result, :analyzed_at) + end + end +end