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:
parent
19ceae0b4e
commit
1318b1b6b7
10 changed files with 78 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue