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 --%> ++ {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 +
+@@ -393,7 +494,7 @@