feat: impact dashboard with MRR at risk, active incidents, enhanced site health

- Add get_impact_summary/1: aggregates current outage subscriber/MRR impact
- Add list_active_incidents/1: down devices enriched with Gaiia/Preseem data, sorted by MRR
- Add get_site_impact_summaries/1: per-site health with QoE, affected subs, MRR at risk
- Dashboard top stats: MRR at Risk and Subscribers Affected cards
- Active Incidents section: color-coded by severity, shows duration/subs/MRR/QoE
- Site Health Grid: green/yellow/red health dots based on uptime and QoE scores
This commit is contained in:
Graham McIntire 2026-02-13 16:38:14 -06:00
parent 1dbb8926ed
commit a1bf5f284a
4 changed files with 331 additions and 6 deletions

View file

@ -1 +1 @@
/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json
/nix/store/wvfi88hcjfw2fwbvdmcf1ahm8bfr6awr-pre-commit-config.json

View file

@ -6,6 +6,8 @@ defmodule Towerops.Dashboard do
alias Towerops.Alerts
alias Towerops.Devices
alias Towerops.Gaiia
alias Towerops.Gaiia.ImpactAnalysis
alias Towerops.Preseem
alias Towerops.Preseem.Insights
alias Towerops.Sites
@ -111,6 +113,147 @@ defmodule Towerops.Dashboard do
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)
else
nil
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} =
if down_count > 0 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}
else
{0, Decimal.new("0")}
end
# 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)

View file

@ -105,6 +105,17 @@ defmodule ToweropsWeb.DashboardLive do
[]
end
# Impact data
impact_summary = Dashboard.get_impact_summary(organization_id)
active_incidents = Dashboard.list_active_incidents(organization_id)
site_impact_summaries =
if organization.use_sites do
Dashboard.get_site_impact_summaries(organization_id)
else
[]
end
socket
|> assign(:summary, summary)
|> assign(:active_alerts, Enum.take(active_alerts, 20))
@ -116,6 +127,9 @@ defmodule ToweropsWeb.DashboardLive do
|> assign(:device_unknown, Map.get(status_counts, :unknown, 0))
|> assign(:has_subscribers, has_subscribers)
|> assign(:site_summaries, site_summaries)
|> assign(:impact_summary, impact_summary)
|> assign(:active_incidents, active_incidents)
|> assign(:site_impact_summaries, site_impact_summaries)
|> load_insights(organization_id)
end
@ -174,4 +188,47 @@ defmodule ToweropsWeb.DashboardLive do
end
defp format_number(number), do: to_string(number)
defp format_duration(nil), do: "Unknown"
defp format_duration(seconds) when is_integer(seconds) do
cond do
seconds < 60 -> "#{seconds}s"
seconds < 3600 -> "#{div(seconds, 60)}m"
seconds < 86400 -> "#{div(seconds, 3600)}h #{div(rem(seconds, 3600), 60)}m"
true -> "#{div(seconds, 86400)}d #{div(rem(seconds, 86400), 3600)}h"
end
end
defp format_duration(_), do: "Unknown"
defp incident_severity_classes(mrr) do
amount = decimal_to_float(mrr)
cond do
amount >= 1000 -> "border-l-4 border-red-500 bg-red-50 dark:bg-red-950/30"
amount >= 100 -> "border-l-4 border-orange-500 bg-orange-50 dark:bg-orange-950/30"
amount > 0 -> "border-l-4 border-yellow-500 bg-yellow-50 dark:bg-yellow-950/30"
true -> "border-l-4 border-gray-300 bg-gray-50 dark:bg-gray-800/50"
end
end
defp site_health_border(:red), do: "border-red-300 bg-red-50/50 dark:border-red-800 dark:bg-red-950/20"
defp site_health_border(:yellow), do: "border-yellow-300 bg-yellow-50/50 dark:border-yellow-800 dark:bg-yellow-950/20"
defp site_health_border(_), do: "border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50"
defp site_health_dot(:red), do: "bg-red-500"
defp site_health_dot(:yellow), do: "bg-yellow-500"
defp site_health_dot(_), do: "bg-green-500"
defp decimal_to_float(%Decimal{} = d), do: Decimal.to_float(d)
defp decimal_to_float(n) when is_number(n), do: n / 1
defp decimal_to_float(_), do: 0.0
defp mrr_at_risk_positive?(%Decimal{} = d), do: Decimal.gt?(d, Decimal.new("0"))
defp mrr_at_risk_positive?(n) when is_number(n), do: n > 0
defp mrr_at_risk_positive?(_), do: false
defp format_qoe(nil), do: ""
defp format_qoe(score), do: :erlang.float_to_binary(score / 1, decimals: 1)
end

View file

@ -148,6 +148,29 @@
{format_mrr(@summary.subscribers.total_mrr)}/mo MRR
</p>
</div>
<%!-- MRR at Risk --%>
<div class={[
"rounded-lg border p-4 shadow-sm",
if(mrr_at_risk_positive?(@impact_summary.mrr_at_risk),
do: "border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950/30",
else: "border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50"
)
]}>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">MRR at Risk</h3>
<p class={[
"mt-1 text-3xl font-bold",
if(mrr_at_risk_positive?(@impact_summary.mrr_at_risk),
do: "text-red-600 dark:text-red-400",
else: "text-gray-900 dark:text-white"
)
]}>
{format_mrr(@impact_summary.mrr_at_risk)}
</p>
<p :if={@impact_summary.subscribers_affected > 0} class="mt-1 text-sm text-red-600 dark:text-red-400">
{format_number(@impact_summary.subscribers_affected)} subscribers affected
</p>
</div>
<% end %>
<%!-- Sites (if enabled) --%>
@ -189,6 +212,73 @@
</div>
</div>
<%!-- Active Incidents (sorted by revenue impact) --%>
<%= if @active_incidents != [] do %>
<div class="mt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Active Incidents
<span class="ml-2 inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400">
{length(@active_incidents)}
</span>
</h2>
</div>
<div class="space-y-3">
<div
:for={incident <- Enum.take(@active_incidents, 10)}
class={["rounded-lg border p-4 shadow-sm", incident_severity_classes(incident.mrr_at_risk)]}
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="font-semibold text-gray-900 dark:text-white">
<.link
navigate={~p"/devices/#{incident.device_id}"}
class="hover:text-blue-600 hover:underline dark:hover:text-blue-400"
>
{incident.device_name}
</.link>
</h3>
<span :if={incident.site_name} class="text-sm text-gray-500 dark:text-gray-400">
at
<.link
navigate={~p"/sites/#{incident.site_id}"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
{incident.site_name}
</.link>
</span>
</div>
<div class="mt-2 flex items-center gap-3 flex-wrap">
<span class="inline-flex items-center gap-1 rounded-md bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400">
<.icon name="hero-clock" class="h-3 w-3" />
Down {format_duration(incident.duration_seconds)}
</span>
<%= if incident.subscribers_affected > 0 do %>
<span class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-2 py-0.5 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 w-3" />
{format_number(incident.subscribers_affected)} affected
</span>
<% end %>
<%= if mrr_at_risk_positive?(incident.mrr_at_risk) do %>
<span class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-2 py-0.5 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 w-3" />
{format_mrr(incident.mrr_at_risk)}/mo at risk
</span>
<% end %>
<%= if incident.qoe_score do %>
<span class="inline-flex items-center gap-1 rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-800 ring-1 ring-inset ring-purple-600/20 dark:bg-purple-400/10 dark:text-purple-400 dark:ring-purple-400/20">
QoE {format_qoe(incident.qoe_score)}
</span>
<% end %>
</div>
</div>
</div>
</div>
</div>
</div>
<% end %>
<%!-- Section B+C: Two-column layout --%>
<div class="mt-8 grid gap-8 lg:grid-cols-5">
<%!-- Left: Alert Feed (3 cols) --%>
@ -304,6 +394,12 @@
<div class="lg:col-span-2">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Insights</h2>
<.link
navigate={~p"/insights"}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
>
View All →
</.link>
</div>
<%!-- Source filter pills --%>
@ -367,7 +463,12 @@
</span>
</div>
<h4 class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
{insight.title}
<.link
navigate={~p"/insights?source=#{insight.source}"}
class="hover:text-blue-600 hover:underline dark:hover:text-blue-400"
>
{insight.title}
</.link>
</h4>
<%= if insight.description do %>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
@ -393,7 +494,7 @@
</div>
<%!-- Section D: Site Health Grid --%>
<%= if @current_scope.organization.use_sites && @site_summaries != [] do %>
<%= if @current_scope.organization.use_sites && @site_impact_summaries != [] do %>
<div class="mt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Site Health</h2>
@ -406,11 +507,17 @@
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<.link
:for={site <- @site_summaries}
:for={site <- @site_impact_summaries}
navigate={~p"/sites/#{site.site_id}"}
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
class={[
"rounded-lg border p-4 shadow-sm transition-shadow hover:shadow-md",
site_health_border(site.site_health)
]}
>
<h3 class="font-semibold text-gray-900 dark:text-white">{site.name}</h3>
<div class="flex items-center gap-2">
<span class={["h-2.5 w-2.5 rounded-full flex-shrink-0", site_health_dot(site.site_health)]}></span>
<h3 class="font-semibold text-gray-900 dark:text-white">{site.name}</h3>
</div>
<div class="mt-2 flex items-center gap-3 text-sm">
<span class="text-gray-600 dark:text-gray-400">
{site.device_count} devices
@ -421,6 +528,11 @@
{site.devices_down} down
</span>
<% end %>
<%= if site.avg_qoe do %>
<span class="text-purple-600 dark:text-purple-400">
QoE {format_qoe(site.avg_qoe)}
</span>
<% end %>
</div>
<%= if site.subscribers do %>
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
@ -433,6 +545,19 @@
<% end %>
</div>
<% end %>
<%= if site.subscribers_affected > 0 do %>
<div class="mt-2 flex items-center gap-3 text-xs">
<span class="flex items-center gap-1 text-red-600 dark:text-red-400">
<.icon name="hero-exclamation-triangle" class="h-3.5 w-3.5" />
{format_number(site.subscribers_affected)} affected
</span>
<%= if mrr_at_risk_positive?(site.mrr_at_risk) do %>
<span class="text-red-600 dark:text-red-400">
{format_mrr(site.mrr_at_risk)}/mo at risk
</span>
<% end %>
</div>
<% end %>
</.link>
</div>
</div>