diff --git a/.gitignore b/.gitignore
index 7cc9b11d..581c6c48 100644
--- a/.gitignore
+++ b/.gitignore
@@ -92,6 +92,7 @@ profiles.json
.runtime/
.claude/
.beads/
+.agents/
.logs/
.direnv/
diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex
index ad7798d8..1f21c526 100644
--- a/lib/towerops/snmp.ex
+++ b/lib/towerops/snmp.ex
@@ -1906,10 +1906,22 @@ defmodule Towerops.Snmp do
end)
|> tap(fn {_s, errors, error_samples} ->
if errors > 0 do
- Logger.warning("#{errors} ARP upsert failures for device #{device_id}",
+ samples_text =
+ case Enum.reverse(error_samples) do
+ [] ->
+ ""
+
+ samples ->
+ "\nSample errors:\n" <>
+ Enum.map_join(samples, "\n", fn sample ->
+ " - IP: #{sample.ip}, MAC: #{sample.mac}, errors: #{inspect(sample.errors)}"
+ end)
+ end
+
+ Logger.warning(
+ "#{errors} ARP upsert failures for device #{device_id}#{samples_text}",
device_id: device_id,
- error_count: errors,
- sample_errors: Enum.reverse(error_samples)
+ error_count: errors
)
end
end)
diff --git a/lib/towerops_web/live/agent_live/index.ex b/lib/towerops_web/live/agent_live/index.ex
index dac3c101..14d48cb8 100644
--- a/lib/towerops_web/live/agent_live/index.ex
+++ b/lib/towerops_web/live/agent_live/index.ex
@@ -398,12 +398,6 @@ defmodule ToweropsWeb.AgentLive.Index do
{:noreply, maybe_update_agent(socket, agent_token_id, organization_id)}
end
- # Handled by StatusTitleComponent in layout - ignore to prevent crashes
- @impl true
- def handle_info({:alert_changed, _org_id}, socket) do
- {:noreply, socket}
- end
-
@impl true
def handle_info({:agents_stale, _stale_agents}, socket) do
{:noreply, reload_agent_data(socket)}
diff --git a/lib/towerops_web/live/alert_live/index.ex b/lib/towerops_web/live/alert_live/index.ex
index 3df8c9e5..96dac9d9 100644
--- a/lib/towerops_web/live/alert_live/index.ex
+++ b/lib/towerops_web/live/alert_live/index.ex
@@ -186,11 +186,6 @@ defmodule ToweropsWeb.AlertLive.Index 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
-
defp load_alerts(socket, organization_id) do
all_alerts = Alerts.list_organization_alerts(organization_id, %{"limit" => 500})
diff --git a/lib/towerops_web/live/components/status_title_component.ex b/lib/towerops_web/live/components/status_title_component.ex
index fbd47c5a..7eb3899a 100644
--- a/lib/towerops_web/live/components/status_title_component.ex
+++ b/lib/towerops_web/live/components/status_title_component.ex
@@ -1,37 +1,9 @@
defmodule ToweropsWeb.Live.Components.StatusTitleComponent do
@moduledoc """
- LiveComponent that subscribes to organization alert changes and updates the page title emoji.
+ LiveComponent that renders the browser-title status hook target.
"""
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"""
diff --git a/lib/towerops_web/live/dashboard_live.ex b/lib/towerops_web/live/dashboard_live.ex
index dd6f5738..da200b8e 100644
--- a/lib/towerops_web/live/dashboard_live.ex
+++ b/lib/towerops_web/live/dashboard_live.ex
@@ -96,11 +96,6 @@ defmodule ToweropsWeb.DashboardLive do
{:noreply, load_dashboard_data(socket, socket.assigns.current_scope.organization.id)}
end
- @impl true
- def handle_info({:alert_changed, _org_id}, socket) do
- {:noreply, load_dashboard_data(socket, socket.assigns.current_scope.organization.id)}
- end
-
defp load_dashboard_data(socket, organization_id) do
organization = socket.assigns.current_scope.organization
summary = Dashboard.get_dashboard_summary(organization_id)
diff --git a/lib/towerops_web/live/device_live/index.ex b/lib/towerops_web/live/device_live/index.ex
index 7d133254..c1b09360 100644
--- a/lib/towerops_web/live/device_live/index.ex
+++ b/lib/towerops_web/live/device_live/index.ex
@@ -276,12 +276,6 @@ defmodule ToweropsWeb.DeviceLive.Index do
{:noreply, reload_device_stream(socket)}
end
- # Handled by StatusTitleComponent in layout - ignore to prevent crashes
- @impl true
- def handle_info({:alert_changed, _org_id}, socket) do
- {:noreply, socket}
- end
-
defp tick_interval_ms do
if Application.get_env(:towerops, :env) == :test, do: :infinity, else: to_timeout(second: 15)
end
diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex
index 90785977..0f77840b 100644
--- a/lib/towerops_web/live/device_live/show.ex
+++ b/lib/towerops_web/live/device_live/show.ex
@@ -244,12 +244,6 @@ defmodule ToweropsWeb.DeviceLive.Show do
{:noreply, assign(socket, :show_check_form, false)}
end
- # Handled by StatusTitleComponent in layout - ignore to prevent crashes
- @impl true
- def handle_info({:alert_changed, _org_id}, socket) do
- {:noreply, socket}
- end
-
# Private functions
defp maybe_subscribe_and_schedule_refresh(socket, device_id) do
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex
index e65190a2..948cd600 100644
--- a/lib/towerops_web/router.ex
+++ b/lib/towerops_web/router.ex
@@ -369,6 +369,7 @@ defmodule ToweropsWeb.Router do
{ToweropsWeb.UserAuth, :require_authenticated_user},
{ToweropsWeb.UserAuth, :require_totp_enrollment},
{ToweropsWeb.UserAuth, :load_current_organization},
+ {ToweropsWeb.UserAuth, :subscribe_to_status_updates},
{ToweropsWeb.UserAuth, :handle_policy_consent}
] do
scope "/orgs/:org_slug", ToweropsWeb do
@@ -389,6 +390,7 @@ defmodule ToweropsWeb.Router do
{ToweropsWeb.UserAuth, :require_authenticated_user},
{ToweropsWeb.UserAuth, :require_totp_enrollment},
{ToweropsWeb.UserAuth, :load_default_organization},
+ {ToweropsWeb.UserAuth, :subscribe_to_status_updates},
{ToweropsWeb.UserAuth, :handle_policy_consent}
] do
scope "/", ToweropsWeb do
diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex
index d6b3b0a3..42fd9054 100644
--- a/lib/towerops_web/user_auth.ex
+++ b/lib/towerops_web/user_auth.ex
@@ -19,6 +19,7 @@ defmodule ToweropsWeb.UserAuth do
alias Phoenix.LiveView
alias Towerops.Accounts
alias Towerops.Accounts.Scope
+ alias ToweropsWeb.Helpers.StatusHelpers
# Make the remember me cookie valid for 14 days. This should match
# the session validity setting in UserToken.
@@ -545,9 +546,10 @@ 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
- if organization && LiveView.connected?(socket) do
- Phoenix.PubSub.subscribe(Towerops.PubSub, "organization:#{organization.id}:alerts")
- end
+ socket =
+ socket
+ |> maybe_subscribe_to_status_updates(organization)
+ |> attach_status_title_hook(organization)
{:cont, socket}
end
@@ -980,4 +982,25 @@ defmodule ToweropsWeb.UserAuth do
defp determine_login_method(_conn, %{"remember_me" => _}), do: "password"
defp determine_login_method(_conn, _params), do: "password"
+
+ defp maybe_subscribe_to_status_updates(socket, organization) do
+ if organization && LiveView.connected?(socket) do
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "organization:#{organization.id}:alerts")
+ end
+
+ socket
+ end
+
+ defp attach_status_title_hook(socket, nil), do: socket
+
+ defp attach_status_title_hook(socket, organization) do
+ LiveView.attach_hook(socket, :status_title_updates, :handle_info, fn
+ {:alert_changed, org_id}, socket when org_id == organization.id ->
+ emoji = StatusHelpers.status_emoji(organization)
+ {:halt, LiveView.push_event(socket, "update_status_emoji", %{emoji: emoji})}
+
+ _message, socket ->
+ {:cont, socket}
+ end)
+ end
end
diff --git a/test/towerops_web/live/site_live_test.exs b/test/towerops_web/live/site_live_test.exs
index 480cde5a..594f2aa0 100644
--- a/test/towerops_web/live/site_live_test.exs
+++ b/test/towerops_web/live/site_live_test.exs
@@ -144,6 +144,35 @@ defmodule ToweropsWeb.SiteLiveTest do
live(conn, ~p"/sites/#{other_site.id}")
end
end
+
+ test "handles alert_changed broadcasts without crashing and pushes title update", %{
+ conn: conn,
+ organization: organization
+ } do
+ {:ok, site} =
+ Sites.create_site(%{name: "Alert Site", organization_id: organization.id})
+
+ {:ok, device} =
+ Towerops.Devices.create_device(%{
+ name: "Tower Router",
+ ip_address: "10.0.0.1",
+ site_id: site.id,
+ organization_id: organization.id
+ })
+
+ {:ok, view, _html} = live(conn, ~p"/sites/#{site.id}")
+
+ {:ok, _alert} =
+ Towerops.Alerts.create_alert(%{
+ device_id: device.id,
+ alert_type: "device_down",
+ triggered_at: DateTime.utc_now(),
+ message: "Device down"
+ })
+
+ assert_push_event(view, "update_status_emoji", %{emoji: "🔴"})
+ assert render(view) =~ "Alert Site"
+ end
end
describe "Show - Metrics Bar" do