add gaiia outage impact analysis (stage 5)
Computes subscriber impact when device alerts fire. Three analysis layers: device-level (inventory item -> direct subscriber) and site-level (network site -> all subscribers at tower). Impact data stored as JSONB on alert records and displayed as badges on alert list.
This commit is contained in:
parent
ce85fb0465
commit
934ee5bb86
6 changed files with 338 additions and 4 deletions
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
128
lib/towerops/gaiia/impact_analysis.ex
Normal file
128
lib/towerops/gaiia/impact_analysis.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -107,6 +107,21 @@
|
|||
|
||||
<p class="mt-1 text-sm text-gray-700 dark:text-gray-300">{alert.message}</p>
|
||||
|
||||
<%= if alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] && alert.gaiia_impact["total_subscribers"] > 0 do %>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-md bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/20">
|
||||
<.icon name="hero-users" class="h-3.5 w-3.5" />
|
||||
{alert.gaiia_impact["total_subscribers"]} subscribers affected
|
||||
</span>
|
||||
<%= if alert.gaiia_impact["total_mrr"] && alert.gaiia_impact["total_mrr"] != "0" do %>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-md bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/20">
|
||||
<.icon name="hero-currency-dollar" class="h-3.5 w-3.5" />
|
||||
${alert.gaiia_impact["total_mrr"]}/mo at risk
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-3 space-y-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
<strong class="font-medium">Triggered:</strong>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
148
test/towerops/gaiia/impact_analysis_test.exs
Normal file
148
test/towerops/gaiia/impact_analysis_test.exs
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue