device reorder

This commit is contained in:
Graham McIntire 2026-01-26 10:22:12 -06:00
parent e781a70c7e
commit 99414459d3
No known key found for this signature in database
12 changed files with 691 additions and 29 deletions

View file

@ -32,6 +32,7 @@ import "./passkey_settings"
import "./passkey_login"
import "./passkey_discoverable"
import type { SensorChartHook } from "./types/liveview"
import { DeviceListReorder } from "./device_list_reorder"
// Helper function to convert range string to milliseconds
function getTimeRangeMs(range: string): number {
@ -637,7 +638,7 @@ const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken, timezone: userTimezone },
hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin, CopyToClipboard, ScrollToTop, AutoDismissFlash, NetworkMap },
hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin, CopyToClipboard, ScrollToTop, AutoDismissFlash, NetworkMap, DeviceListReorder },
})
// Show progress bar on live navigation and form submits

View file

@ -0,0 +1,124 @@
/**
* Minimal hook for HTML5 drag-and-drop events.
* Only captures events and pushes to LiveView - no business logic.
*/
interface DragData {
type: "site" | "device"
id: string
siteId: string
position: number
}
export const DeviceListReorder = {
draggedElement: null as HTMLElement | null,
dragData: null as DragData | null,
mounted(this: any) {
// Capture drag start
this.el.addEventListener("dragstart", (e: DragEvent) => {
const target = (e.target as HTMLElement).closest("[data-site-id]") as HTMLElement
if (!target) return
// Check if element is actually draggable
if (target.getAttribute("draggable") !== "true") return
const siteId = target.dataset.siteId
const position = parseInt(target.dataset.sitePosition || target.dataset.devicePosition || "0")
if (target.classList.contains("site-header")) {
this.draggedElement = target
this.dragData = { type: "site", id: siteId!, siteId: siteId!, position }
target.classList.add("opacity-50")
} else if (target.classList.contains("device-row")) {
const deviceId = target.dataset.deviceId
this.draggedElement = target
this.dragData = { type: "device", id: deviceId!, siteId: siteId!, position }
target.classList.add("opacity-50")
}
e.dataTransfer!.effectAllowed = "move"
})
// Clean up on drag end
this.el.addEventListener("dragend", (_e: DragEvent) => {
if (this.draggedElement) {
this.draggedElement.classList.remove("opacity-50")
this.draggedElement = null
}
this.dragData = null
// Remove drop indicators
this.el.querySelectorAll(".border-blue-500").forEach((el: Element) => {
el.classList.remove("border-blue-500", "border-t-4", "border-b-4")
})
})
// Show drop indicator
this.el.addEventListener("dragover", (e: DragEvent) => {
e.preventDefault()
if (!this.dragData) return
const target = (e.target as HTMLElement).closest("[data-site-id], [data-device-id]") as HTMLElement
if (!target || target === this.draggedElement) return
// Check if in bottom half of element
const rect = target.getBoundingClientRect()
const middleY = rect.top + (rect.height / 2)
const isBottomHalf = e.clientY > middleY
// Visual feedback - show border on top or bottom depending on drop position
this.el.querySelectorAll(".border-blue-500").forEach((el: Element) => {
el.classList.remove("border-blue-500", "border-t-4", "border-b-4")
})
target.classList.add("border-blue-500", isBottomHalf ? "border-b-4" : "border-t-4")
})
// Handle drop
this.el.addEventListener("drop", (e: DragEvent) => {
e.preventDefault()
if (!this.dragData) return
const target = (e.target as HTMLElement).closest("[data-site-id], [data-device-id]") as HTMLElement
if (!target || target === this.draggedElement) return
const dropSiteId = target.dataset.siteId
let dropPosition = parseInt(target.dataset.sitePosition || target.dataset.devicePosition || "0")
// Check if dropping in bottom half of element - if so, insert after instead of before
const rect = target.getBoundingClientRect()
const middleY = rect.top + (rect.height / 2)
if (e.clientY > middleY) {
dropPosition += 1
}
// Push to LiveView based on type
if (this.dragData.type === "site" && target.classList.contains("site-header")) {
this.pushEvent("reorder_site", {
site_id: this.dragData.id,
new_position: dropPosition
})
} else if (this.dragData.type === "device" && target.classList.contains("device-row")) {
// Only allow within same site
if (dropSiteId === this.dragData.siteId) {
this.pushEvent("reorder_device", {
device_id: this.dragData.id,
new_position: dropPosition
})
}
}
// Clean up
this.el.querySelectorAll(".border-blue-500").forEach((el: Element) => {
el.classList.remove("border-blue-500", "border-t-4", "border-b-4")
})
})
// Prevent drag handle from triggering row click
this.el.querySelectorAll(".drag-handle").forEach((handle: Element) => {
handle.addEventListener("click", (e: Event) => {
e.stopPropagation()
})
})
}
}

View file

@ -13,13 +13,20 @@ defmodule Towerops.Devices do
@doc """
Returns the list of devices for a site.
Ordered by custom display_order (if set), then alphabetically by name.
"""
def list_site_devices(site_id) do
Repo.all(from(e in DeviceSchema, where: e.site_id == ^site_id, order_by: [asc: e.name]))
Repo.all(
from(e in DeviceSchema,
where: e.site_id == ^site_id,
order_by: [asc: e.display_order, asc: e.name]
)
)
end
@doc """
Returns the list of all devices for an organization (via sites).
Ordered by custom display_order (if set), then alphabetically by name.
Supports filtering by:
- site_id: Filter by specific site
@ -30,7 +37,7 @@ defmodule Towerops.Devices do
from(e in DeviceSchema,
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id,
order_by: [asc: e.name],
order_by: [asc: e.display_order, asc: e.name],
preload: [site: s]
)
@ -400,4 +407,57 @@ defmodule Towerops.Devices do
)
)
end
## Display Order Management
@doc """
Reorders a device to a new position within its site.
Updates display_order for all devices in the site to maintain continuous ordering (1, 2, 3, ...).
The new_position is 1-based (1 = first position).
Returns {:ok, device} on success, {:error, changeset} on failure.
"""
def reorder_device(device_id, new_position) when is_integer(new_position) and new_position > 0 do
Repo.transaction(fn ->
device = Repo.get!(DeviceSchema, device_id)
site_id = device.site_id
# Get all devices in site excluding the one being moved, in current order
other_devices =
Repo.all(
from(d in DeviceSchema,
where: d.site_id == ^site_id and d.id != ^device_id,
order_by: [asc: d.display_order, asc: d.name]
)
)
# Insert at new position (1-based index, convert to 0-based for List.insert_at)
new_order = List.insert_at(other_devices, new_position - 1, device)
# Update display_order for all devices
new_order
|> Enum.with_index(1)
|> Enum.each(fn {d, order} ->
Repo.update!(Ecto.Changeset.change(d, display_order: order))
end)
Repo.get!(DeviceSchema, device_id)
end)
end
@doc """
Reset all devices in organization to alphabetical order.
Clears display_order field for all devices, allowing them to fall back to alphabetical sorting.
"""
def reset_organization_device_order(organization_id) do
Repo.update_all(
from(d in DeviceSchema,
join: s in assoc(d, :site),
where: s.organization_id == ^organization_id
),
set: [display_order: nil, updated_at: DateTime.truncate(DateTime.utc_now(), :second)]
)
end
end

View file

@ -23,6 +23,7 @@ defmodule Towerops.Devices.Device do
field :name, :string
field :ip_address, :string
field :description, :string
field :display_order, :integer
field :status, Ecto.Enum, values: [:up, :down, :unknown], default: :unknown
field :last_checked_at, :utc_datetime
field :last_status_change_at, :utc_datetime
@ -50,6 +51,7 @@ defmodule Towerops.Devices.Device do
name: String.t() | nil,
ip_address: String.t(),
description: String.t() | nil,
display_order: integer() | nil,
status: :up | :down | :unknown,
last_checked_at: DateTime.t() | nil,
last_status_change_at: DateTime.t() | nil,
@ -76,6 +78,7 @@ defmodule Towerops.Devices.Device do
:name,
:ip_address,
:description,
:display_order,
:site_id,
:monitoring_enabled,
:check_interval_seconds,

View file

@ -12,10 +12,15 @@ defmodule Towerops.Sites do
@doc """
Returns the list of sites for an organization.
Ordered by custom display_order (if set), then alphabetically by name.
"""
def list_organization_sites(organization_id) do
Repo.all(
from(s in Site, where: s.organization_id == ^organization_id, order_by: [asc: s.name], preload: [:parent_site])
from(s in Site,
where: s.organization_id == ^organization_id,
order_by: [asc: s.display_order, asc: s.name],
preload: [:parent_site]
)
)
end
@ -31,22 +36,29 @@ defmodule Towerops.Sites do
@doc """
Returns the list of root sites (sites without a parent) for an organization.
Ordered by custom display_order (if set), then alphabetically by name.
"""
def list_root_sites(organization_id) do
Repo.all(
from(s in Site,
where: s.organization_id == ^organization_id,
where: is_nil(s.parent_site_id),
order_by: [asc: s.name]
order_by: [asc: s.display_order, asc: s.name]
)
)
end
@doc """
Returns the list of child sites for a parent site.
Ordered by custom display_order (if set), then alphabetically by name.
"""
def list_child_sites(parent_site_id) do
Repo.all(from(s in Site, where: s.parent_site_id == ^parent_site_id, order_by: [asc: s.name]))
Repo.all(
from(s in Site,
where: s.parent_site_id == ^parent_site_id,
order_by: [asc: s.display_order, asc: s.name]
)
)
end
@doc """
@ -184,4 +196,54 @@ defmodule Towerops.Sites do
Repo.delete_all(from(a in AgentAssignment, where: a.device_id in ^device_ids))
end
end
## Display Order Management
@doc """
Reorders a site to a new position within the organization.
Updates display_order for all sites to maintain continuous ordering (1, 2, 3, ...).
The new_position is 1-based (1 = first position).
Returns {:ok, site} on success, {:error, changeset} on failure.
"""
def reorder_site(site_id, new_position) when is_integer(new_position) and new_position > 0 do
Repo.transaction(fn ->
site = Repo.get!(Site, site_id)
organization_id = site.organization_id
# Get all sites excluding the one being moved, in current order
other_sites =
Repo.all(
from(s in Site,
where: s.organization_id == ^organization_id and s.id != ^site_id,
order_by: [asc: s.display_order, asc: s.name]
)
)
# Insert at new position (1-based index, convert to 0-based for List.insert_at)
new_order = List.insert_at(other_sites, new_position - 1, site)
# Update display_order for all sites
new_order
|> Enum.with_index(1)
|> Enum.each(fn {s, order} ->
Repo.update!(Ecto.Changeset.change(s, display_order: order))
end)
Repo.get!(Site, site_id)
end)
end
@doc """
Reset all sites in organization to alphabetical order.
Clears display_order field for all sites, allowing them to fall back to alphabetical sorting.
"""
def reset_site_order(organization_id) do
Repo.update_all(
from(s in Site, where: s.organization_id == ^organization_id),
set: [display_order: nil, updated_at: DateTime.truncate(DateTime.utc_now(), :second)]
)
end
end

View file

@ -20,6 +20,7 @@ defmodule Towerops.Sites.Site do
field :name, :string
field :description, :string
field :location, :string
field :display_order, :integer
# SNMP configuration (overrides organization default)
field :snmp_version, :string
@ -39,6 +40,7 @@ defmodule Towerops.Sites.Site do
name: String.t(),
description: String.t() | nil,
location: String.t() | nil,
display_order: integer() | nil,
snmp_version: String.t() | nil,
snmp_community: String.t() | nil,
organization_id: Ecto.UUID.t(),
@ -60,6 +62,7 @@ defmodule Towerops.Sites.Site do
:name,
:description,
:location,
:display_order,
:organization_id,
:agent_token_id,
:parent_site_id,

View file

@ -19,7 +19,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> assign(:page_title, "Devices")
|> assign(:device, device)
|> assign(:grouped_devices, grouped_devices)
|> assign(:has_sites, sites != [])}
|> assign(:has_sites, sites != [])
|> assign(:reorder_mode, false)}
end
@impl true
@ -45,6 +46,73 @@ defmodule ToweropsWeb.DeviceLive.Index do
end
end
@impl true
def handle_event("reorder_site", %{"site_id" => site_id, "new_position" => new_position}, socket) do
# Handle both integer and string input
position = if is_integer(new_position), do: new_position, else: String.to_integer(new_position)
case Sites.reorder_site(site_id, position) do
{:ok, _site} ->
# Reload devices with updated order
devices = Devices.list_organization_devices(socket.assigns.current_organization.id)
grouped_devices = group_devices_by_site(devices)
{:noreply,
socket
|> assign(:device, devices)
|> assign(:grouped_devices, grouped_devices)
|> put_flash(:info, "Site order updated")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to reorder site")}
end
end
@impl true
def handle_event("reorder_device", %{"device_id" => device_id, "new_position" => new_position}, socket) do
# Handle both integer and string input
position = if is_integer(new_position), do: new_position, else: String.to_integer(new_position)
case Devices.reorder_device(device_id, position) do
{:ok, _device} ->
# Reload devices with updated order
devices = Devices.list_organization_devices(socket.assigns.current_organization.id)
grouped_devices = group_devices_by_site(devices)
{:noreply,
socket
|> assign(:device, devices)
|> assign(:grouped_devices, grouped_devices)
|> put_flash(:info, "Device order updated")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to reorder device")}
end
end
@impl true
def handle_event("toggle_reorder_mode", _params, socket) do
{:noreply, assign(socket, :reorder_mode, !socket.assigns.reorder_mode)}
end
@impl true
def handle_event("reset_order", _params, socket) do
organization_id = socket.assigns.current_organization.id
Sites.reset_site_order(organization_id)
Devices.reset_organization_device_order(organization_id)
# 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)
|> put_flash(:info, "Order reset to alphabetical")}
end
def handle_event("add_discovered_device", params, socket) do
identifier = Jason.decode!(params["identifier"])
discovered = Enum.find(socket.assigns.discovered_devices, &(&1.identifier == identifier))
@ -67,13 +135,19 @@ defmodule ToweropsWeb.DeviceLive.Index do
end
# Group devices by site with statistics
# Sites and devices are sorted by display_order (with alphabetical fallback)
defp group_devices_by_site(devices) do
devices
|> Enum.group_by(& &1.site)
|> Enum.map(fn {site, site_devices} ->
{site, site_devices, calculate_site_stats(site_devices)}
end)
|> Enum.sort_by(fn {site, _devices, _stats} -> site.name end, :asc)
|> Enum.sort_by(
fn {site, _devices, _stats} ->
{site.display_order || 999_999, site.name}
end,
:asc
)
end
defp calculate_site_stats(devices) do

View file

@ -10,13 +10,36 @@
<:subtitle>Monitor and manage all your network devices</:subtitle>
<:actions>
<%= if @device != [] do %>
<.button
type="button"
phx-click="force_rediscover_all"
data-confirm="This will trigger SNMP discovery for all SNMP-enabled devices. Continue?"
>
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> Force Rediscover All
</.button>
<%= if @reorder_mode do %>
<.button
type="button"
phx-click="reset_order"
data-confirm="Reset all sites and devices to alphabetical order?"
>
<.icon name="hero-arrow-path" class="h-4 w-4" /> Reset Order
</.button>
<.button
type="button"
phx-click="toggle_reorder_mode"
variant="primary"
>
<.icon name="hero-check" class="h-4 w-4" /> Done
</.button>
<% else %>
<.button
type="button"
phx-click="toggle_reorder_mode"
>
<.icon name="hero-bars-3" class="h-4 w-4" /> Reorder
</.button>
<.button
type="button"
phx-click="force_rediscover_all"
data-confirm="This will trigger SNMP discovery for all SNMP-enabled devices. Continue?"
>
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> Force Rediscover All
</.button>
<% end %>
<% end %>
<.button
:if={@has_sites}
@ -66,12 +89,23 @@
<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">
<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 pr-3 pl-4 text-left text-sm font-semibold text-gray-900 sm:pl-3 dark:text-white"
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>
@ -97,15 +131,35 @@
</thead>
<tbody class="bg-white dark:bg-gray-900">
<%= for {{site, devices, stats}, index} <- Enum.with_index(@grouped_devices) do %>
<tr class={[
"border-t",
if(index == 0, do: "border-gray-200", else: "border-gray-200"),
"dark:border-white/10"
]}>
<tr
class={[
"border-t site-header",
if(index == 0, do: "border-gray-200", else: "border-gray-200"),
"dark:border-white/10"
]}
data-site-id={site.id}
data-site-position={index + 1}
draggable={if @reorder_mode, do: "true", else: "false"}
>
<td
:if={@reorder_mode}
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 pl-4 text-left sm:pl-3 dark:bg-gray-800/50"
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">
@ -140,22 +194,47 @@
</tr>
<%= for {device, device_index} <- Enum.with_index(devices) do %>
<tr
phx-click={JS.navigate(~p"/devices/#{device.id}")}
class={[
"border-t cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50",
"border-t device-row",
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={site.id}
data-device-position={device_index + 1}
draggable={if @reorder_mode, do: "true", else: "false"}
>
<td class="py-4 pr-3 pl-4 text-sm font-medium whitespace-nowrap text-gray-900 sm:pl-3 dark:text-white">
<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 hover:bg-gray-50 dark:hover:bg-gray-800/50",
@reorder_mode && "pl-2",
!@reorder_mode && "pl-4 sm:pl-3"
]}
>
{device.name}
</td>
<td class="px-3 py-4 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400 font-mono">
<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 hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
{device.ip_address}
</td>
<td class="px-3 py-4 text-sm whitespace-nowrap">
<td
phx-click={JS.navigate(~p"/devices/#{device.id}")}
class="px-3 py-4 text-sm whitespace-nowrap cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
device.status == :up &&
@ -168,7 +247,10 @@
{device.status |> to_string() |> String.upcase()}
</span>
</td>
<td class="px-3 py-4 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400">
<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 hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<.timestamp datetime={device.last_checked_at} timezone={@timezone} />
</td>
</tr>

View file

@ -0,0 +1,12 @@
defmodule Towerops.Repo.Migrations.AddDisplayOrderToSites do
use Ecto.Migration
def change do
alter table(:sites) do
add :display_order, :integer
end
# Composite index for efficient sorting (organization + display_order + name)
create index(:sites, [:organization_id, :display_order, :name])
end
end

View file

@ -0,0 +1,12 @@
defmodule Towerops.Repo.Migrations.AddDisplayOrderToDevices do
use Ecto.Migration
def change do
alter table(:devices) do
add :display_order, :integer
end
# Composite index for efficient sorting within sites (site + display_order + name)
create index(:devices, [:site_id, :display_order, :name])
end
end

View file

@ -234,6 +234,134 @@ defmodule Towerops.EquipmentTest do
# Empty strings are stored as nil in the database
assert device.snmp_community == nil || device.snmp_community == ""
end
test "reorder_device/2 reorders device to first position", %{site: site} do
{:ok, device1} = Devices.create_device(%{name: "Device 1", ip_address: "192.168.1.1", site_id: site.id})
{:ok, device2} = Devices.create_device(%{name: "Device 2", ip_address: "192.168.1.2", site_id: site.id})
{:ok, device3} = Devices.create_device(%{name: "Device 3", ip_address: "192.168.1.3", site_id: site.id})
# Move device3 to first position
{:ok, _updated} = Devices.reorder_device(device3.id, 1)
# Verify order
devices = Devices.list_site_devices(site.id)
assert Enum.at(devices, 0).id == device3.id
assert Enum.at(devices, 0).display_order == 1
assert Enum.at(devices, 1).id == device1.id
assert Enum.at(devices, 1).display_order == 2
assert Enum.at(devices, 2).id == device2.id
assert Enum.at(devices, 2).display_order == 3
end
test "reorder_device/2 reorders device to last position", %{site: site} do
{:ok, device1} = Devices.create_device(%{name: "Device 1", ip_address: "192.168.1.1", site_id: site.id})
{:ok, device2} = Devices.create_device(%{name: "Device 2", ip_address: "192.168.1.2", site_id: site.id})
{:ok, device3} = Devices.create_device(%{name: "Device 3", ip_address: "192.168.1.3", site_id: site.id})
# Move device1 to last position
{:ok, _updated} = Devices.reorder_device(device1.id, 3)
# Verify order
devices = Devices.list_site_devices(site.id)
assert Enum.at(devices, 0).id == device2.id
assert Enum.at(devices, 0).display_order == 1
assert Enum.at(devices, 1).id == device3.id
assert Enum.at(devices, 1).display_order == 2
assert Enum.at(devices, 2).id == device1.id
assert Enum.at(devices, 2).display_order == 3
end
test "reorder_device/2 reorders device to middle position", %{site: site} do
{:ok, device1} = Devices.create_device(%{name: "Device 1", ip_address: "192.168.1.1", site_id: site.id})
{:ok, device2} = Devices.create_device(%{name: "Device 2", ip_address: "192.168.1.2", site_id: site.id})
{:ok, device3} = Devices.create_device(%{name: "Device 3", ip_address: "192.168.1.3", site_id: site.id})
# Move device3 to middle position
{:ok, _updated} = Devices.reorder_device(device3.id, 2)
# Verify order
devices = Devices.list_site_devices(site.id)
assert Enum.at(devices, 0).id == device1.id
assert Enum.at(devices, 0).display_order == 1
assert Enum.at(devices, 1).id == device3.id
assert Enum.at(devices, 1).display_order == 2
assert Enum.at(devices, 2).id == device2.id
assert Enum.at(devices, 2).display_order == 3
end
test "reorder_device/2 maintains continuous numbering", %{site: site} do
{:ok, _device1} = Devices.create_device(%{name: "Device 1", ip_address: "192.168.1.1", site_id: site.id})
{:ok, device2} = Devices.create_device(%{name: "Device 2", ip_address: "192.168.1.2", site_id: site.id})
{:ok, _device3} = Devices.create_device(%{name: "Device 3", ip_address: "192.168.1.3", site_id: site.id})
{:ok, device4} = Devices.create_device(%{name: "Device 4", ip_address: "192.168.1.4", site_id: site.id})
# Reorder multiple times
{:ok, _} = Devices.reorder_device(device2.id, 1)
{:ok, _} = Devices.reorder_device(device4.id, 2)
# Verify all have continuous display_order values
devices = Devices.list_site_devices(site.id)
display_orders = Enum.map(devices, & &1.display_order)
assert display_orders == [1, 2, 3, 4]
end
test "reorder_device/2 only affects devices in same site", %{organization: organization, site: site} do
# Create devices in first site
{:ok, device1} = Devices.create_device(%{name: "Device 1", ip_address: "192.168.1.1", site_id: site.id})
{:ok, device2} = Devices.create_device(%{name: "Device 2", ip_address: "192.168.1.2", site_id: site.id})
# Create second site and device
{:ok, site2} = Towerops.Sites.create_site(%{name: "Site 2", organization_id: organization.id})
{:ok, device3} = Devices.create_device(%{name: "Device 3", ip_address: "192.168.2.1", site_id: site2.id})
# Reorder device in first site
{:ok, _} = Devices.reorder_device(device1.id, 2)
# Verify first site is reordered
site1_devices = Devices.list_site_devices(site.id)
assert Enum.at(site1_devices, 0).id == device2.id
assert Enum.at(site1_devices, 1).id == device1.id
# Verify second site is unchanged (no display_order set)
site2_devices = Devices.list_site_devices(site2.id)
assert length(site2_devices) == 1
assert hd(site2_devices).id == device3.id
assert is_nil(hd(site2_devices).display_order)
end
test "reset_organization_device_order/1 clears all display_order values", %{
organization: organization,
site: site
} do
{:ok, device1} = Devices.create_device(%{name: "Zebra Device", ip_address: "192.168.1.1", site_id: site.id})
{:ok, device2} = Devices.create_device(%{name: "Alpha Device", ip_address: "192.168.1.2", site_id: site.id})
# Set custom order
{:ok, _} = Devices.reorder_device(device1.id, 1)
{:ok, _} = Devices.reorder_device(device2.id, 2)
# Verify custom order is set
devices_before = Devices.list_site_devices(site.id)
assert List.first(devices_before).id == device1.id
assert List.first(devices_before).display_order == 1
# Reset order
{count, _} = Devices.reset_organization_device_order(organization.id)
assert count == 2
# Verify display_order is cleared and falls back to alphabetical
devices_after = Devices.list_site_devices(site.id)
assert List.first(devices_after).name == "Alpha Device"
assert List.first(devices_after).display_order == nil
assert List.last(devices_after).name == "Zebra Device"
assert List.last(devices_after).display_order == nil
end
end
describe "SNMP configuration inheritance" do

View file

@ -333,5 +333,106 @@ defmodule Towerops.SitesTest do
# Verify assignment was deleted
assert is_nil(Towerops.Agents.get_device_assignment(device.id))
end
test "reorder_site/2 reorders site to first position", %{organization: organization} do
{:ok, site1} = Sites.create_site(%{name: "Site 1", organization_id: organization.id})
{:ok, site2} = Sites.create_site(%{name: "Site 2", organization_id: organization.id})
{:ok, site3} = Sites.create_site(%{name: "Site 3", organization_id: organization.id})
# Move site3 to first position
{:ok, _updated} = Sites.reorder_site(site3.id, 1)
# Verify order
sites = Sites.list_organization_sites(organization.id)
assert Enum.at(sites, 0).id == site3.id
assert Enum.at(sites, 0).display_order == 1
assert Enum.at(sites, 1).id == site1.id
assert Enum.at(sites, 1).display_order == 2
assert Enum.at(sites, 2).id == site2.id
assert Enum.at(sites, 2).display_order == 3
end
test "reorder_site/2 reorders site to last position", %{organization: organization} do
{:ok, site1} = Sites.create_site(%{name: "Site 1", organization_id: organization.id})
{:ok, site2} = Sites.create_site(%{name: "Site 2", organization_id: organization.id})
{:ok, site3} = Sites.create_site(%{name: "Site 3", organization_id: organization.id})
# Move site1 to last position
{:ok, _updated} = Sites.reorder_site(site1.id, 3)
# Verify order
sites = Sites.list_organization_sites(organization.id)
assert Enum.at(sites, 0).id == site2.id
assert Enum.at(sites, 0).display_order == 1
assert Enum.at(sites, 1).id == site3.id
assert Enum.at(sites, 1).display_order == 2
assert Enum.at(sites, 2).id == site1.id
assert Enum.at(sites, 2).display_order == 3
end
test "reorder_site/2 reorders site to middle position", %{organization: organization} do
{:ok, site1} = Sites.create_site(%{name: "Site 1", organization_id: organization.id})
{:ok, site2} = Sites.create_site(%{name: "Site 2", organization_id: organization.id})
{:ok, site3} = Sites.create_site(%{name: "Site 3", organization_id: organization.id})
# Move site3 to middle position
{:ok, _updated} = Sites.reorder_site(site3.id, 2)
# Verify order
sites = Sites.list_organization_sites(organization.id)
assert Enum.at(sites, 0).id == site1.id
assert Enum.at(sites, 0).display_order == 1
assert Enum.at(sites, 1).id == site3.id
assert Enum.at(sites, 1).display_order == 2
assert Enum.at(sites, 2).id == site2.id
assert Enum.at(sites, 2).display_order == 3
end
test "reorder_site/2 maintains continuous numbering", %{organization: organization} do
{:ok, _site1} = Sites.create_site(%{name: "Site 1", organization_id: organization.id})
{:ok, site2} = Sites.create_site(%{name: "Site 2", organization_id: organization.id})
{:ok, _site3} = Sites.create_site(%{name: "Site 3", organization_id: organization.id})
{:ok, site4} = Sites.create_site(%{name: "Site 4", organization_id: organization.id})
# Reorder multiple times
{:ok, _} = Sites.reorder_site(site2.id, 1)
{:ok, _} = Sites.reorder_site(site4.id, 2)
# Verify all have continuous display_order values
sites = Sites.list_organization_sites(organization.id)
display_orders = Enum.map(sites, & &1.display_order)
assert display_orders == [1, 2, 3, 4]
end
test "reset_site_order/1 clears all display_order values", %{organization: organization} do
{:ok, site1} = Sites.create_site(%{name: "Zebra Site", organization_id: organization.id})
{:ok, site2} = Sites.create_site(%{name: "Alpha Site", organization_id: organization.id})
# Set custom order
{:ok, _} = Sites.reorder_site(site1.id, 1)
{:ok, _} = Sites.reorder_site(site2.id, 2)
# Verify custom order is set
sites_before = Sites.list_organization_sites(organization.id)
assert List.first(sites_before).id == site1.id
assert List.first(sites_before).display_order == 1
# Reset order
{count, _} = Sites.reset_site_order(organization.id)
assert count == 2
# Verify display_order is cleared and falls back to alphabetical
sites_after = Sites.list_organization_sites(organization.id)
assert List.first(sites_after).name == "Alpha Site"
assert List.first(sites_after).display_order == nil
assert List.last(sites_after).name == "Zebra Site"
assert List.last(sites_after).display_order == nil
end
end
end