towerops/lib/towerops/dashboard.ex
Graham McIntie 9e7ee5099d refactor: fix all credo strict issues, format all code, fix broken routes
- Fix broken route paths in dashboard (/discovery, /subscribers/trace, /config-changes)
- Fix insights empty state settings link (org-scoped route)
- Add underscores to all 86400 literals across 6 files
- Alphabetize aliases in search.ex and agent_channel.ex
- Extract changelog parser helper to reduce nesting
- Extract dashboard impact calculation to reduce nesting
- Refactor agent_channel config change detection (pattern match, extract function)
- Combine double Enum.reject into single call in trace.ex
- Fix line length in trace.ex search query
- Replace length/1 > 0 with != [] in trace_live
- Run mix format on all files
2026-02-13 19:34:40 -06:00

272 lines
8.8 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.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 = Gaiia.get_org_subscriber_totals(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 Gaiia data."
def get_org_subscriber_summary(organization_id) do
totals = Gaiia.get_org_subscriber_totals(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