diff --git a/lib/snmpkit/snmp_lib/config.ex b/lib/snmpkit/snmp_lib/config.ex index 61579daf..e4f947b3 100644 --- a/lib/snmpkit/snmp_lib/config.ex +++ b/lib/snmpkit/snmp_lib/config.ex @@ -441,6 +441,9 @@ defmodule SnmpKit.SnmpLib.Config do ## Private Implementation + # Capture Mix.env() at compile time since Mix is not available in releases + @compile_env if Code.ensure_loaded?(Mix), do: Mix.env(), else: :prod + # Environment detection with multiple fallbacks defp detect_environment(opts) do cond do @@ -448,7 +451,7 @@ defmodule SnmpKit.SnmpLib.Config do env = System.get_env("MIX_ENV") -> String.to_atom(env) env = System.get_env("SNMP_LIB_ENV") -> String.to_atom(env) env = Application.get_env(:snmp_lib, :environment) -> env - true -> :dev + true -> @compile_env end end diff --git a/lib/towerops/dashboard.ex b/lib/towerops/dashboard.ex index d910a2bf..85ab7271 100644 --- a/lib/towerops/dashboard.ex +++ b/lib/towerops/dashboard.ex @@ -7,6 +7,7 @@ defmodule Towerops.Dashboard do alias Towerops.Devices alias Towerops.Gaiia alias Towerops.Gaiia.ImpactAnalysis + alias Towerops.Integrations alias Towerops.Preseem alias Towerops.Preseem.Insights alias Towerops.Sites @@ -16,7 +17,7 @@ defmodule Towerops.Dashboard do status_counts = Devices.get_device_status_counts(organization_id) total_devices = status_counts |> Map.values() |> Enum.sum() active_alerts = Alerts.list_organization_active_alerts(organization_id) - subscriber_totals = Gaiia.get_org_subscriber_totals(organization_id) + subscriber_totals = Integrations.get_billing_summary(organization_id) insight_counts = Insights.count_active_by_urgency(organization_id) %{ @@ -83,9 +84,9 @@ defmodule Towerops.Dashboard do end end - @doc "Returns subscriber summary for the org from Gaiia data." + @doc "Returns subscriber summary for the org from billing integrations." def get_org_subscriber_summary(organization_id) do - totals = Gaiia.get_org_subscriber_totals(organization_id) + totals = Integrations.get_billing_summary(organization_id) %{ total: totals.total_subscribers, diff --git a/lib/towerops/gaiia.ex b/lib/towerops/gaiia.ex index b8c0c66c..e37da734 100644 --- a/lib/towerops/gaiia.ex +++ b/lib/towerops/gaiia.ex @@ -38,6 +38,20 @@ defmodule Towerops.Gaiia do ) end + def count_active_subscribers(organization_id) do + Account + |> where(organization_id: ^organization_id) + |> where([a], a.subscription_count > 0) + |> Repo.aggregate(:count) + end + + def sum_active_mrr(organization_id) do + BillingSubscription + |> where(organization_id: ^organization_id) + |> where(status: "ACTIVE") + |> Repo.aggregate(:sum, :mrr_amount) || Decimal.new(0) + end + # --- Network Sites --- def list_network_sites(organization_id) do diff --git a/lib/towerops/gaiia/site_aggregation.ex b/lib/towerops/gaiia/site_aggregation.ex new file mode 100644 index 00000000..c374e361 --- /dev/null +++ b/lib/towerops/gaiia/site_aggregation.ex @@ -0,0 +1,104 @@ +defmodule Towerops.Gaiia.SiteAggregation do + @moduledoc """ + Computes per-site subscriber counts and MRR by matching inventory item + IP addresses against network site CIDR blocks. + """ + + import Ecto.Query + + alias Towerops.Gaiia.Account + alias Towerops.Gaiia.BillingSubscription + alias Towerops.Gaiia.InventoryItem + alias Towerops.Gaiia.NetworkSite + alias Towerops.Repo + + require Logger + + @doc """ + For each network site with IP blocks, finds inventory items whose IP addresses + fall within those blocks, then counts unique accounts with active subscriptions + and sums their MRR. + + Updates each network site's `account_count` and `total_mrr` fields. + """ + def compute_and_store(organization_id) do + sites = load_sites_with_ip_blocks(organization_id) + items = load_assigned_items(organization_id) + + Enum.each(sites, fn site -> + cidrs = parse_cidrs(site.ip_blocks) + matched_account_ids = match_accounts_to_cidrs(items, cidrs) + {account_count, total_mrr} = compute_totals(organization_id, matched_account_ids) + update_site_totals(site, account_count, total_mrr) + end) + end + + defp load_sites_with_ip_blocks(organization_id) do + NetworkSite + |> where(organization_id: ^organization_id) + |> where([ns], fragment("cardinality(?) > 0", ns.ip_blocks)) + |> Repo.all() + end + + defp load_assigned_items(organization_id) do + InventoryItem + |> where(organization_id: ^organization_id) + |> where([ii], not is_nil(ii.assigned_account_gaiia_id)) + |> where([ii], not is_nil(ii.ip_address)) + |> Repo.all() + end + + defp parse_cidrs(ip_blocks) do + Enum.flat_map(ip_blocks, fn block -> + case InetCidr.parse_cidr(block) do + {:ok, cidr} -> [cidr] + _ -> [] + end + end) + end + + defp match_accounts_to_cidrs(items, cidrs) do + items + |> Enum.filter(fn item -> ip_in_any_cidr?(item.ip_address, cidrs) end) + |> Enum.map(& &1.assigned_account_gaiia_id) + |> Enum.uniq() + end + + defp ip_in_any_cidr?(ip_string, cidrs) do + case :inet.parse_address(String.to_charlist(ip_string)) do + {:ok, addr} -> + Enum.any?(cidrs, fn cidr -> InetCidr.contains?(cidr, addr) end) + + _ -> + false + end + end + + defp compute_totals(_organization_id, []) do + {0, Decimal.new("0")} + end + + defp compute_totals(organization_id, account_gaiia_ids) do + account_count = + Account + |> where(organization_id: ^organization_id) + |> where([a], a.gaiia_id in ^account_gaiia_ids) + |> where([a], a.subscription_count > 0) + |> Repo.aggregate(:count) + + total_mrr = + BillingSubscription + |> where(organization_id: ^organization_id) + |> where([bs], bs.account_gaiia_id in ^account_gaiia_ids) + |> where(status: "ACTIVE") + |> Repo.aggregate(:sum, :mrr_amount) || Decimal.new("0") + + {account_count, total_mrr} + end + + defp update_site_totals(site, account_count, total_mrr) do + site + |> Ecto.Changeset.change(%{account_count: account_count, total_mrr: total_mrr}) + |> Repo.update() + end +end diff --git a/lib/towerops/gaiia/sync.ex b/lib/towerops/gaiia/sync.ex index 47eab447..cb3c6435 100644 --- a/lib/towerops/gaiia/sync.ex +++ b/lib/towerops/gaiia/sync.ex @@ -37,6 +37,8 @@ defmodule Towerops.Gaiia.Sync do "Synced #{accounts_count} accounts, #{sites_count} sites, #{items_count} inventory items, #{subs_count} subscriptions" Integrations.update_sync_status(integration, "success", message) + persist_billing_totals(integration) + Gaiia.SiteAggregation.compute_and_store(org_id) {:ok, %{ @@ -168,6 +170,13 @@ defmodule Towerops.Gaiia.Sync do } end + defp persist_billing_totals(integration) do + org_id = integration.organization_id + subscriber_count = Gaiia.count_active_subscribers(org_id) + total_mrr = Gaiia.sum_active_mrr(org_id) + Integrations.update_billing_totals(integration, subscriber_count, total_mrr) + end + defp client_opts(integration) do case integration.credentials["endpoint"] do nil -> [] diff --git a/lib/towerops/integrations.ex b/lib/towerops/integrations.ex index d4129465..08595cf2 100644 --- a/lib/towerops/integrations.ex +++ b/lib/towerops/integrations.ex @@ -62,6 +62,32 @@ defmodule Towerops.Integrations do end end + @billing_providers ~w(gaiia splynx visp sonar) + + def update_billing_totals(%Integration{} = integration, subscriber_count, total_mrr) do + integration + |> Integration.changeset(%{subscriber_count: subscriber_count, total_mrr: total_mrr}) + |> Repo.update() + end + + def get_billing_summary(organization_id) do + result = + Integration + |> where(organization_id: ^organization_id) + |> where([i], i.provider in @billing_providers) + |> where(enabled: true) + |> select([i], %{ + total_subscribers: coalesce(sum(i.subscriber_count), 0), + total_mrr: coalesce(sum(i.total_mrr), 0) + }) + |> Repo.one() + + %{ + total_subscribers: result.total_subscribers, + total_mrr: result.total_mrr || Decimal.new(0) + } + end + def list_enabled_integrations(provider) do Integration |> where(provider: ^provider, enabled: true) diff --git a/lib/towerops/integrations/integration.ex b/lib/towerops/integrations/integration.ex index 532615af..b211fd62 100644 --- a/lib/towerops/integrations/integration.ex +++ b/lib/towerops/integrations/integration.ex @@ -25,6 +25,8 @@ defmodule Towerops.Integrations.Integration do field :last_synced_at, :utc_datetime field :last_sync_status, :string, default: "never" field :last_sync_message, :string + field :subscriber_count, :integer, default: 0 + field :total_mrr, :decimal belongs_to :organization, Towerops.Organizations.Organization @@ -41,7 +43,9 @@ defmodule Towerops.Integrations.Integration do :sync_interval_minutes, :last_synced_at, :last_sync_status, - :last_sync_message + :last_sync_message, + :subscriber_count, + :total_mrr ]) |> validate_required([:organization_id, :provider]) |> validate_inclusion(:provider, @valid_providers) diff --git a/lib/towerops/sonar/sync.ex b/lib/towerops/sonar/sync.ex index 9bae166d..56490876 100644 --- a/lib/towerops/sonar/sync.ex +++ b/lib/towerops/sonar/sync.ex @@ -35,6 +35,7 @@ defmodule Towerops.Sonar.Sync do if(mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(mrr / 1, decimals: 2)})", else: "") Integrations.update_sync_status(integration, "success", message) + Integrations.update_billing_totals(integration, accounts_count, Decimal.from_float(mrr)) {:ok, %{ diff --git a/lib/towerops/splynx/sync.ex b/lib/towerops/splynx/sync.ex index 5af37e66..5880db36 100644 --- a/lib/towerops/splynx/sync.ex +++ b/lib/towerops/splynx/sync.ex @@ -33,6 +33,7 @@ defmodule Towerops.Splynx.Sync do if(mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(mrr / 1, decimals: 2)})", else: "") Integrations.update_sync_status(integration, "success", message) + Integrations.update_billing_totals(integration, active_count, Decimal.from_float(mrr)) {:ok, %{ diff --git a/lib/towerops/visp/sync.ex b/lib/towerops/visp/sync.ex index 7cbdac49..213431b9 100644 --- a/lib/towerops/visp/sync.ex +++ b/lib/towerops/visp/sync.ex @@ -34,6 +34,7 @@ defmodule Towerops.Visp.Sync do if(total_mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(total_mrr / 1, decimals: 2)})", else: "") Integrations.update_sync_status(integration, "success", message) + Integrations.update_billing_totals(integration, subscribers_count, Decimal.from_float(total_mrr)) {:ok, %{ diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex index df4d7c4c..2f76b243 100644 --- a/lib/towerops_web/live/org/integrations_live.html.heex +++ b/lib/towerops_web/live/org/integrations_live.html.heex @@ -32,7 +32,10 @@ <%= if category.exclusive do %> <%!-- Exclusive category (billing): show as picker + single config panel --%> - <% active_billing = Enum.find(category.providers, fn p -> @integrations[p.id] && @integrations[p.id].enabled end) %> + <% active_billing = + Enum.find(category.providers, fn p -> + @integrations[p.id] && @integrations[p.id].enabled + end) %>
- {t("Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change.")} + {t( + "Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change." + )}