From 5d1d27769708917f5df1e8a5a484e441b68737dc Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Mar 2026 14:06:18 -0500 Subject: [PATCH] feat: add alert count badge to notification bell Shows a red badge on the bell icon with the unresolved alert count. Displays the number when <= 100, blank red dot when > 100 (Apple-style). Count is loaded at mount and updated live when alerts change via PubSub. Uses process dictionary pattern (same as cookie consent) so no template changes needed across the 41 LiveView layouts. --- lib/towerops_web/components/layouts.ex | 17 +++++++++++++++++ lib/towerops_web/user_auth.ex | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index 13b6224a..29f90c6e 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -115,6 +115,10 @@ defmodule ToweropsWeb.Layouts do default: nil, doc: "the currently active page (dashboard, sites, device, or alerts)" + attr :unresolved_alert_count, :integer, + default: 0, + doc: "number of unresolved alerts for the current organization" + slot :inner_block, required: true def authenticated(assigns) do @@ -122,6 +126,10 @@ defmodule ToweropsWeb.Layouts do requires_cookie_consent = Process.get(:requires_cookie_consent, false) assigns = Map.put(assigns, :requires_cookie_consent, requires_cookie_consent) + # Get unresolved alert count from process dictionary (set/updated in on_mount hook) + unresolved_alert_count = Process.get(:unresolved_alert_count, 0) + assigns = Map.put(assigns, :unresolved_alert_count, unresolved_alert_count) + # Extract organization and timezone from scope for template convenience current_organization = assigns[:current_scope] && assigns.current_scope.organization timezone = (assigns[:current_scope] && assigns.current_scope.timezone) || "UTC" @@ -416,6 +424,15 @@ defmodule ToweropsWeb.Layouts do title={t("Alerts")} > <.icon name="hero-bell" class="h-5 w-5" /> + <%= if @unresolved_alert_count > 0 do %> + + <%= if @unresolved_alert_count > 100 do %> +   + <% else %> + {@unresolved_alert_count} + <% end %> + + <% end %> diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex index 42fd9054..9c7fe3c2 100644 --- a/lib/towerops_web/user_auth.ex +++ b/lib/towerops_web/user_auth.ex @@ -546,10 +546,16 @@ defmodule ToweropsWeb.UserAuth do def on_mount(:subscribe_to_status_updates, _params, _session, socket) do organization = socket.assigns[:current_scope] && socket.assigns.current_scope.organization + alert_count = + if organization, do: Towerops.Alerts.count_active_alerts(organization.id), else: 0 + + Process.put(:unresolved_alert_count, alert_count) + socket = socket |> maybe_subscribe_to_status_updates(organization) |> attach_status_title_hook(organization) + |> attach_alert_count_hook(organization) {:cont, socket} end @@ -1003,4 +1009,18 @@ defmodule ToweropsWeb.UserAuth do {:cont, socket} end) end + + defp attach_alert_count_hook(socket, nil), do: socket + + defp attach_alert_count_hook(socket, organization) do + LiveView.attach_hook(socket, :alert_count_updates, :handle_info, fn + {:alert_changed, org_id}, socket when org_id == organization.id -> + count = Towerops.Alerts.count_active_alerts(organization.id) + Process.put(:unresolved_alert_count, count) + {:cont, Phoenix.Component.assign(socket, :_alert_count_tick, count)} + + _message, socket -> + {:cont, socket} + end) + end end