Device list bug fix

This commit is contained in:
Graham McIntire 2026-02-04 16:49:17 -06:00
parent 4f936d5549
commit 89911bae9c
No known key found for this signature in database
4 changed files with 227 additions and 215 deletions

View file

@ -88,37 +88,11 @@ defmodule ToweropsWeb.Api.V1.DevicesController do
organization_id = conn.assigns.current_organization_id
current_user = conn.assigns[:current_user]
# Default organization_id to authenticated org if not provided or empty
device_params =
case Map.get(device_params, "organization_id") do
nil -> Map.put(device_params, "organization_id", organization_id)
"" -> Map.put(device_params, "organization_id", organization_id)
_provided_id -> device_params
end
device_params = default_organization_id(device_params, organization_id)
# Verify site belongs to organization if site_id is provided
case verify_site_access(device_params["site_id"], organization_id) do
:ok ->
# Superusers bypass device quota limits
opts = if current_user && current_user.is_superuser, do: [bypass_limits: true], else: []
case Devices.create_device(device_params, opts) do
{:ok, device} ->
# Trigger SNMP discovery if enabled
_ =
if device.snmp_enabled do
DiscoveryWorker.enqueue(device.id)
end
conn
|> put_status(:created)
|> json(format_device(device))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
create_and_discover_device(conn, device_params, current_user)
{:error, reason} ->
conn
@ -265,6 +239,38 @@ defmodule ToweropsWeb.Api.V1.DevicesController do
# Private helpers
defp default_organization_id(device_params, organization_id) do
case Map.get(device_params, "organization_id") do
nil -> Map.put(device_params, "organization_id", organization_id)
"" -> Map.put(device_params, "organization_id", organization_id)
_provided_id -> device_params
end
end
defp create_and_discover_device(conn, device_params, current_user) do
opts = if current_user && current_user.is_superuser, do: [bypass_limits: true], else: []
case Devices.create_device(device_params, opts) do
{:ok, device} ->
maybe_enqueue_discovery(device)
conn
|> put_status(:created)
|> json(format_device(device))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
end
defp maybe_enqueue_discovery(device) do
if device.snmp_enabled do
DiscoveryWorker.enqueue(device.id)
end
end
defp verify_site_access(nil, _organization_id), do: :ok
defp verify_site_access(site_id, organization_id) do

View file

@ -25,6 +25,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:device, device)
|> assign(:grouped_devices, grouped_devices)
|> assign(:sites_enabled, organization.use_sites)
|> assign(:has_sites, sites != [])
|> assign(:reorder_mode, false)
|> assign(:device_quota, %{current: current, limit: limit})}

View file

@ -79,7 +79,7 @@
<% end %>
<.button
:if={@has_sites}
:if={!@sites_enabled || @has_sites}
navigate={~p"/devices/new"}
variant="primary"
>
@ -137,227 +137,229 @@
<%= case @active_tab do %>
<% "existing" -> %>
<%= if !@has_sites do %>
<%= if @sites_enabled && !@has_sites && @grouped_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">
<.icon name="hero-information-circle" class="h-5 w-5 text-blue-400" />
</div>
<div class="ml-3 flex-1">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200">
Organize your devices with sites
</h3>
<div class="mt-2 text-sm text-blue-700 dark:text-blue-300">
<p>
Sites help you organize devices by physical location. Create a site to assign your devices and keep things organized.
</p>
</div>
<div class="mt-4">
<.button navigate={~p"/sites/new"}>
<.icon name="hero-plus" class="h-4 w-4" /> Create Your First Site
</.button>
</div>
</div>
</div>
</div>
<% end %>
<%= if @grouped_devices == [] do %>
<div class="text-center py-16">
<.icon
name="hero-building-office"
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">
Create a site first
</h3>
<.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>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Before adding device, you need to create at least one site location.
</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Sites help you organize your devices by physical location.
Get started by adding your first device.
</p>
<div class="mt-6">
<.button navigate={~p"/sites/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> Create Your First Site
<.button navigate={~p"/devices/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> New Device
</.button>
</div>
</div>
<% else %>
<%= if @grouped_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>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Get started by adding your first device.
</p>
<div class="mt-6">
<.button navigate={~p"/devices/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> New Device
</.button>
</div>
</div>
<% else %>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="relative min-w-full" phx-hook="DeviceListReorder" id="device-list">
<thead class="bg-white dark:bg-gray-900">
<tr>
<th
:if={@reorder_mode}
scope="col"
class="py-3.5 pl-4 pr-2 w-8 sm:pl-3"
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="relative min-w-full" phx-hook="DeviceListReorder" id="device-list">
<thead class="bg-white dark:bg-gray-900">
<tr>
<th
:if={@reorder_mode}
scope="col"
class="py-3.5 pl-4 pr-2 w-8 sm:pl-3"
>
<span class="sr-only">Drag handle</span>
</th>
<th
scope="col"
class={[
"py-3.5 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3 dark:text-white",
@reorder_mode && "pl-2",
!@reorder_mode && "pl-4"
]}
>
Name
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
IP Address
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
Status
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
Last Checked
</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"
>
<span class="sr-only">Drag handle</span>
</th>
<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="col"
scope="colgroup"
colspan="4"
class={[
"py-3.5 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3 dark:text-white",
@reorder_mode && "pl-2",
!@reorder_mode && "pl-4"
"bg-gray-50 py-2 pr-3 text-left dark:bg-gray-800/50",
!@reorder_mode && "pl-4 sm:pl-3"
]}
>
Name
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
IP Address
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
Status
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
Last Checked
<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>
</thead>
<tbody class="bg-white dark:bg-gray-900">
<%= for {{site, devices, stats}, index} <- Enum.with_index(@grouped_devices) do %>
<%= for {device, device_index} <- Enum.with_index(devices) do %>
<tr
class={[
"border-t site-header",
if(index == 0, do: "border-gray-200", else: "border-gray-200"),
"dark:border-white/10"
"border-t device-row hover:bg-gray-50 dark:hover:bg-gray-800/50",
if(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-site-position={index + 1}
draggable={if @reorder_mode && site, do: "true", else: "false"}
data-device-position={device_index + 1}
draggable={if @reorder_mode, 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"
>
<td :if={@reorder_mode} class="py-4 pl-4 pr-2 sm:pl-3">
<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"
title="Drag to reorder device"
>
<.icon name="hero-bars-3" class="h-5 w-5" />
</button>
</td>
<th
scope="colgroup"
colspan="4"
<td
phx-click={JS.navigate(~p"/devices/#{device.id}")}
class={[
"bg-gray-50 py-2 pr-3 text-left dark:bg-gray-800/50",
"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"
]}
>
<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">
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 %>
<tr
class={[
"border-t device-row hover:bg-gray-50 dark:hover:bg-gray-800/50",
if(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}
draggable={if @reorder_mode, do: "true", else: "false"}
{device.name}
</td>
<td
phx-click={JS.navigate(~p"/devices/#{device.id}")}
class="px-3 py-4 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400 font-mono cursor-pointer"
>
<td :if={@reorder_mode} class="py-4 pl-4 pr-2 sm:pl-3">
<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 device"
>
<.icon name="hero-bars-3" class="h-5 w-5" />
</button>
</td>
<td
phx-click={JS.navigate(~p"/devices/#{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}
</td>
<td
phx-click={JS.navigate(~p"/devices/#{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}
</td>
<td
phx-click={JS.navigate(~p"/devices/#{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 &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
device.status == :down &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
device.status == :unknown &&
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
]}>
{device.status |> to_string() |> String.upcase()}
</span>
</td>
<td
phx-click={JS.navigate(~p"/devices/#{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} />
</td>
</tr>
<% end %>
{device.ip_address}
</td>
<td
phx-click={JS.navigate(~p"/devices/#{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 &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
device.status == :down &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
device.status == :unknown &&
"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200"
]}>
{device.status |> to_string() |> String.upcase()}
</span>
</td>
<td
phx-click={JS.navigate(~p"/devices/#{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} />
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
<% end %>
<% "discovered" -> %>
<%= if @discovered_devices == [] do %>

View file

@ -6,6 +6,9 @@ Devices Tested & Working
2026-02-04
* Feature: SNMP v3 support
* Sites are now optional and not enabled by default
* Ability to set an org as default
* Poller: snmp v3 support
* Poller: ensure credentials do not remain in memory
2026-02-03
* More tests