towerops/lib/towerops_web/live/alert_live/index.ex
Graham McIntire 872f717dba Fix critical bugs, memory leaks, and N+1 queries (#132)
CRITICAL FIXES:
- Fix unsafe pattern matching in SNMP discovery that would crash on timeout
  - Changed {:ok, _} = ... to proper case statements with error handling
  - Extracted safe_discover/3 helper to reduce cyclomatic complexity
  - Added fallback to empty lists and warning logging for timeouts
  - Affects: discover_vlans, discover_ip_addresses, discover_processors, discover_storage

MEMORY LEAK FIXES (JS Hooks):
- GlobalSearchTrigger: Added destroyed() cleanup for click listener
- SidebarCollapse: Added destroyed() cleanup for click listener
- ThemeSelector: Added destroyed() cleanup for phx:set-theme window listener
- NetworkMap: Added destroyed() cleanup for 3 button listeners (zoom in/out/fit)
- All hooks now properly store handler references and remove listeners on unmount

LIVEVIEW FIXES:
- Added phx-update="ignore" to 5 SensorChart hooks to prevent chart re-initialization
- Added catch-all handle_info(_msg, socket) to 5 LiveViews to prevent crashes on unexpected messages
  - device_live/show.ex, device_live/index.ex, alert_live/index.ex, activity_feed_live.ex, agent_live/index.ex

N+1 QUERY FIXES:
- Created Gaiia.get_site_subscriber_summaries/1 batch function
- Refactored alert_live and device_live to use batch query instead of looping
- Reduces queries from N to 1 when loading site subscriber data

Impact:
- Prevents discovery worker crashes during SNMP timeouts
- Eliminates memory accumulation from leaked event handlers
- Prevents chart state loss on LiveView updates
- Prevents LiveView crashes from unexpected PubSub messages
- Improves database performance by eliminating N+1 queries in alert/device views
- Passes credo --strict with 0 issues

Reviewed-on: graham/towerops-web#132
2026-03-23 17:03:41 -05:00

382 lines
12 KiB
Elixir

defmodule ToweropsWeb.AlertLive.Index do
@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
alias Towerops.Gaiia
alias ToweropsWeb.Live.Helpers.AccessControl
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
_ =
if connected?(socket) do
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:new")
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:resolved")
end
{:ok,
socket
|> assign(:page_title, t("Alerts"))
|> assign(:filter, "unresolved")
|> assign(:sort_by, "severity")
|> assign(:expanded_sites, MapSet.new())
|> assign(:expanded_alert, nil)
|> assign(:selected_alerts, MapSet.new())
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> load_alerts(organization.id)}
end
@impl true
def handle_params(params, _url, socket) do
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
@impl true
def handle_event("acknowledge", %{"id" => id}, socket) do
organization = socket.assigns.current_scope.organization
user_id = socket.assigns.current_scope.user.id
case AccessControl.verify_alert_access(id, organization.id) do
{:ok, alert} ->
perform_acknowledge_alert(socket, alert, user_id, organization.id)
{: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("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_alert", %{"id" => id}, socket) do
expanded = if socket.assigns.expanded_alert == id, do: nil, else: id
{:noreply, assign(socket, :expanded_alert, expanded)}
end
def handle_event("toggle_select", %{"id" => id}, socket) do
selected = socket.assigns.selected_alerts
selected =
if MapSet.member?(selected, id),
do: MapSet.delete(selected, id),
else: MapSet.put(selected, id)
{:noreply, assign(socket, :selected_alerts, selected)}
end
def handle_event("select_all", _params, socket) do
all_ids = Enum.map(socket.assigns.alerts, & &1.id)
{:noreply, assign(socket, :selected_alerts, MapSet.new(all_ids))}
end
def handle_event("select_none", _params, socket) do
{:noreply, assign(socket, :selected_alerts, MapSet.new())}
end
def handle_event("bulk_acknowledge", _params, socket) do
organization = socket.assigns.current_scope.organization
user_id = socket.assigns.current_scope.user.id
count =
Enum.reduce(socket.assigns.selected_alerts, 0, fn id, acc ->
with {:ok, alert} <- AccessControl.verify_alert_access(id, organization.id),
{:ok, _} <- Alerts.acknowledge_alert(alert, user_id) do
acc + 1
else
_ -> acc
end
end)
{:noreply,
socket
|> assign(:selected_alerts, MapSet.new())
|> put_flash(:info, "#{count} alert(s) acknowledged")
|> load_alerts(organization.id)}
end
def handle_event("bulk_resolve", _params, socket) do
organization = socket.assigns.current_scope.organization
count =
Enum.reduce(socket.assigns.selected_alerts, 0, fn id, acc ->
with {:ok, alert} <- AccessControl.verify_alert_access(id, organization.id),
{:ok, _} <- Alerts.resolve_alert(alert) do
acc + 1
else
_ -> acc
end
end)
{:noreply,
socket
|> assign(:selected_alerts, MapSet.new())
|> put_flash(:info, "#{count} alert(s) resolved")
|> load_alerts(organization.id)}
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} ->
{:noreply,
socket
|> put_flash(:info, t_equipment("Alert acknowledged"))
|> load_alerts(organization_id)}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, t_equipment("Unable to acknowledge alert"))}
end
end
@impl true
def handle_info({:new_alert, _device_id, _alert_type}, socket) do
{:noreply, load_alerts(socket, socket.assigns.current_scope.organization.id)}
end
@impl true
def handle_info({:alert_resolved, _device_id, _alert_type}, socket) do
{:noreply, load_alerts(socket, socket.assigns.current_scope.organization.id)}
end
@impl true
def handle_info({:alert_changed, _org_id}, socket) do
{:noreply, load_alerts(socket, socket.assigns.current_scope.organization.id)}
end
def handle_info(_msg, socket), do: {:noreply, socket}
defp load_alerts(socket, organization_id) do
all_alerts = Alerts.list_organization_alerts(organization_id, %{"limit" => 500})
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)
# 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, 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]
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
site_ids =
alerts
|> Enum.map(& &1.device.site_id)
|> Enum.uniq()
|> Enum.reject(&is_nil/1)
Gaiia.get_site_subscriber_summaries(site_ids)
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()
|> String.graphemes()
|> Enum.reverse()
|> Enum.chunk_every(3)
|> Enum.join(",")
|> String.reverse()
end
defp format_number(number), do: to_string(number)
@doc false
def duration_text(alert) do
end_time = alert.resolved_at || DateTime.utc_now()
minutes = DateTime.diff(end_time, alert.triggered_at, :minute)
cond do
minutes < 1 -> "<1m"
minutes < 60 -> "#{minutes}m"
minutes < 1440 -> "#{div(minutes, 60)}h #{rem(minutes, 60)}m"
true -> "#{div(minutes, 1440)}d #{div(rem(minutes, 1440), 60)}h"
end
end
defp severity_badge_class(alert) do
color = severity_color(alert)
case color do
"red" -> "bg-red-500"
"orange" -> "bg-orange-500"
"yellow" -> "bg-yellow-500"
"green" -> "bg-green-500"
"gray" -> "bg-gray-300 dark:bg-gray-600"
_ -> "bg-gray-300"
end
end
defp impact_text(alert, site_subs) do
count = get_subscriber_count(alert, site_subs)
if count > 0, do: "#{format_number(count)} subs"
end
end