Add real-time device list with PubSub and LiveView streams

Broadcast org-scoped PubSub events from Devices context mutations
(create, update, delete, status change). Convert DeviceLive.Index
from assigns to streams with flat row items. Subscribe on mount so
the device list updates automatically across browser tabs.
This commit is contained in:
Graham McIntire 2026-02-10 17:38:58 -06:00
parent 138151ade6
commit 77cf289058
No known key found for this signature in database
5 changed files with 462 additions and 115 deletions

View file

@ -539,6 +539,8 @@ defmodule Towerops.Devices do
DevicePollerWorker.start_polling(device.id)
end
broadcast_device_change(device.organization_id, :device_created)
{:ok, device}
end
end
@ -628,6 +630,7 @@ defmodule Towerops.Devices do
{:ok, updated_device} = result ->
_ = handle_monitoring_changes(updated_device, old_monitoring)
_ = handle_snmp_changes(updated_device, old_snmp, old_snmp_version, old_snmp_port)
broadcast_device_change(updated_device.organization_id, :device_updated)
result
error ->
@ -808,6 +811,8 @@ defmodule Towerops.Devices do
Deletes device.
"""
def delete_device(%DeviceSchema{} = device) do
organization_id = device.organization_id
# Stop monitoring and polling jobs before deleting
_ = DeviceMonitorWorker.stop_monitoring(device.id)
_ = DevicePollerWorker.stop_polling(device.id)
@ -824,9 +829,20 @@ defmodule Towerops.Devices do
# Delete the device (cascades to SNMP device, sensors, interfaces, etc.)
result = Repo.delete(device)
# Notify agent to stop processing jobs for this device
if agent_token_id do
Agents.broadcast_assignment_change(agent_token_id, :device_deleted)
case result do
{:ok, _} ->
# Notify agent to stop processing jobs for this device
if agent_token_id do
Agents.broadcast_assignment_change(agent_token_id, :device_deleted)
end
broadcast_device_change(organization_id, :device_deleted)
_ ->
# Notify agent even on failure (preserves original behavior)
if agent_token_id do
Agents.broadcast_assignment_change(agent_token_id, :device_deleted)
end
end
result
@ -844,6 +860,7 @@ defmodule Towerops.Devices do
"""
def update_device_status(%DeviceSchema{} = device, new_status) do
now = DateTime.truncate(DateTime.utc_now(), :second)
status_changed = device.status != new_status
changes = %{
status: new_status,
@ -851,15 +868,23 @@ defmodule Towerops.Devices do
}
changes =
if device.status == new_status do
changes
else
if status_changed do
Map.put(changes, :last_status_change_at, now)
else
changes
end
device
|> Ecto.Changeset.change(changes)
|> Repo.update()
case device |> Ecto.Changeset.change(changes) |> Repo.update() do
{:ok, updated_device} = result ->
if status_changed do
broadcast_device_change(updated_device.organization_id, :device_status_changed)
end
result
error ->
error
end
end
@doc """
@ -1022,4 +1047,12 @@ defmodule Towerops.Devices do
set: [display_order: nil, updated_at: DateTime.truncate(DateTime.utc_now(), :second)]
)
end
defp broadcast_device_change(organization_id, event) do
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"devices:org:#{organization_id}",
{event, organization_id}
)
end
end

View file

@ -12,9 +12,12 @@ defmodule ToweropsWeb.DeviceLive.Index do
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
device = Devices.list_organization_devices(organization.id)
devices = Devices.list_organization_devices(organization.id)
sites = Sites.list_organization_sites(organization.id)
grouped_devices = group_devices_by_site(device)
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
end
# Load device quota
{current, limit} = SubscriptionLimits.device_quota(organization)
@ -23,8 +26,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
socket
|> assign(:page_title, t_equipment("Devices"))
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:device, device)
|> assign(:grouped_devices, grouped_devices)
|> assign(:has_devices, devices != [])
|> stream(:device_rows, build_device_rows(devices))
|> assign(:sites_enabled, organization.use_sites)
|> assign(:has_sites, sites != [])
|> assign(:reorder_mode, false)
@ -79,7 +82,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
@impl true
def handle_event("force_rediscover_all", _params, socket) do
devices = socket.assigns.device
organization = socket.assigns.current_scope.organization
devices = Devices.list_organization_devices(organization.id)
snmp_devices = Enum.filter(devices, & &1.snmp_enabled)
if Enum.empty?(snmp_devices) do
@ -144,12 +148,11 @@ defmodule ToweropsWeb.DeviceLive.Index do
# Reload devices with alphabetical order
devices = Devices.list_organization_devices(organization_id)
grouped_devices = group_devices_by_site(devices)
{:noreply,
socket
|> assign(:device, devices)
|> assign(:grouped_devices, grouped_devices)
|> assign(:has_devices, devices != [])
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> put_flash(:info, t_equipment("Order reset to alphabetical"))}
end
@ -183,7 +186,6 @@ defmodule ToweropsWeb.DeviceLive.Index do
{:ok, _} ->
# Reload devices with updated order
devices = Devices.list_organization_devices(organization_id)
grouped_devices = group_devices_by_site(devices)
success_message =
case resource_name do
@ -193,8 +195,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
{:noreply,
socket
|> assign(:device, devices)
|> assign(:grouped_devices, grouped_devices)
|> assign(:has_devices, devices != [])
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> put_flash(:info, success_message)}
{:error, _changeset} ->
@ -208,6 +210,23 @@ defmodule ToweropsWeb.DeviceLive.Index do
end
end
@impl true
def handle_info({event, _org_id}, socket)
when event in [:device_created, :device_updated, :device_deleted, :device_status_changed] do
{:noreply, reload_devices(socket)}
end
defp reload_devices(socket) do
organization = socket.assigns.current_scope.organization
devices = Devices.list_organization_devices(organization.id)
{current, limit} = SubscriptionLimits.device_quota(organization)
socket
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> assign(:has_devices, devices != [])
|> assign(:device_quota, %{current: current, limit: limit})
end
defp enqueue_discovery(device_id) do
if Application.get_env(:towerops, :env) == :test do
_ = Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)
@ -216,8 +235,35 @@ defmodule ToweropsWeb.DeviceLive.Index do
end
end
# Group devices by site with statistics
# Sites and devices are sorted by display_order (with alphabetical fallback)
defp build_device_rows(devices) do
devices
|> group_devices_by_site()
|> Enum.flat_map(fn {site, site_devices, stats} ->
site_id = if site, do: site.id, else: "no-site"
site_row = %{
id: "site-header-#{site_id}",
type: :site_header,
site: site,
stats: stats,
devices: site_devices
}
device_rows =
Enum.with_index(site_devices, fn device, idx ->
%{
id: "device-#{device.id}",
type: :device,
device: device,
site: site,
device_index: idx
}
end)
[site_row | device_rows]
end)
end
defp group_devices_by_site(devices) do
devices
|> Enum.group_by(& &1.site)
@ -229,7 +275,6 @@ defmodule ToweropsWeb.DeviceLive.Index do
if site do
{site.display_order || 999_999, site.name}
else
# Put devices without sites first (display_order 0)
{0, ""}
end
end,

View file

@ -52,7 +52,7 @@
</div>
<div class="flex gap-3 mb-6">
<%= if @device != [] do %>
<%= if @has_devices do %>
<%= if @reorder_mode do %>
<.button
type="button"
@ -86,7 +86,7 @@
<.icon name="hero-plus" class="h-5 w-5" /> New Device
</.button>
<%= if @device != [] and not @reorder_mode do %>
<%= if @has_devices and not @reorder_mode do %>
<.button
type="button"
phx-click="force_rediscover_all"
@ -137,7 +137,7 @@
<%= case @active_tab do %>
<% "existing" -> %>
<%= if @sites_enabled && !@has_sites && @grouped_devices != [] do %>
<%= if @sites_enabled && !@has_sites && @has_devices do %>
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
@ -162,7 +162,7 @@
</div>
<% end %>
<%= if @grouped_devices == [] do %>
<%= if !@has_devices do %>
<div class="text-center py-16">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">No devices</h3>
@ -219,90 +219,95 @@
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900">
<%= for {{site, devices, stats}, index} <- Enum.with_index(@grouped_devices) do %>
<tr
class={[
"border-t site-header",
if(index == 0, do: "border-gray-200", else: "border-gray-200"),
"dark:border-white/10"
]}
data-site-id={if site, do: site.id, else: "no-site"}
data-site-position={index + 1}
draggable={if @reorder_mode && site, do: "true", else: "false"}
>
<td
:if={@reorder_mode && site}
class="py-2 pl-4 pr-2 sm:pl-3 bg-gray-50 dark:bg-gray-800/50"
>
<button
type="button"
class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Drag to reorder site"
>
<.icon name="hero-bars-3" class="h-5 w-5" />
</button>
</td>
<th
scope="colgroup"
colspan="4"
class={[
"bg-gray-50 py-2 pr-3 text-left dark:bg-gray-800/50",
!@reorder_mode && "pl-4 sm:pl-3"
]}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<%= if site do %>
<.link
navigate={~p"/sites/#{site.id}"}
class="text-sm font-semibold text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400"
>
{site.name}
</.link>
<% else %>
<span class="text-sm font-semibold text-gray-900 dark:text-white">
{if @sites_enabled, do: "Unassigned Devices", else: "All Devices"}
</span>
<% end %>
<span class="text-gray-400 dark:text-gray-600">·</span>
<span class="text-xs text-gray-600 dark:text-gray-400">
{stats.total} {if stats.total == 1, do: "device", else: "devices"}
</span>
<span
:if={stats.up > 0}
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
>
{stats.up} up
</span>
<span
:if={stats.down > 0}
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
>
{stats.down} down
</span>
</div>
<p
:if={site && site.location}
class="text-xs text-gray-500 dark:text-gray-400"
>
<.icon name="hero-map-pin" class="inline h-3 w-3" /> {site.location}
</p>
</div>
</th>
</tr>
<%= for {device, device_index} <- Enum.with_index(devices) do %>
<tbody id="device-rows" class="bg-white dark:bg-gray-900" phx-update="stream">
<%= for {dom_id, row} <- @streams.device_rows do %>
<%= if row.type == :site_header do %>
<tr
id={dom_id}
class={[
"border-t site-header border-gray-200",
"dark:border-white/10"
]}
data-site-id={if row.site, do: row.site.id, else: "no-site"}
draggable={if @reorder_mode && row.site, do: "true", else: "false"}
>
<td
:if={@reorder_mode && row.site}
class="py-2 pl-4 pr-2 sm:pl-3 bg-gray-50 dark:bg-gray-800/50"
>
<button
type="button"
class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Drag to reorder site"
>
<.icon name="hero-bars-3" class="h-5 w-5" />
</button>
</td>
<th
scope="colgroup"
colspan="4"
class={[
"bg-gray-50 py-2 pr-3 text-left dark:bg-gray-800/50",
!@reorder_mode && "pl-4 sm:pl-3"
]}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<%= if row.site do %>
<.link
navigate={~p"/sites/#{row.site.id}"}
class="text-sm font-semibold text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400"
>
{row.site.name}
</.link>
<% else %>
<span class="text-sm font-semibold text-gray-900 dark:text-white">
{if @sites_enabled,
do: "Unassigned Devices",
else: "All Devices"}
</span>
<% end %>
<span class="text-gray-400 dark:text-gray-600">·</span>
<span class="text-xs text-gray-600 dark:text-gray-400">
{row.stats.total} {if row.stats.total == 1,
do: "device",
else: "devices"}
</span>
<span
:if={row.stats.up > 0}
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
>
{row.stats.up} up
</span>
<span
:if={row.stats.down > 0}
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
>
{row.stats.down} down
</span>
</div>
<p
:if={row.site && row.site.location}
class="text-xs text-gray-500 dark:text-gray-400"
>
<.icon name="hero-map-pin" class="inline h-3 w-3" /> {row.site.location}
</p>
</div>
</th>
</tr>
<% else %>
<tr
id={dom_id}
class={[
"border-t device-row hover:bg-gray-50 dark:hover:bg-gray-800/50",
if(device_index == 0,
if(row.device_index == 0,
do: "border-gray-300 dark:border-white/15",
else: "border-gray-200 dark:border-white/10"
)
]}
data-device-id={device.id}
data-site-id={if site, do: site.id, else: "no-site"}
data-device-position={device_index + 1}
data-device-id={row.device.id}
data-site-id={if row.site, do: row.site.id, else: "no-site"}
data-device-position={row.device_index + 1}
draggable={if @reorder_mode, do: "true", else: "false"}
>
<td :if={@reorder_mode} class="py-4 pl-4 pr-2 sm:pl-3">
@ -315,42 +320,42 @@
</button>
</td>
<td
phx-click={JS.navigate(~p"/devices/#{device.id}")}
phx-click={JS.navigate(~p"/devices/#{row.device.id}")}
class={[
"py-4 pr-3 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white cursor-pointer",
@reorder_mode && "pl-2",
!@reorder_mode && "pl-4 sm:pl-3"
]}
>
{device.name}
{row.device.name}
</td>
<td
phx-click={JS.navigate(~p"/devices/#{device.id}")}
phx-click={JS.navigate(~p"/devices/#{row.device.id}")}
class="px-3 py-4 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400 font-mono cursor-pointer"
>
{device.ip_address}
{row.device.ip_address}
</td>
<td
phx-click={JS.navigate(~p"/devices/#{device.id}")}
phx-click={JS.navigate(~p"/devices/#{row.device.id}")}
class="px-3 py-4 text-sm whitespace-nowrap cursor-pointer"
>
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
device.status == :up &&
row.device.status == :up &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
device.status == :down &&
row.device.status == :down &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
device.status == :unknown &&
row.device.status == :unknown &&
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
]}>
{device.status |> to_string() |> String.upcase()}
{row.device.status |> to_string() |> String.upcase()}
</span>
</td>
<td
phx-click={JS.navigate(~p"/devices/#{device.id}")}
phx-click={JS.navigate(~p"/devices/#{row.device.id}")}
class="px-3 py-4 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400 cursor-pointer"
>
<.timestamp datetime={device.last_checked_at} timezone={@timezone} />
<.timestamp datetime={row.device.last_checked_at} timezone={@timezone} />
</td>
</tr>
<% end %>

View file

@ -1642,4 +1642,114 @@ defmodule Towerops.EquipmentTest do
assert org_ids == Enum.sort(org_ids)
end
end
describe "PubSub broadcasts" do
import Towerops.AccountsFixtures
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site, user: user}
end
test "create_device/1 broadcasts :device_created", %{organization: organization, site: site} do
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
assert_receive {:device_created, org_id}
assert org_id == organization.id
end
test "update_device/2 broadcasts :device_updated", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
{:ok, _updated} = Devices.update_device(device, %{name: "Updated Router"})
assert_receive {:device_updated, org_id}
assert org_id == organization.id
end
test "delete_device/1 broadcasts :device_deleted", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
{:ok, _deleted} = Devices.delete_device(device)
assert_receive {:device_deleted, org_id}
assert org_id == organization.id
end
test "update_device_status/2 broadcasts :device_status_changed on actual status change", %{
organization: organization,
site: site
} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
# Status changes from :unknown to :up — should broadcast
{:ok, _updated} = Devices.update_device_status(device, :up)
assert_receive {:device_status_changed, org_id}
assert org_id == organization.id
end
test "update_device_status/2 does not broadcast when status unchanged", %{
organization: organization,
site: site
} do
{:ok, device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
# Set initial status to :up
{:ok, device} = Devices.update_device_status(device, :up)
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
# Same status — should NOT broadcast
{:ok, _updated} = Devices.update_device_status(device, :up)
refute_receive {:device_status_changed, _}
end
end
end

View file

@ -0,0 +1,154 @@
defmodule ToweropsWeb.DeviceLive.IndexTest do
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Towerops.Devices
setup :register_and_log_in_user
setup %{user: user} do
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site}
end
describe "index page rendering" do
test "renders empty state when no devices", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/devices")
assert html =~ "No devices"
assert html =~ "New Device"
end
test "renders device list with devices", %{conn: conn, site: site, organization: organization} do
{:ok, _device} =
Devices.create_device(%{
name: "Test Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _view, html} = live(conn, ~p"/devices")
assert html =~ "Test Router"
assert html =~ "192.168.1.1"
end
test "renders site headers with device stats", %{conn: conn, site: site, organization: organization} do
{:ok, _device} =
Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _view, html} = live(conn, ~p"/devices")
assert html =~ "Test Site"
assert html =~ "1 device"
end
end
describe "real-time updates via PubSub" do
test "updates when a new device is created", %{conn: conn, site: site, organization: organization} do
{:ok, view, html} = live(conn, ~p"/devices")
assert html =~ "No devices"
# Create a device (triggers PubSub broadcast)
{:ok, _device} =
Devices.create_device(%{
name: "New Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
# LiveView should receive PubSub event and re-render
html = render(view)
assert html =~ "New Router"
assert html =~ "192.168.1.1"
end
test "updates when a device is modified", %{conn: conn, site: site, organization: organization} do
{:ok, device} =
Devices.create_device(%{
name: "Old Name",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, html} = live(conn, ~p"/devices")
assert html =~ "Old Name"
{:ok, _updated} = Devices.update_device(device, %{name: "New Name"})
html = render(view)
assert html =~ "New Name"
end
test "updates when a device status changes", %{conn: conn, site: site, organization: organization} do
{:ok, device} =
Devices.create_device(%{
name: "Status Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
{:ok, _updated} = Devices.update_device_status(device, :up)
html = render(view)
assert html =~ "UP"
end
test "updates when a device is deleted", %{conn: conn, site: site, organization: organization} do
{:ok, device} =
Devices.create_device(%{
name: "Delete Me",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, html} = live(conn, ~p"/devices")
assert html =~ "Delete Me"
{:ok, _deleted} = Devices.delete_device(device)
html = render(view)
refute html =~ "Delete Me"
assert html =~ "No devices"
end
test "updates device quota after changes", %{conn: conn, site: site, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/devices")
{:ok, _device} =
Devices.create_device(%{
name: "Quota Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
html = render(view)
assert html =~ "1/10 devices"
end
end
end