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 @impl true 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 @impl true 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 @impl true 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 @impl true 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 @impl true def handle_event("select_none", _params, socket) do {:noreply, assign(socket, :selected_alerts, MapSet.new())} end @impl true def handle_event("bulk_acknowledge", _params, socket) do organization = socket.assigns.current_scope.organization user_id = socket.assigns.current_scope.user.id alert_ids = MapSet.to_list(socket.assigns.selected_alerts) count = Alerts.bulk_acknowledge_alerts(alert_ids, organization.id, user_id) {:noreply, socket |> assign(:selected_alerts, MapSet.new()) |> put_flash(:info, "#{count} alert(s) acknowledged") |> load_alerts(organization.id)} end @impl true def handle_event("bulk_resolve", _params, socket) do organization = socket.assigns.current_scope.organization alert_ids = MapSet.to_list(socket.assigns.selected_alerts) count = Alerts.bulk_resolve_alerts(alert_ids, organization.id) {:noreply, socket |> assign(:selected_alerts, MapSet.new()) |> put_flash(:info, "#{count} alert(s) resolved") |> load_alerts(organization.id)} end @impl true 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 @impl true def handle_info(_msg, socket), do: {:noreply, socket} defp load_alerts(socket, organization_id) do filter = socket.assigns.filter filtered_alerts = Alerts.list_organization_alerts(organization_id, %{"limit" => 500, "status" => 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) counts = %{ all: Alerts.count_organization_alerts(organization_id), critical: Alerts.count_organization_alerts(organization_id, "critical"), unresolved: Alerts.count_organization_alerts(organization_id, "unresolved"), resolved: Alerts.count_organization_alerts(organization_id, "resolved") } socket |> assign(:alerts, sorted_alerts) |> assign(:site_subscribers, site_subscribers) |> assign(:grouped_alerts, grouped) |> assign(:counts, counts) end @doc false def filter_alerts(alerts, "all"), do: alerts def filter_alerts(alerts, "critical"), do: Enum.filter(alerts, &(&1.alert_type == "device_down" and is_nil(&1.resolved_at))) def filter_alerts(alerts, "unresolved"), do: Enum.filter(alerts, &is_nil(&1.resolved_at)) def filter_alerts(alerts, "resolved"), do: Enum.filter(alerts, &(not is_nil(&1.resolved_at))) def filter_alerts(alerts, _), do: alerts @doc false def sort_alerts(alerts, "age", _site_subs) do Enum.sort_by(alerts, & &1.triggered_at, {:asc, DateTime}) end def 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 def sort_alerts(alerts, _severity, _site_subs) do Enum.sort_by(alerts, &{severity_weight(&1), &1.triggered_at}) end @doc false def severity_weight(%{alert_type: "device_down", resolved_at: nil}), do: 0 def severity_weight(%{alert_type: "device_down"}), do: 1 def severity_weight(_alert), do: 2 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 @doc false def get_site_subscriber_count(%{account_count: count}) when is_integer(count), do: count def get_site_subscriber_count(_), do: 0 @doc false def get_subscriber_count(alert, site_subs) do 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(%{resolved_at: ra}) when not is_nil(ra), do: "gray" def severity_color(%{alert_type: "device_down", triggered_at: triggered_at}) do DateTime.utc_now() |> DateTime.diff(triggered_at, :minute) |> age_severity_color() end def severity_color(_alert), do: "green" @doc false def age_severity_color(age_minutes) when age_minutes > 60, do: "red" def age_severity_color(age_minutes) when age_minutes > 15, do: "orange" def age_severity_color(_age_minutes), do: "yellow" @doc false def age_text(alert) do DateTime.utc_now() |> DateTime.diff(alert.triggered_at, :minute) |> format_age_minutes() end @doc false def format_age_minutes(minutes) when minutes < 1, do: "just now" def format_age_minutes(minutes) when minutes < 60, do: "#{minutes}m ago" def format_age_minutes(minutes) when minutes < 1440, do: "#{div(minutes, 60)}h ago" def format_age_minutes(minutes), do: "#{div(minutes, 1440)}d ago" @doc false def 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 def format_number(number), do: to_string(number) @doc false def duration_text(alert) do end_time = alert.resolved_at || DateTime.utc_now() end_time |> DateTime.diff(alert.triggered_at, :minute) |> format_duration_minutes() end @doc false def format_duration_minutes(minutes) when minutes < 1, do: "<1m" def format_duration_minutes(minutes) when minutes < 60, do: "#{minutes}m" def format_duration_minutes(minutes) when minutes < 1440, do: "#{div(minutes, 60)}h #{rem(minutes, 60)}m" def format_duration_minutes(minutes), do: "#{div(minutes, 1440)}d #{div(rem(minutes, 1440), 60)}h" 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