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.
This commit is contained in:
Graham McIntire 2026-03-12 14:06:18 -05:00
parent ad4fde9415
commit 5d1d277697
No known key found for this signature in database
2 changed files with 37 additions and 0 deletions

View file

@ -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 %>
<span class="absolute top-0.5 right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-0.5 text-[10px] font-bold leading-none text-white ring-1 ring-white dark:ring-gray-950">
<%= if @unresolved_alert_count > 100 do %>
&nbsp;
<% else %>
{@unresolved_alert_count}
<% end %>
</span>
<% end %>
</.link>
<!-- Help icon -->

View file

@ -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