From 1318b1b6b791b93e49a4cb421900275758d85aa6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 11:01:56 -0600 Subject: [PATCH] feat: live device timestamp updates and org-scoped alert PubSub Always broadcast from update_device_status/2 so the device index LiveView refreshes even when status hasn't changed. Add a 15-second tick timer to keep "Last Checked" timestamps fresh between polls. Scope alert PubSub topics to organization for defense-in-depth. --- lib/towerops/devices.ex | 2 ++ lib/towerops/workers/device_monitor_worker.ex | 4 +-- lib/towerops_web/channels/agent_channel.ex | 4 +-- lib/towerops_web/live/alert_live/index.ex | 4 +-- lib/towerops_web/live/dashboard_live.ex | 4 +-- lib/towerops_web/live/device_live/index.ex | 21 +++++++++++ test/towerops/devices_test.exs | 6 ++-- test/towerops_web/live/alert_live_test.exs | 10 +++--- .../towerops_web/live/dashboard_live_test.exs | 4 +-- .../live/device_live/index_test.exs | 36 +++++++++++++++++++ 10 files changed, 78 insertions(+), 17 deletions(-) diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index 6139f153..55578d68 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -902,6 +902,8 @@ defmodule Towerops.Devices do {:ok, updated_device} = result -> if status_changed do broadcast_device_change(updated_device.organization_id, :device_status_changed) + else + broadcast_device_change(updated_device.organization_id, :device_updated) end result diff --git a/lib/towerops/workers/device_monitor_worker.ex b/lib/towerops/workers/device_monitor_worker.ex index 8764ba6c..754c8735 100644 --- a/lib/towerops/workers/device_monitor_worker.ex +++ b/lib/towerops/workers/device_monitor_worker.ex @@ -213,7 +213,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do _ = Phoenix.PubSub.broadcast( Towerops.PubSub, - "alerts:new", + "alerts:org:#{device.organization_id}:new", {:new_alert, device.id, :device_down} ) end @@ -242,7 +242,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do _ = Phoenix.PubSub.broadcast( Towerops.PubSub, - "alerts:resolved", + "alerts:org:#{device.organization_id}:resolved", {:alert_resolved, device.id, :device_down} ) end diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index f0339e16..a4548259 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -1265,7 +1265,7 @@ defmodule ToweropsWeb.AgentChannel do Phoenix.PubSub.broadcast( Towerops.PubSub, - "alerts:new", + "alerts:org:#{device.organization_id}:new", {:new_alert, device.id, :device_down} ) end @@ -1293,7 +1293,7 @@ defmodule ToweropsWeb.AgentChannel do Phoenix.PubSub.broadcast( Towerops.PubSub, - "alerts:resolved", + "alerts:org:#{device.organization_id}:resolved", {:alert_resolved, device.id, :device_down} ) end diff --git a/lib/towerops_web/live/alert_live/index.ex b/lib/towerops_web/live/alert_live/index.ex index 6382e150..10b46bbb 100644 --- a/lib/towerops_web/live/alert_live/index.ex +++ b/lib/towerops_web/live/alert_live/index.ex @@ -12,8 +12,8 @@ defmodule ToweropsWeb.AlertLive.Index do # Subscribe to alert events _ = if connected?(socket) do - _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:new") - Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:resolved") + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:new") + Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:resolved") end {:ok, diff --git a/lib/towerops_web/live/dashboard_live.ex b/lib/towerops_web/live/dashboard_live.ex index 8a64c6f6..9fdf3e66 100644 --- a/lib/towerops_web/live/dashboard_live.ex +++ b/lib/towerops_web/live/dashboard_live.ex @@ -13,8 +13,8 @@ defmodule ToweropsWeb.DashboardLive do # Subscribe to real-time alert updates _ = if connected?(socket) do - _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:new") - Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:resolved") + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:new") + Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:resolved") end {:ok, diff --git a/lib/towerops_web/live/device_live/index.ex b/lib/towerops_web/live/device_live/index.ex index 567f6491..e3faa229 100644 --- a/lib/towerops_web/live/device_live/index.ex +++ b/lib/towerops_web/live/device_live/index.ex @@ -17,6 +17,7 @@ defmodule ToweropsWeb.DeviceLive.Index do if connected?(socket) do Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}") + schedule_tick() end # Load device quota @@ -211,6 +212,18 @@ defmodule ToweropsWeb.DeviceLive.Index do end end + # Periodic tick to refresh "time ago" timestamps between poll events + @impl true + def handle_info(:tick, socket) do + schedule_tick() + + if socket.assigns.active_tab == "existing" do + {:noreply, schedule_debounced_reload(socket, :tick)} + else + {:noreply, socket} + end + end + # Structural changes (create/delete) need full reload with quota recalculation @impl true def handle_info({event, _org_id}, socket) when event in [:device_created, :device_deleted] do @@ -229,6 +242,14 @@ defmodule ToweropsWeb.DeviceLive.Index do {:noreply, reload_device_stream(socket)} end + @tick_interval_ms if Mix.env() == :test, do: :infinity, else: to_timeout(second: 15) + + defp schedule_tick do + if @tick_interval_ms != :infinity do + Process.send_after(self(), :tick, @tick_interval_ms) + end + end + @debounce_ms if Mix.env() == :test, do: 0, else: 100 defp schedule_debounced_reload(socket, event) do diff --git a/test/towerops/devices_test.exs b/test/towerops/devices_test.exs index 878fd698..5025df89 100644 --- a/test/towerops/devices_test.exs +++ b/test/towerops/devices_test.exs @@ -1729,7 +1729,7 @@ defmodule Towerops.EquipmentTest do assert org_id == organization.id end - test "update_device_status/2 does not broadcast when status unchanged", %{ + test "update_device_status/2 broadcasts :device_updated when status unchanged", %{ organization: organization, site: site } do @@ -1746,10 +1746,12 @@ defmodule Towerops.EquipmentTest do Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}") - # Same status — should NOT broadcast + # Same status — should broadcast :device_updated (not :device_status_changed) {:ok, _updated} = Devices.update_device_status(device, :up) refute_receive {:device_status_changed, _} + assert_receive {:device_updated, org_id} + assert org_id == organization.id end end diff --git a/test/towerops_web/live/alert_live_test.exs b/test/towerops_web/live/alert_live_test.exs index e9225d1a..de6c3c75 100644 --- a/test/towerops_web/live/alert_live_test.exs +++ b/test/towerops_web/live/alert_live_test.exs @@ -123,15 +123,15 @@ defmodule ToweropsWeb.AlertLive.IndexTest do test "updates in real-time when new alert is created", %{ conn: conn, - organization: _organization, + organization: organization, device: device } do {:ok, view, _html} = live(conn, ~p"/alerts") - # Broadcast new alert event + # Broadcast new alert event on org-scoped topic Phoenix.PubSub.broadcast( Towerops.PubSub, - "alerts:new", + "alerts:org:#{organization.id}:new", {:new_alert, device.id, :device_down} ) @@ -141,7 +141,7 @@ defmodule ToweropsWeb.AlertLive.IndexTest do test "updates in real-time when alert is resolved", %{ conn: conn, - organization: _organization, + organization: organization, device: device } do {:ok, alert} = @@ -159,7 +159,7 @@ defmodule ToweropsWeb.AlertLive.IndexTest do Phoenix.PubSub.broadcast( Towerops.PubSub, - "alerts:resolved", + "alerts:org:#{organization.id}:resolved", {:alert_resolved, device.id, :device_down} ) diff --git a/test/towerops_web/live/dashboard_live_test.exs b/test/towerops_web/live/dashboard_live_test.exs index 689011d3..281e01d8 100644 --- a/test/towerops_web/live/dashboard_live_test.exs +++ b/test/towerops_web/live/dashboard_live_test.exs @@ -118,10 +118,10 @@ defmodule ToweropsWeb.DashboardLiveTest do {:ok, view, _html} = live(conn, ~p"/dashboard") - # Trigger a new alert via PubSub + # Trigger a new alert via org-scoped PubSub Phoenix.PubSub.broadcast( Towerops.PubSub, - "alerts:new", + "alerts:org:#{organization.id}:new", {:new_alert, device.id, :device_down} ) diff --git a/test/towerops_web/live/device_live/index_test.exs b/test/towerops_web/live/device_live/index_test.exs index dd45318b..1f0eee15 100644 --- a/test/towerops_web/live/device_live/index_test.exs +++ b/test/towerops_web/live/device_live/index_test.exs @@ -152,6 +152,42 @@ defmodule ToweropsWeb.DeviceLive.IndexTest do end end + describe "tick timer" do + test "tick refreshes device stream on existing tab", %{conn: conn, site: site, organization: organization} do + {:ok, device} = + Devices.create_device(%{ + name: "Tick Router", + ip_address: "192.168.1.1", + site_id: site.id, + organization_id: organization.id + }) + + {:ok, view, _html} = live(conn, ~p"/devices") + + # Update device status directly in DB (no broadcast) + {:ok, _} = + device + |> Ecto.Changeset.change(%{status: :up}) + |> Towerops.Repo.update() + + # Send tick to trigger reload + send(view.pid, :tick) + + html = render(view) + assert html =~ "UP" + end + + test "tick does not reload on discovered tab", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/devices?tab=discovered") + + # Send tick - should not crash or error + send(view.pid, :tick) + + html = render(view) + assert html =~ "Discovered" + end + end + describe "discovered tab" do test "renders discovered devices tab", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/devices?tab=discovered")