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.
This commit is contained in:
Graham McIntire 2026-02-12 11:01:56 -06:00
parent 19ceae0b4e
commit 1318b1b6b7
No known key found for this signature in database
10 changed files with 78 additions and 17 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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