device reorder
This commit is contained in:
parent
e781a70c7e
commit
99414459d3
12 changed files with 691 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
124
assets/js/device_list_reorder.ts
Normal file
124
assets/js/device_list_reorder.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue