L2: Remove 2-second Process.sleep from post_startup — the try/catch already handles transient noproc errors from the TaskSupervisor. L3: Remove Process.sleep from delete_device — in-flight jobs already handle the race condition via verify_polling_assignment_unchanged. The blocking sleep was unnecessary and delayed web requests by 500ms.
378 lines
12 KiB
Elixir
378 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
|
|
|
|
@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
|