feat: add real-time status emoji updates in page titles

Dynamically update page title emoji when device/alert status changes:
- Add PubSub broadcasts when alerts are created/resolved
- Create StatusTitleComponent LiveComponent to subscribe to alert changes
- Add StatusTitle JavaScript hook to update document.title in real-time
- Subscribe to organization:alerts channel on LiveView mount
- Push emoji update events when alert status changes

This provides immediate visual feedback in browser tabs when
critical/warning/healthy status changes without requiring page refresh.
This commit is contained in:
Graham McIntire 2026-03-06 14:49:44 -06:00
parent a2ce5817a2
commit c86ca57864
No known key found for this signature in database
5 changed files with 93 additions and 4 deletions

View file

@ -1178,10 +1178,21 @@ const GlobalSearch = {
}
}
// Status Title - Updates page title with status emoji when alerts change
const StatusTitle = {
mounted(this: any) {
this.handleEvent("update_status_emoji", ({ emoji }: { emoji: string }) => {
const currentTitle = document.title
const titleWithoutEmoji = currentTitle.replace(/^[🔴🟡🟢]\s/, "")
document.title = emoji ? `${emoji} ${titleWithoutEmoji}` : titleWithoutEmoji
})
}
}
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken, timezone: userTimezone },
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, SitesMap, LeafletMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon },
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, SitesMap, LeafletMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle },
})
// Show progress bar on live navigation and form submits

View file

@ -10,6 +10,16 @@ defmodule Towerops.Alerts do
alias Towerops.Repo
alias Towerops.Workers.AlertNotificationWorker
defp broadcast_alert_change(%Alert{organization_id: org_id}) when not is_nil(org_id) do
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"organization:#{org_id}:alerts",
{:alert_changed, org_id}
)
end
defp broadcast_alert_change(_alert), do: :ok
@doc """
Creates an alert for device status change.
@ -27,6 +37,7 @@ defmodule Towerops.Alerts do
with {:ok, alert} <- Repo.insert(Alert.changeset(%Alert{}, attrs)) do
maybe_compute_gaiia_impact(alert)
broadcast_alert_change(alert)
{:ok, alert}
end
end
@ -295,6 +306,7 @@ defmodule Towerops.Alerts do
case result do
{:ok, updated_alert} ->
AlertNotificationWorker.enqueue_resolve(updated_alert.id)
broadcast_alert_change(updated_alert)
{:ok, updated_alert}
error ->
@ -306,9 +318,19 @@ defmodule Towerops.Alerts do
Resolves an alert without notifying PagerDuty (used when PagerDuty is the source).
"""
def resolve_alert_silent(alert) do
alert
|> Alert.changeset(%{resolved_at: DateTime.truncate(DateTime.utc_now(), :second)})
|> Repo.update()
result =
alert
|> Alert.changeset(%{resolved_at: DateTime.truncate(DateTime.utc_now(), :second)})
|> Repo.update()
case result do
{:ok, updated_alert} ->
broadcast_alert_change(updated_alert)
{:ok, updated_alert}
error ->
error
end
end
@doc """

View file

@ -699,6 +699,11 @@ defmodule ToweropsWeb.Layouts do
id="global-search"
organization_id={@current_organization.id}
/>
<.live_component
module={ToweropsWeb.Live.Components.StatusTitleComponent}
id="status-title"
organization_id={@current_organization.id}
/>
<% end %>
"""
end

View file

@ -0,0 +1,41 @@
defmodule ToweropsWeb.Live.Components.StatusTitleComponent do
@moduledoc """
LiveComponent that subscribes to organization alert changes and updates the page title emoji.
"""
use ToweropsWeb, :live_component
alias ToweropsWeb.Helpers.StatusHelpers
def mount(socket) do
{:ok, socket}
end
def update(%{organization_id: org_id}, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "organization:#{org_id}:alerts")
end
{:ok, assign(socket, :organization_id, org_id)}
end
def handle_info({:alert_changed, _org_id}, socket) do
# Get the organization from the parent socket
org_id = socket.assigns.organization_id
organization = Towerops.Organizations.get_organization!(org_id)
if organization do
emoji = StatusHelpers.status_emoji(organization)
# Push event to JavaScript hook to update the browser title
{:noreply, push_event(socket, "update_status_emoji", %{emoji: emoji})}
else
{:noreply, socket}
end
end
def render(assigns) do
~H"""
<div id="status-title-watcher" phx-hook="StatusTitle" phx-target={@myself} style="display: none;">
</div>
"""
end
end

View file

@ -542,6 +542,16 @@ defmodule ToweropsWeb.UserAuth do
{:cont, socket}
end
def on_mount(:subscribe_to_status_updates, _params, _session, socket) do
organization = socket.assigns[:current_scope] && socket.assigns.current_scope.organization
if organization && LiveView.connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "organization:#{organization.id}:alerts")
end
{:cont, socket}
end
def on_mount(:load_default_organization, _params, session, socket) do
user = socket.assigns.current_scope && socket.assigns.current_scope.user