From 8b403db527eab5e21fee839cd980664b0562f939 Mon Sep 17 00:00:00 2001 From: Graham McIntie Date: Fri, 13 Feb 2026 18:56:37 -0600 Subject: [PATCH] feat: smart alert triage with site grouping, severity colors, and resolve - Group alerts by site (tower/location) with collapsible sections - Show alert count badges per site, critical count highlighted - Color-code alerts by severity and age (red >1h, orange >15m, yellow new) - Add Resolve button with confirmation dialog - Show subscriber impact from Gaiia inventory at site and alert level - Filter tabs: Unresolved, Critical, All, Resolved with counts - Sort options: Severity (default), Oldest First, Subscriber Impact - Sites with most critical alerts and highest subscriber impact sort first - Use DaisyUI collapse, badge, tab, and btn components --- lib/towerops_web/live/alert_live/index.ex | 188 ++++++++- .../live/alert_live/index.html.heex | 367 +++++++++++------- 2 files changed, 408 insertions(+), 147 deletions(-) diff --git a/lib/towerops_web/live/alert_live/index.ex b/lib/towerops_web/live/alert_live/index.ex index 72f5a80c..6ac737cb 100644 --- a/lib/towerops_web/live/alert_live/index.ex +++ b/lib/towerops_web/live/alert_live/index.ex @@ -1,5 +1,10 @@ defmodule ToweropsWeb.AlertLive.Index do - @moduledoc false + @moduledoc """ + Smart Alert Triage view for WISP operators. + + Groups alerts by site, color-codes by severity/age, supports filtering + and sorting, and shows subscriber impact from Gaiia inventory. + """ use ToweropsWeb, :live_view alias Towerops.Alerts @@ -10,7 +15,6 @@ defmodule ToweropsWeb.AlertLive.Index do def mount(_params, _session, socket) do organization = socket.assigns.current_scope.organization - # Subscribe to alert events _ = if connected?(socket) do _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:new") @@ -20,17 +24,21 @@ defmodule ToweropsWeb.AlertLive.Index do {:ok, socket |> assign(:page_title, "Alerts") - |> assign(:filter, "active") + |> assign(:filter, "unresolved") + |> assign(:sort_by, "severity") + |> assign(:expanded_sites, MapSet.new()) |> load_alerts(organization.id)} end @impl true def handle_params(params, _url, socket) do - filter = Map.get(params, "filter", "active") + filter = Map.get(params, "filter", "unresolved") + sort_by = Map.get(params, "sort", socket.assigns.sort_by) {:noreply, socket |> assign(:filter, filter) + |> assign(:sort_by, sort_by) |> load_alerts(socket.assigns.current_scope.organization.id)} end @@ -51,6 +59,41 @@ defmodule ToweropsWeb.AlertLive.Index do end end + def handle_event("resolve", %{"id" => id}, socket) do + organization = socket.assigns.current_scope.organization + + case AccessControl.verify_alert_access(id, organization.id) do + {:ok, alert} -> + case Alerts.resolve_alert(alert) do + {:ok, _alert} -> + {:noreply, + socket + |> put_flash(:info, t_equipment("Alert resolved")) + |> load_alerts(organization.id)} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, t_equipment("Unable to resolve alert"))} + end + + {:error, :not_found} -> + {:noreply, put_flash(socket, :error, t_equipment("Alert not found"))} + + {:error, :unauthorized} -> + {:noreply, put_flash(socket, :error, t_equipment("You don't have access to this alert"))} + end + end + + def handle_event("toggle_site", %{"site-id" => site_id}, socket) do + expanded = socket.assigns.expanded_sites + + expanded = + if MapSet.member?(expanded, site_id), + do: MapSet.delete(expanded, site_id), + else: MapSet.put(expanded, site_id) + + {:noreply, assign(socket, :expanded_sites, expanded)} + end + defp perform_acknowledge_alert(socket, alert, user_id, organization_id) do case Alerts.acknowledge_alert(alert, user_id) do {:ok, _alert} -> @@ -75,20 +118,107 @@ defmodule ToweropsWeb.AlertLive.Index do end defp load_alerts(socket, organization_id) do - alerts = - case socket.assigns.filter do - "active" -> - Alerts.list_organization_active_alerts(organization_id) + all_alerts = Alerts.list_organization_alerts(organization_id, %{"limit" => 500}) - _ -> - Alerts.list_organization_alerts(organization_id, 100) - end + filtered_alerts = filter_alerts(all_alerts, socket.assigns.filter) + site_subscribers = load_site_subscribers(filtered_alerts) + sorted_alerts = sort_alerts(filtered_alerts, socket.assigns.sort_by, site_subscribers) + grouped = group_by_site(sorted_alerts, site_subscribers) - site_subscribers = load_site_subscribers(alerts) + # Compute counts for filter tabs + counts = %{ + all: length(all_alerts), + critical: Enum.count(all_alerts, &(&1.alert_type == :device_down and is_nil(&1.resolved_at))), + unresolved: Enum.count(all_alerts, &is_nil(&1.resolved_at)), + resolved: Enum.count(all_alerts, &(not is_nil(&1.resolved_at))) + } socket - |> assign(:alerts, alerts) + |> assign(:alerts, sorted_alerts) |> assign(:site_subscribers, site_subscribers) + |> assign(:grouped_alerts, grouped) + |> assign(:counts, counts) + end + + defp filter_alerts(alerts, "all"), do: alerts + + defp filter_alerts(alerts, "critical"), + do: Enum.filter(alerts, &(&1.alert_type == :device_down and is_nil(&1.resolved_at))) + + defp filter_alerts(alerts, "unresolved"), + do: Enum.filter(alerts, &is_nil(&1.resolved_at)) + + defp filter_alerts(alerts, "resolved"), + do: Enum.filter(alerts, &(not is_nil(&1.resolved_at))) + + defp filter_alerts(alerts, _), do: alerts + + defp sort_alerts(alerts, "age", _site_subs) do + Enum.sort_by(alerts, & &1.triggered_at, {:asc, DateTime}) + end + + defp sort_alerts(alerts, "impact", site_subs) do + Enum.sort_by(alerts, fn alert -> + sub_count = get_subscriber_count(alert, site_subs) + -sub_count + end) + end + + defp sort_alerts(alerts, _severity, _site_subs) do + # Default: severity sort — device_down unresolved first, then by age + Enum.sort_by(alerts, fn alert -> + severity_weight = + cond do + alert.alert_type == :device_down and is_nil(alert.resolved_at) -> 0 + alert.alert_type == :device_down -> 1 + true -> 2 + end + + {severity_weight, alert.triggered_at} + end) + end + + defp group_by_site(alerts, site_subscribers) do + alerts + |> Enum.group_by(fn alert -> + if alert.device.site do + {alert.device.site.id, alert.device.site.name} + else + {nil, "No Site"} + end + end) + |> Enum.map(fn {{site_id, site_name}, site_alerts} -> + sub_info = if site_id, do: site_subscribers[site_id], else: nil + critical_count = Enum.count(site_alerts, &(&1.alert_type == :device_down and is_nil(&1.resolved_at))) + + %{ + site_id: site_id, + site_name: site_name, + alerts: site_alerts, + total_count: length(site_alerts), + critical_count: critical_count, + subscriber_info: sub_info, + total_subscribers: get_site_subscriber_count(sub_info) + } + end) + |> Enum.sort_by(fn group -> {-group.critical_count, -group.total_subscribers, -group.total_count} end) + end + + defp get_site_subscriber_count(%{account_count: count}) when is_integer(count), do: count + defp get_site_subscriber_count(_), do: 0 + + defp get_subscriber_count(alert, site_subs) do + # Use gaiia_impact on alert first, fall back to site-level data + cond do + alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] -> + alert.gaiia_impact["total_subscribers"] + + alert.device.site_id && site_subs[alert.device.site_id] -> + get_site_subscriber_count(site_subs[alert.device.site_id]) + + true -> + 0 + end end defp load_site_subscribers(alerts) do @@ -99,6 +229,38 @@ defmodule ToweropsWeb.AlertLive.Index do |> Map.new(fn site_id -> {site_id, Gaiia.get_site_subscriber_summary(site_id)} end) end + @doc false + def severity_color(alert) do + cond do + alert.resolved_at -> + "gray" + + alert.alert_type == :device_down and is_nil(alert.resolved_at) -> + age_minutes = DateTime.diff(DateTime.utc_now(), alert.triggered_at, :minute) + + cond do + age_minutes > 60 -> "red" + age_minutes > 15 -> "orange" + true -> "yellow" + end + + true -> + "green" + end + end + + @doc false + def age_text(alert) do + minutes = DateTime.diff(DateTime.utc_now(), alert.triggered_at, :minute) + + cond do + minutes < 1 -> "just now" + minutes < 60 -> "#{minutes}m ago" + minutes < 1440 -> "#{div(minutes, 60)}h ago" + true -> "#{div(minutes, 1440)}d ago" + end + end + defp format_number(number) when is_integer(number) do number |> Integer.to_string() diff --git a/lib/towerops_web/live/alert_live/index.html.heex b/lib/towerops_web/live/alert_live/index.html.heex index 264e19bc..4f1c2408 100644 --- a/lib/towerops_web/live/alert_live/index.html.heex +++ b/lib/towerops_web/live/alert_live/index.html.heex @@ -6,40 +6,64 @@ <.header> Alerts - - Experimental - + Experimental - <:subtitle>Monitor and acknowledge system alerts + <:subtitle>Triage alerts by site impact — fix the biggest problems first -
-
- <.link - patch={~p"/alerts"} - class={[ - "px-4 py-2 text-sm font-medium rounded-l-lg", - @filter == "active" && - "bg-blue-600 text-white dark:bg-blue-500", - @filter != "active" && - "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-gray-300 dark:hover:bg-gray-800" - ]} - > - Active Alerts - - <.link - patch={~p"/alerts?filter=all"} - class={[ - "px-4 py-2 text-sm font-medium rounded-r-lg border-l border-gray-200 dark:border-white/10", - @filter == "all" && - "bg-blue-600 text-white dark:bg-blue-500", - @filter != "all" && - "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-gray-300 dark:hover:bg-gray-800" - ]} - > - All Alerts - -
+ <%!-- Filter Tabs --%> +
+ <.link + patch={~p"/alerts?filter=unresolved&sort=#{@sort_by}"} + class={["tab", @filter == "unresolved" && "tab-active"]} + > + Unresolved + {@counts.unresolved} + + <.link + patch={~p"/alerts?filter=critical&sort=#{@sort_by}"} + class={["tab", @filter == "critical" && "tab-active"]} + > + Critical + {@counts.critical} + + <.link + patch={~p"/alerts?filter=all&sort=#{@sort_by}"} + class={["tab", @filter == "all" && "tab-active"]} + > + All + {@counts.all} + + <.link + patch={~p"/alerts?filter=resolved&sort=#{@sort_by}"} + class={["tab", @filter == "resolved" && "tab-active"]} + > + Resolved + {@counts.resolved} + +
+ + <%!-- Sort Controls --%> +
+ Sort: + <.link + patch={~p"/alerts?filter=#{@filter}&sort=severity"} + class={["btn btn-xs", @sort_by == "severity" && "btn-primary" || "btn-ghost"]} + > + Severity + + <.link + patch={~p"/alerts?filter=#{@filter}&sort=age"} + class={["btn btn-xs", @sort_by == "age" && "btn-primary" || "btn-ghost"]} + > + Oldest First + + <.link + patch={~p"/alerts?filter=#{@filter}&sort=impact"} + class={["btn btn-xs", @sort_by == "impact" && "btn-primary" || "btn-ghost"]} + > + Subscriber Impact +
<%= if Enum.empty?(@alerts) do %> @@ -47,133 +71,208 @@ <.icon name="hero-bell-slash" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />

No alerts

- <%= if @filter == "active" do %> - There are no active alerts for this organization. - <% else %> - No alerts have been triggered yet. + <%= case @filter do %> + <% "unresolved" -> %> + All clear — no unresolved alerts. + <% "critical" -> %> + No critical alerts. Nice work! + <% "resolved" -> %> + No resolved alerts yet. + <% _ -> %> + No alerts have been triggered yet. <% end %>

<% else %> + <%!-- Grouped by Site --%>
- <%= for alert <- @alerts do %> + <%= for group <- @grouped_alerts do %>
0 && "border-red-300 dark:border-red-800 bg-red-50/50 dark:bg-red-950/30", + group.critical_count == 0 && "border-base-300 bg-base-100" ]}> -
-
-
- - <%= case alert.alert_type do %> - <% :device_down -> %> - <.icon name="hero-exclamation-triangle" class="h-4 w-4" /> Device Down - <% :device_up -> %> - <.icon name="hero-check-circle" class="h-4 w-4" /> Device Recovered + 0} + phx-click="toggle_site" + phx-value-site-id={group.site_id || "none"} + /> + + <%!-- Site Header --%> +
+
+
+ <.icon + name={if group.critical_count > 0, do: "hero-exclamation-triangle", else: "hero-signal"} + class={[ + "h-5 w-5", + group.critical_count > 0 && "text-red-500", + group.critical_count == 0 && "text-gray-400" + ]} + /> + + <%= if group.site_id do %> + <.link + navigate={~p"/sites/#{group.site_id}"} + class="hover:text-blue-600 dark:hover:text-blue-400" + > + {group.site_name} + + <% else %> + {group.site_name} <% end %> - <%= if alert.resolved_at do %> - - Resolved - - <% end %> + <%!-- Alert count badge --%> + 0 && "badge-error", + group.critical_count == 0 && "badge-ghost" + ]}> + {group.total_count} alert{if group.total_count != 1, do: "s"} + - <%= if alert.acknowledged_at do %> - - Acknowledged + <%= if group.critical_count > 0 do %> + + {group.critical_count} down <% end %>
-

- <.link - navigate={~p"/devices/#{alert.device.id}"} - class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" - > - {alert.device.name} - -

- -

{alert.message}

- - <%= if alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] && alert.gaiia_impact["total_subscribers"] > 0 do %> -
- + <%!-- Subscriber impact for site --%> +
+ <%= if group.subscriber_info && group.total_subscribers > 0 do %> + <.icon name="hero-users" class="h-3.5 w-3.5" /> - {alert.gaiia_impact["total_subscribers"]} subscribers affected + {format_number(group.total_subscribers)} subscribers - <%= if alert.gaiia_impact["total_mrr"] && alert.gaiia_impact["total_mrr"] != "0" do %> - + <%= if group.subscriber_info.total_mrr do %> + <.icon name="hero-currency-dollar" class="h-3.5 w-3.5" /> - ${alert.gaiia_impact["total_mrr"]}/mo at risk + {format_mrr(group.subscriber_info.total_mrr)}/mo <% end %> -
- <% end %> - -
-
- Triggered: - {ToweropsWeb.TimeHelpers.format_iso8601(alert.triggered_at, @timezone)} -
- - <%= if alert.acknowledged_at do %> -
- Acknowledged: - {ToweropsWeb.TimeHelpers.format_iso8601(alert.acknowledged_at, @timezone)} - <%= if alert.acknowledged_by do %> - by {alert.acknowledged_by.email} - <% end %> -
<% end %> - - <%= if alert.resolved_at do %> -
- Resolved: - {ToweropsWeb.TimeHelpers.format_iso8601(alert.resolved_at, @timezone)} -
- <% end %> - -
- Site: - <.link - navigate={~p"/sites/#{alert.device.site.id}"} - class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" - > - {alert.device.site.name} - - <%= if @site_subscribers[alert.device.site_id] do %> - · - - {@site_subscribers[alert.device.site_id].account_count} subscribers - - <%= if @site_subscribers[alert.device.site_id].total_mrr do %> - · - - {format_mrr(@site_subscribers[alert.device.site_id].total_mrr)}/mo MRR - - <% end %> - <% end %> -
+
-
- <%= if alert.alert_type == :device_down && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %> - <.button phx-click="acknowledge" phx-value-id={alert.id} variant="primary"> - <.icon name="hero-check" class="h-4 w-4" /> Acknowledge - + <%!-- Alerts within site --%> +
+
+ <%= for alert <- group.alerts do %> + <% color = severity_color(alert) %> +
+
+
+ <%!-- Alert type badge --%> + + <%= case alert.alert_type do %> + <% :device_down -> %> + <.icon name="hero-exclamation-triangle" class="h-3.5 w-3.5" /> Down + <% :device_up -> %> + <.icon name="hero-check-circle" class="h-3.5 w-3.5" /> Recovered + <% end %> + + + <%!-- Age badge --%> + + {age_text(alert)} + + + <%!-- Status badges --%> + <%= if alert.resolved_at do %> + Resolved + <% end %> + <%= if alert.acknowledged_at && is_nil(alert.resolved_at) do %> + Ack'd + <% end %> + + <%!-- Per-alert subscriber impact --%> + <%= if alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] && alert.gaiia_impact["total_subscribers"] > 0 do %> + + <.icon name="hero-users" class="h-3 w-3" /> + {alert.gaiia_impact["total_subscribers"]} affected + + <%= if alert.gaiia_impact["total_mrr"] && alert.gaiia_impact["total_mrr"] != "0" do %> + + ${alert.gaiia_impact["total_mrr"]}/mo + + <% end %> + <% end %> +
+ + <%!-- Device name + message --%> +
+ <.link + navigate={~p"/devices/#{alert.device.id}"} + class="font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" + > + {alert.device.name} + + {alert.device.ip_address} +
+ <%= if alert.message do %> +

{alert.message}

+ <% end %> + + <%!-- Timestamps --%> +
+ + Triggered: {ToweropsWeb.TimeHelpers.format_iso8601(alert.triggered_at, @timezone)} + + <%= if alert.acknowledged_at do %> + + Ack'd: {ToweropsWeb.TimeHelpers.format_iso8601(alert.acknowledged_at, @timezone)} + <%= if alert.acknowledged_by do %> + by {alert.acknowledged_by.email} + <% end %> + + <% end %> + <%= if alert.resolved_at do %> + + Resolved: {ToweropsWeb.TimeHelpers.format_iso8601(alert.resolved_at, @timezone)} + + <% end %> +
+
+ + <%!-- Action Buttons --%> +
+ <%= if alert.alert_type == :device_down && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %> + <.button phx-click="acknowledge" phx-value-id={alert.id} variant="primary"> + <.icon name="hero-check" class="h-4 w-4" /> Ack + + <% end %> + <%= if is_nil(alert.resolved_at) do %> + <.button + phx-click="resolve" + phx-value-id={alert.id} + data-confirm="Resolve this alert?" + > + <.icon name="hero-check-circle" class="h-4 w-4" /> Resolve + + <% end %> +
+
<% end %>