towerops/lib/towerops/dashboard.ex
Graham McIntire de075ea8d9
Store billing subscriber/MRR data on integrations for dashboard
Each billing sync (Gaiia, Splynx, VISP, Sonar) already computes
subscriber counts and MRR but only stored them in sync messages.
Now persists them on the integrations table so the dashboard can
aggregate across all billing providers instead of only querying
Gaiia network sites (which were never populated).

Also fixes SnmpKit Config environment detection to use Mix.env()
as fallback when MIX_ENV env var is not set.
2026-02-15 16:51:33 -06:00

273 lines
8.9 KiB
Elixir

defmodule Towerops.Dashboard do
@moduledoc """
Aggregates data from multiple contexts for the command center dashboard.
"""
alias Towerops.Alerts
alias Towerops.Devices
alias Towerops.Gaiia
alias Towerops.Gaiia.ImpactAnalysis
alias Towerops.Integrations
alias Towerops.Preseem
alias Towerops.Preseem.Insights
alias Towerops.Sites
@doc "Returns a complete dashboard summary for an organization."
def get_dashboard_summary(organization_id) 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 = Integrations.get_billing_summary(organization_id)
insight_counts = Insights.count_active_by_urgency(organization_id)
%{
health_score: compute_health_score(organization_id),
devices: %{
total: total_devices,
up: Map.get(status_counts, :up, 0),
down: Map.get(status_counts, :down, 0),
unknown: Map.get(status_counts, :unknown, 0)
},
alerts: %{
active_count: length(active_alerts)
},
subscribers: %{
total: subscriber_totals.total_subscribers,
total_mrr: subscriber_totals.total_mrr
},
insights: insight_counts
}
end
@doc """
Compute a weighted 0-100 health score for an organization.
Weights:
- Device uptime % (40%)
- Response time health (20%) - currently based on device status
- Critical alert penalty (20%)
- Preseem QoE average (10%, skipped if unavailable)
- Agent online % (10%, skipped if no agents)
Unavailable components have their weight redistributed proportionally.
"""
def compute_health_score(organization_id) do
status_counts = Devices.get_device_status_counts(organization_id)
total = status_counts |> Map.values() |> Enum.sum()
if total == 0 do
100
else
up = Map.get(status_counts, :up, 0)
down = Map.get(status_counts, :down, 0)
active_alerts = Alerts.list_organization_active_alerts(organization_id)
# Device uptime percentage (0.0-1.0)
uptime_ratio = up / total
# Response time health - approximate from up/unknown ratio
responsive_ratio = (total - down) / total
# Alert penalty - each alert reduces score, capped at full penalty
alert_penalty = min(length(active_alerts) * 0.1, 1.0)
alert_score = 1.0 - alert_penalty
# Weighted score (redistribute Preseem/Agent weights to core metrics)
# Base weights: uptime 40%, response 20%, alerts 20%, preseem 10%, agents 10%
# Without preseem/agents: uptime 50%, response 25%, alerts 25%
score =
uptime_ratio * 50.0 +
responsive_ratio * 25.0 +
alert_score * 25.0
round(score)
end
end
@doc "Returns subscriber summary for the org from billing integrations."
def get_org_subscriber_summary(organization_id) do
totals = Integrations.get_billing_summary(organization_id)
%{
total: totals.total_subscribers,
total_mrr: totals.total_mrr
}
end
@doc "Returns per-site summaries with device counts, alerts, and subscriber data."
def list_site_summaries(organization_id) do
sites = Sites.list_organization_sites(organization_id)
Enum.map(sites, fn site ->
device_count = Devices.count_site_devices(site.id)
down_count = Devices.count_site_devices_down(site.id)
gaiia_summary = Gaiia.get_site_subscriber_summary(site.id)
%{
site_id: site.id,
name: site.name,
device_count: device_count,
devices_down: down_count,
subscribers: if(gaiia_summary, do: gaiia_summary.account_count),
mrr: if(gaiia_summary, do: gaiia_summary.total_mrr)
}
end)
end
@doc """
Returns aggregate impact summary for current outages.
Shows total subscribers affected by down devices, total MRR at risk,
and number of sites with outages.
"""
def get_impact_summary(organization_id) do
down_devices = Devices.list_organization_devices(organization_id, %{"status" => "down"})
# Compute impact for each down device
impacts =
Enum.map(down_devices, fn device ->
ImpactAnalysis.analyze_device_impact(organization_id, device.id)
end)
# Aggregate — count unique sites with outages
sites_with_outages =
down_devices
|> Enum.map(& &1.site_id)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
|> length()
total_subscribers = impacts |> Enum.map(& &1.total_subscribers) |> Enum.sum()
total_mrr =
impacts
|> Enum.map(& &1.total_mrr)
|> Enum.reduce(Decimal.new("0"), &Decimal.add/2)
%{
subscribers_affected: total_subscribers,
mrr_at_risk: total_mrr,
sites_with_outages: sites_with_outages,
down_device_count: length(down_devices)
}
end
@doc """
Returns down devices enriched with Gaiia subscriber impact, Preseem QoE data,
site info, and outage duration. Sorted by MRR impact descending.
"""
def list_active_incidents(organization_id) do
down_devices = Devices.list_organization_devices(organization_id, %{"status" => "down"})
now = DateTime.utc_now()
incidents =
Enum.map(down_devices, fn device ->
impact = ImpactAnalysis.analyze_device_impact(organization_id, device.id)
preseem_ap = Preseem.get_access_point_for_device(device.id)
duration_seconds =
if device.last_status_change_at do
DateTime.diff(now, device.last_status_change_at, :second)
end
%{
device_id: device.id,
device_name: device.name,
site_id: device.site_id,
site_name: if(device.site, do: device.site.name),
duration_seconds: duration_seconds,
subscribers_affected: impact.total_subscribers,
mrr_at_risk: impact.total_mrr,
qoe_score: if(preseem_ap, do: preseem_ap.qoe_score),
triggered_at: device.last_status_change_at
}
end)
Enum.sort_by(incidents, & &1.mrr_at_risk, {:desc, Decimal})
end
@doc """
Enhanced site summaries with impact data from currently-down devices,
MRR at risk, average QoE score, and site health score.
"""
def get_site_impact_summaries(organization_id) do
sites = Sites.list_organization_sites(organization_id)
Enum.map(sites, fn site ->
device_count = Devices.count_site_devices(site.id)
down_count = Devices.count_site_devices_down(site.id)
gaiia_summary = Gaiia.get_site_subscriber_summary(site.id)
# Get impact from down devices at this site
down_devices = Devices.list_organization_devices(organization_id, %{"status" => "down", "site_id" => site.id})
{subscribers_affected, mrr_at_risk} =
calculate_down_impact(organization_id, down_devices, down_count)
# Get average QoE from Preseem for devices at this site
site_devices = Devices.list_site_devices(site.id)
qoe_scores =
site_devices
|> Enum.map(fn d -> Preseem.get_access_point_for_device(d.id) end)
|> Enum.reject(&is_nil/1)
|> Enum.map(& &1.qoe_score)
|> Enum.reject(&is_nil/1)
avg_qoe =
if qoe_scores != [] do
Enum.sum(qoe_scores) / length(qoe_scores)
end
# Site health: green (all up), yellow (some issues), red (devices down)
site_health =
cond do
down_count > 0 -> :red
avg_qoe && avg_qoe < 7.0 -> :yellow
true -> :green
end
%{
site_id: site.id,
name: site.name,
device_count: device_count,
devices_down: down_count,
subscribers: if(gaiia_summary, do: gaiia_summary.account_count),
mrr: if(gaiia_summary, do: gaiia_summary.total_mrr),
subscribers_affected: subscribers_affected,
mrr_at_risk: mrr_at_risk,
avg_qoe: avg_qoe,
site_health: site_health
}
end)
end
@doc "Returns a summary for a single site."
def get_site_summary(site_id) do
device_count = Devices.count_site_devices(site_id)
down_count = Devices.count_site_devices_down(site_id)
alert_count = Alerts.count_site_active_alerts(site_id)
gaiia_summary = Gaiia.get_site_subscriber_summary(site_id)
%{
device_count: device_count,
devices_down: down_count,
alert_count: alert_count,
subscribers: if(gaiia_summary, do: gaiia_summary.account_count),
mrr: if(gaiia_summary, do: gaiia_summary.total_mrr)
}
end
defp calculate_down_impact(_organization_id, _down_devices, 0), do: {0, Decimal.new("0")}
defp calculate_down_impact(organization_id, down_devices, _down_count) do
impacts =
Enum.map(down_devices, fn device ->
ImpactAnalysis.analyze_device_impact(organization_id, device.id)
end)
subs = impacts |> Enum.map(& &1.total_subscribers) |> Enum.sum()
mrr = impacts |> Enum.map(& &1.total_mrr) |> Enum.reduce(Decimal.new("0"), &Decimal.add/2)
{subs, mrr}
end
end