Proactively add catch-all handlers to device index/show pages to prevent FunctionClauseError when StatusTitleComponent broadcasts alert changes. These are the most frequently open pages where users would encounter the crash.
589 lines
20 KiB
Elixir
589 lines
20 KiB
Elixir
defmodule ToweropsWeb.DeviceLive.Index do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Devices
|
|
alias Towerops.Gaiia
|
|
alias Towerops.Monitoring
|
|
alias Towerops.Organizations.SubscriptionLimits
|
|
alias Towerops.Sites
|
|
alias Towerops.Snmp
|
|
alias Towerops.Workers.DiscoveryWorker
|
|
alias ToweropsWeb.Live.Helpers.AccessControl
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
devices = Devices.list_organization_devices(organization.id)
|
|
sites = Sites.list_organization_sites(organization.id)
|
|
|
|
if connected?(socket) do
|
|
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
|
|
schedule_tick()
|
|
end
|
|
|
|
# Load device quota
|
|
{current, limit} = SubscriptionLimits.device_quota(organization)
|
|
|
|
site_subscribers = load_site_subscribers(devices)
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, t_equipment("Devices"))
|
|
|> assign(:timezone, socket.assigns.current_scope.timezone)
|
|
|> 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)
|
|
|> assign(:total_devices, length(devices))
|
|
|> assign(:total_up, Enum.count(devices, &(&1.status == :up)))
|
|
|> assign(:total_down, Enum.count(devices, &(&1.status == :down)))
|
|
|> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown)))
|
|
|> assign(:device_quota, %{current: current, limit: limit})
|
|
|> assign(:site_subscribers, site_subscribers)
|
|
|> assign(:search_query, "")
|
|
|> assign(:status_filter, "all")
|
|
|> assign(:pending_reload_ref, nil)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _url, socket) do
|
|
tab = Map.get(params, "tab", "existing")
|
|
{:noreply, apply_action(socket, socket.assigns.live_action, params, tab)}
|
|
end
|
|
|
|
defp apply_action(socket, :index, params, tab) do
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
case tab do
|
|
"discovered" ->
|
|
page = params |> Map.get("page", "1") |> String.to_integer()
|
|
per_page = 20
|
|
|
|
# Load all discovered devices - this is temporarily kept in memory for slicing
|
|
# For very large datasets, consider database-level pagination
|
|
all_discovered = Snmp.list_discovered_devices_for_organization(organization.id)
|
|
total_count = length(all_discovered)
|
|
total_pages = ceil(total_count / per_page)
|
|
|
|
# Ensure page is within valid range
|
|
page = max(1, min(page, max(1, total_pages)))
|
|
|
|
offset = (page - 1) * per_page
|
|
# Only keep the current page in assigns - don't store all_discovered
|
|
discovered_devices = Enum.slice(all_discovered, offset, per_page)
|
|
|
|
socket
|
|
|> assign(:active_tab, "discovered")
|
|
# Use regular assign to prevent state bleeding between tab switches
|
|
|> assign(:discovered_devices, discovered_devices)
|
|
|> assign(:pagination, %{
|
|
page: page,
|
|
per_page: per_page,
|
|
total_count: total_count,
|
|
total_pages: total_pages
|
|
})
|
|
|
|
_ ->
|
|
# Reload device stream to ensure immediate rendering when switching back to existing tab.
|
|
# Without this, the table won't appear until the next PubSub update or tick event.
|
|
devices = Devices.list_organization_devices(organization.id)
|
|
|
|
socket
|
|
|> assign(:active_tab, "existing")
|
|
|> assign(:discovered_devices, [])
|
|
|> assign(:pagination, nil)
|
|
|> stream(:device_rows, build_device_rows(devices), reset: true)
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("search", %{"search_query" => query}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:search_query, query)
|
|
|> apply_filters()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("filter_status", %{"status" => status}, socket) do
|
|
new_status = if socket.assigns.status_filter == status, do: "all", else: status
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:status_filter, new_status)
|
|
|> apply_filters()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("force_rediscover_all", _params, socket) do
|
|
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
|
|
{:noreply, put_flash(socket, :error, t_equipment("No SNMP-enabled devices found"))}
|
|
else
|
|
Enum.each(snmp_devices, fn device ->
|
|
enqueue_discovery(device.id)
|
|
end)
|
|
|
|
count = length(snmp_devices)
|
|
message = t_equipment("Discovery started for %{count} device", count: count) <> if count == 1, do: "", else: "s"
|
|
|
|
{:noreply, put_flash(socket, :info, message)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("reorder_site", %{"site_id" => site_id, "new_position" => new_position}, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
# Verify site belongs to current organization
|
|
case AccessControl.verify_site_access(site_id, organization.id) do
|
|
{:ok, _site} ->
|
|
perform_site_reorder(socket, site_id, new_position, organization.id)
|
|
|
|
{:error, :not_found} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("Site not found"))}
|
|
|
|
{:error, :unauthorized} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("You don't have access to this site"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("reorder_device", %{"device_id" => device_id, "new_position" => new_position}, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
# Verify device belongs to current organization
|
|
case AccessControl.verify_device_access(device_id, organization.id) do
|
|
{:ok, _device} ->
|
|
perform_device_reorder(socket, device_id, new_position, organization.id)
|
|
|
|
{:error, :not_found} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("Device not found"))}
|
|
|
|
{:error, :unauthorized} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("You don't have access to this 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_scope.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)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:has_devices, devices != [])
|
|
|> stream(:device_rows, build_device_rows(devices), reset: true)
|
|
|> put_flash(:info, t_equipment("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))
|
|
|
|
prefill_params = %{
|
|
"name" => discovered.hostname || "",
|
|
"ip_address" => List.first(discovered.ip_addresses) || "",
|
|
"snmp_enabled" => "true"
|
|
}
|
|
|
|
{:noreply, push_navigate(socket, to: ~p"/devices/new?#{prefill_params}")}
|
|
end
|
|
|
|
defp perform_site_reorder(socket, site_id, new_position, organization_id) do
|
|
position = if is_integer(new_position), do: new_position, else: String.to_integer(new_position)
|
|
result = Sites.reorder_site(site_id, position)
|
|
perform_reorder(socket, result, "site", organization_id)
|
|
end
|
|
|
|
defp perform_device_reorder(socket, device_id, new_position, organization_id) do
|
|
position = if is_integer(new_position), do: new_position, else: String.to_integer(new_position)
|
|
result = Devices.reorder_device(device_id, position)
|
|
perform_reorder(socket, result, "device", organization_id)
|
|
end
|
|
|
|
defp perform_reorder(socket, reorder_result, resource_name, organization_id) do
|
|
case reorder_result do
|
|
{:ok, _} ->
|
|
# Reload devices with updated order
|
|
devices = Devices.list_organization_devices(organization_id)
|
|
|
|
success_message =
|
|
case resource_name do
|
|
"site" -> t_equipment("Site order updated")
|
|
"device" -> t_equipment("Device order updated")
|
|
end
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:has_devices, devices != [])
|
|
|> stream(:device_rows, build_device_rows(devices), reset: true)
|
|
|> put_flash(:info, success_message)}
|
|
|
|
{:error, _changeset} ->
|
|
error_message =
|
|
case resource_name do
|
|
"site" -> t_equipment("Failed to reorder site")
|
|
"device" -> t_equipment("Failed to reorder device")
|
|
end
|
|
|
|
{:noreply, put_flash(socket, :error, error_message)}
|
|
end
|
|
end
|
|
|
|
# Periodic tick to refresh "time ago" timestamps between poll events
|
|
@impl true
|
|
def handle_info(:tick, socket) do
|
|
schedule_tick()
|
|
|
|
if socket.assigns.active_tab == "existing" do
|
|
{:noreply, schedule_debounced_reload(socket, :tick)}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
# Structural changes (create/delete) need full reload with quota recalculation
|
|
@impl true
|
|
def handle_info({event, _org_id}, socket) when event in [:device_created, :device_deleted] do
|
|
{:noreply, reload_devices(socket)}
|
|
end
|
|
|
|
# Status changes and updates are debounced to avoid redundant reloads
|
|
# when many devices change status simultaneously (e.g., network event)
|
|
@impl true
|
|
def handle_info({event, _org_id}, socket) when event in [:device_status_changed, :device_updated] do
|
|
{:noreply, schedule_debounced_reload(socket, event)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:debounced_device_reload, socket) do
|
|
{:noreply, reload_device_stream(socket)}
|
|
end
|
|
|
|
# Handled by StatusTitleComponent in layout - ignore to prevent crashes
|
|
@impl true
|
|
def handle_info({:alert_changed, _org_id}, socket) do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
defp tick_interval_ms do
|
|
if Application.get_env(:towerops, :env) == :test, do: :infinity, else: to_timeout(second: 15)
|
|
end
|
|
|
|
defp schedule_tick do
|
|
case tick_interval_ms() do
|
|
:infinity -> :ok
|
|
interval -> Process.send_after(self(), :tick, interval)
|
|
end
|
|
end
|
|
|
|
defp debounce_ms do
|
|
if Application.get_env(:towerops, :env) == :test, do: 0, else: 100
|
|
end
|
|
|
|
defp schedule_debounced_reload(socket, event) do
|
|
# Cancel any pending debounced reload
|
|
if ref = socket.assigns.pending_reload_ref do
|
|
Process.cancel_timer(ref)
|
|
end
|
|
|
|
# For status changes, skip reload entirely if viewing the discovered tab
|
|
if event == :device_status_changed && socket.assigns.active_tab == "discovered" do
|
|
assign(socket, :pending_reload_ref, nil)
|
|
else
|
|
case debounce_ms() do
|
|
0 ->
|
|
# In test: reload immediately so render(view) sees the update
|
|
reload_device_stream(socket)
|
|
|
|
ms ->
|
|
ref = Process.send_after(self(), :debounced_device_reload, ms)
|
|
assign(socket, :pending_reload_ref, ref)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Full reload: stream + quota (for structural changes like create/delete)
|
|
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})
|
|
|> assign(:site_subscribers, load_site_subscribers(devices))
|
|
|> assign(:total_devices, length(devices))
|
|
|> assign(:total_up, Enum.count(devices, &(&1.status == :up)))
|
|
|> assign(:total_down, Enum.count(devices, &(&1.status == :down)))
|
|
|> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown)))
|
|
end
|
|
|
|
# Lightweight reload: stream only, skip quota query (for status/update changes)
|
|
defp reload_device_stream(socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
devices = Devices.list_organization_devices(organization.id)
|
|
|
|
socket
|
|
|> stream(:device_rows, build_device_rows(devices), reset: true)
|
|
|> assign(:has_devices, devices != [])
|
|
|> assign(:pending_reload_ref, nil)
|
|
|> assign(:total_devices, length(devices))
|
|
|> assign(:total_up, Enum.count(devices, &(&1.status == :up)))
|
|
|> assign(:total_down, Enum.count(devices, &(&1.status == :down)))
|
|
|> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown)))
|
|
end
|
|
|
|
defp apply_filters(socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
devices = Devices.list_organization_devices(organization.id)
|
|
filtered = filter_devices(devices, socket.assigns.search_query, socket.assigns.status_filter)
|
|
|
|
socket
|
|
|> stream(:device_rows, build_device_rows(filtered), reset: true)
|
|
|> assign(:has_devices, devices != [])
|
|
end
|
|
|
|
defp filter_devices(devices, query, status_filter) do
|
|
devices
|
|
|> filter_by_search(query)
|
|
|> filter_by_status(status_filter)
|
|
end
|
|
|
|
defp filter_by_search(devices, ""), do: devices
|
|
defp filter_by_search(devices, nil), do: devices
|
|
|
|
defp filter_by_search(devices, query) do
|
|
q = String.downcase(query)
|
|
|
|
Enum.filter(devices, fn device ->
|
|
String.contains?(String.downcase(device.name || ""), q) ||
|
|
String.contains?(String.downcase(to_string(device.ip_address)), q)
|
|
end)
|
|
end
|
|
|
|
defp filter_by_status(devices, "all"), do: devices
|
|
defp filter_by_status(devices, "up"), do: Enum.filter(devices, &(&1.status == :up))
|
|
defp filter_by_status(devices, "down"), do: Enum.filter(devices, &(&1.status == :down))
|
|
defp filter_by_status(devices, "unknown"), do: Enum.filter(devices, &(&1.status == :unknown))
|
|
defp filter_by_status(devices, _), do: devices
|
|
|
|
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)
|
|
else
|
|
DiscoveryWorker.enqueue(device_id)
|
|
end
|
|
end
|
|
|
|
defp build_device_rows(devices) do
|
|
device_ids = Enum.map(devices, & &1.id)
|
|
health_data = Monitoring.get_device_health_summary(device_ids)
|
|
response_data = Monitoring.get_device_latest_response_times(device_ids)
|
|
subscriber_data = Gaiia.get_device_subscriber_counts(device_ids)
|
|
|
|
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,
|
|
health: Map.get(health_data, device.id),
|
|
response: Map.get(response_data, device.id),
|
|
subscribers: Map.get(subscriber_data, device.id)
|
|
}
|
|
end)
|
|
|
|
[site_row | device_rows]
|
|
end)
|
|
end
|
|
|
|
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} ->
|
|
if site do
|
|
{site.display_order || 999_999, site.name}
|
|
else
|
|
{0, ""}
|
|
end
|
|
end,
|
|
:asc
|
|
)
|
|
end
|
|
|
|
defp calculate_site_stats(devices) do
|
|
status_counts = Enum.frequencies_by(devices, & &1.status)
|
|
|
|
%{
|
|
total: length(devices),
|
|
up: Map.get(status_counts, :up, 0),
|
|
down: Map.get(status_counts, :down, 0),
|
|
unknown: Map.get(status_counts, :unknown, 0)
|
|
}
|
|
end
|
|
|
|
defp device_type_icon(:router), do: "hero-signal"
|
|
defp device_type_icon(:switch), do: "hero-squares-2x2"
|
|
defp device_type_icon(:wireless), do: "hero-wifi"
|
|
defp device_type_icon(:server), do: "hero-server"
|
|
defp device_type_icon(:workstation), do: "hero-computer-desktop"
|
|
defp device_type_icon(_), do: "hero-question-mark-circle"
|
|
|
|
defp device_type_label(type) do
|
|
type |> to_string() |> String.capitalize()
|
|
end
|
|
|
|
defp load_site_subscribers(devices) do
|
|
devices
|
|
|> Enum.map(& &1.site_id)
|
|
|> Enum.uniq()
|
|
|> Enum.reject(&is_nil/1)
|
|
|> Map.new(fn site_id -> {site_id, Gaiia.get_site_subscriber_summary(site_id)} end)
|
|
end
|
|
|
|
defp device_row_title(device) do
|
|
{last_seen, _color} = last_seen_text(device.last_checked_at)
|
|
snmp = if device.snmp_enabled, do: "SNMP: #{device.snmp_version || "v2c"}", else: "SNMP: off"
|
|
type_label = (device.device_role || "unknown") |> to_string() |> String.capitalize()
|
|
"#{type_label} | Last Poll: #{last_seen} | #{snmp}"
|
|
end
|
|
|
|
# Health indicator helpers
|
|
|
|
defp health_dot_color(nil), do: "bg-gray-400"
|
|
defp health_dot_color(%{worst_status: 0}), do: "bg-green-500"
|
|
defp health_dot_color(%{worst_status: 1}), do: "bg-yellow-500"
|
|
defp health_dot_color(%{worst_status: 2}), do: "bg-red-500"
|
|
defp health_dot_color(%{worst_status: 3}), do: "bg-gray-400"
|
|
defp health_dot_color(_), do: "bg-gray-400"
|
|
|
|
defp health_dot_title(nil), do: "No check data"
|
|
defp health_dot_title(%{worst_status: 0}), do: "All checks passing"
|
|
defp health_dot_title(%{worst_status: 1}), do: "Warning"
|
|
defp health_dot_title(%{worst_status: 2}), do: "Critical"
|
|
defp health_dot_title(%{worst_status: 3}), do: "Unknown"
|
|
defp health_dot_title(_), do: "No check data"
|
|
|
|
defp last_seen_text(nil), do: {"Never", "text-gray-400 dark:text-gray-600"}
|
|
|
|
defp last_seen_text(datetime) do
|
|
diff_seconds = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
|
|
|
cond do
|
|
diff_seconds < 0 -> {"just now", "text-green-600 dark:text-green-400"}
|
|
diff_seconds < 60 -> {"#{diff_seconds}s ago", "text-green-600 dark:text-green-400"}
|
|
diff_seconds < 600 -> {"#{div(diff_seconds, 60)}m ago", "text-green-600 dark:text-green-400"}
|
|
diff_seconds < 3600 -> {"#{div(diff_seconds, 60)}m ago", "text-yellow-600 dark:text-yellow-400"}
|
|
diff_seconds < 86_400 -> {"#{div(diff_seconds, 3600)}h ago", "text-red-600 dark:text-red-400"}
|
|
true -> {"#{div(diff_seconds, 86_400)}d ago", "text-red-600 dark:text-red-400"}
|
|
end
|
|
end
|
|
|
|
defp response_time_badge(nil), do: {"-", "text-gray-400"}
|
|
|
|
defp response_time_badge(%{response_time_ms: nil}), do: {"-", "text-gray-400"}
|
|
|
|
defp response_time_badge(%{response_time_ms: ms}) do
|
|
formatted =
|
|
cond do
|
|
ms < 1 -> "<1ms"
|
|
ms < 1000 -> "#{round(ms)}ms"
|
|
true -> "#{Float.round(ms / 1000, 1)}s"
|
|
end
|
|
|
|
color =
|
|
cond do
|
|
ms < 20 -> "text-green-600 dark:text-green-400"
|
|
ms <= 100 -> "text-yellow-600 dark:text-yellow-400"
|
|
true -> "text-red-600 dark:text-red-400"
|
|
end
|
|
|
|
{formatted, color}
|
|
end
|
|
|
|
defp response_time_bar_width(nil), do: 0
|
|
defp response_time_bar_width(%{response_time_ms: nil}), do: 0
|
|
|
|
defp response_time_bar_width(%{response_time_ms: ms}) do
|
|
# Scale: 0-200ms maps to 0-100% width, capped at 100
|
|
min(100, round(ms / 2))
|
|
end
|
|
|
|
defp response_time_bar_color(nil), do: "bg-gray-300"
|
|
defp response_time_bar_color(%{response_time_ms: nil}), do: "bg-gray-300"
|
|
|
|
defp response_time_bar_color(%{response_time_ms: ms}) do
|
|
cond do
|
|
ms < 20 -> "bg-green-500"
|
|
ms <= 100 -> "bg-yellow-500"
|
|
true -> "bg-red-500"
|
|
end
|
|
end
|
|
|
|
defp format_subscriber_count(nil), do: nil
|
|
|
|
defp format_subscriber_count(%{subscriber_count: count}) when count > 0 do
|
|
"#{count} subs"
|
|
end
|
|
|
|
defp format_subscriber_count(_), do: nil
|
|
|
|
# Generate pagination range with ellipsis for large page counts
|
|
# Shows: [1] ... [4] [5] [6] ... [20] when on page 5 of 20
|
|
defp pagination_range(current_page, total_pages) do
|
|
cond do
|
|
total_pages <= 7 ->
|
|
# Show all pages if 7 or fewer
|
|
Enum.to_list(1..total_pages)
|
|
|
|
current_page <= 4 ->
|
|
# Near the start
|
|
[1, 2, 3, 4, 5, :ellipsis, total_pages]
|
|
|
|
current_page >= total_pages - 3 ->
|
|
# Near the end
|
|
[1, :ellipsis, total_pages - 4, total_pages - 3, total_pages - 2, total_pages - 1, total_pages]
|
|
|
|
true ->
|
|
# In the middle
|
|
[1, :ellipsis, current_page - 1, current_page, current_page + 1, :ellipsis, total_pages]
|
|
end
|
|
end
|
|
end
|