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.
273 lines
8.9 KiB
Elixir
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
|