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:
parent
138151ade6
commit
77cf289058
5 changed files with 462 additions and 115 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
154
test/towerops_web/live/device_live/index_test.exs
Normal file
154
test/towerops_web/live/device_live/index_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue