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:
parent
1dbb8926ed
commit
a1bf5f284a
4 changed files with 331 additions and 6 deletions
|
|
@ -1 +1 @@
|
|||
/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json
|
||||
/nix/store/wvfi88hcjfw2fwbvdmcf1ahm8bfr6awr-pre-commit-config.json
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue