From a1bf5f284acc820b9abd16a3ec9ddb13ea9d376f Mon Sep 17 00:00:00 2001
From: Graham McIntie
Date: Fri, 13 Feb 2026 16:38:14 -0600
Subject: [PATCH] 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
---
.pre-commit-config.yaml | 2 +-
lib/towerops/dashboard.ex | 143 ++++++++++++++++++
lib/towerops_web/live/dashboard_live.ex | 57 +++++++
.../live/dashboard_live.html.heex | 135 ++++++++++++++++-
4 files changed, 331 insertions(+), 6 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 173daa95..87d896df 120000
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1 +1 @@
-/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json
\ No newline at end of file
+/nix/store/wvfi88hcjfw2fwbvdmcf1ahm8bfr6awr-pre-commit-config.json
\ No newline at end of file
diff --git a/lib/towerops/dashboard.ex b/lib/towerops/dashboard.ex
index 1c9df5ef..c4fb1c75 100644
--- a/lib/towerops/dashboard.ex
+++ b/lib/towerops/dashboard.ex
@@ -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)
diff --git a/lib/towerops_web/live/dashboard_live.ex b/lib/towerops_web/live/dashboard_live.ex
index db086332..5ee5c0d0 100644
--- a/lib/towerops_web/live/dashboard_live.ex
+++ b/lib/towerops_web/live/dashboard_live.ex
@@ -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
diff --git a/lib/towerops_web/live/dashboard_live.html.heex b/lib/towerops_web/live/dashboard_live.html.heex
index c8f8f5a8..0357f928 100644
--- a/lib/towerops_web/live/dashboard_live.html.heex
+++ b/lib/towerops_web/live/dashboard_live.html.heex
@@ -148,6 +148,29 @@
{format_mrr(@summary.subscribers.total_mrr)}/mo MRR
+
+ <%!-- MRR at Risk --%>
+
+
MRR at Risk
+
+ {format_mrr(@impact_summary.mrr_at_risk)}
+
+
0} class="mt-1 text-sm text-red-600 dark:text-red-400">
+ {format_number(@impact_summary.subscribers_affected)} subscribers affected
+
+
<% end %>
<%!-- Sites (if enabled) --%>
@@ -189,6 +212,73 @@
+ <%!-- Active Incidents (sorted by revenue impact) --%>
+ <%= if @active_incidents != [] do %>
+
+
+
+ Active Incidents
+
+ {length(@active_incidents)}
+
+
+
+
+
+
+
+
+
+ <.link
+ navigate={~p"/devices/#{incident.device_id}"}
+ class="hover:text-blue-600 hover:underline dark:hover:text-blue-400"
+ >
+ {incident.device_name}
+
+
+
+ at
+ <.link
+ navigate={~p"/sites/#{incident.site_id}"}
+ class="text-blue-600 hover:text-blue-700 dark:text-blue-400"
+ >
+ {incident.site_name}
+
+
+
+
+
+ <.icon name="hero-clock" class="h-3 w-3" />
+ Down {format_duration(incident.duration_seconds)}
+
+ <%= if incident.subscribers_affected > 0 do %>
+
+ <.icon name="hero-users" class="h-3 w-3" />
+ {format_number(incident.subscribers_affected)} affected
+
+ <% end %>
+ <%= if mrr_at_risk_positive?(incident.mrr_at_risk) do %>
+
+ <.icon name="hero-currency-dollar" class="h-3 w-3" />
+ {format_mrr(incident.mrr_at_risk)}/mo at risk
+
+ <% end %>
+ <%= if incident.qoe_score do %>
+
+ QoE {format_qoe(incident.qoe_score)}
+
+ <% end %>
+
+
+
+
+
+
+ <% end %>
+
<%!-- Section B+C: Two-column layout --%>
<%!-- Left: Alert Feed (3 cols) --%>
@@ -304,6 +394,12 @@
Insights
+ <.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 →
+
<%!-- Source filter pills --%>
@@ -367,7 +463,12 @@
- {insight.title}
+ <.link
+ navigate={~p"/insights?source=#{insight.source}"}
+ class="hover:text-blue-600 hover:underline dark:hover:text-blue-400"
+ >
+ {insight.title}
+
<%= if insight.description do %>
@@ -393,7 +494,7 @@
<%!-- Section D: Site Health Grid --%>
- <%= if @current_scope.organization.use_sites && @site_summaries != [] do %>
+ <%= if @current_scope.organization.use_sites && @site_impact_summaries != [] do %>
Site Health
@@ -406,11 +507,17 @@
<.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)
+ ]}
>
-
{site.name}
+
+
+
{site.name}
+
{site.device_count} devices
@@ -421,6 +528,11 @@
{site.devices_down} down
<% end %>
+ <%= if site.avg_qoe do %>
+
+ QoE {format_qoe(site.avg_qoe)}
+
+ <% end %>
<%= if site.subscribers do %>
@@ -433,6 +545,19 @@
<% end %>
<% end %>
+ <%= if site.subscribers_affected > 0 do %>
+
+
+ <.icon name="hero-exclamation-triangle" class="h-3.5 w-3.5" />
+ {format_number(site.subscribers_affected)} affected
+
+ <%= if mrr_at_risk_positive?(site.mrr_at_risk) do %>
+
+ {format_mrr(site.mrr_at_risk)}/mo at risk
+
+ <% end %>
+
+ <% end %>