- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia) - Jason.decode! → Jason.decode with error handling for untrusted input - inspect() leak: replace with generic error messages, log details server-side - SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes - SSRF validation added to HTTP monitoring executor and integration credentials - GraphQL complexity limits: always applied, not just in prod - GraphQL introspection: also check GET query params, not just body - Stripe webhook: explicit nil/empty checks for signature and body - Cookie security: secure flag for session (prod), http_only+secure for remember_me - Honeybadger API key: read from env var with fallback - String.to_integer → Integer.parse with fallback for URL params - String.to_atom → whitelist map for HTTP methods - Gaiia webhook: remove secret_len and expected signature from log - Admin API: add rate limiting pipeline - to_atom_keys: per-key fallback instead of all-or-nothing rescue
594 lines
20 KiB
Elixir
594 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 = safe_to_integer(Map.get(params, "page", "1"), 1)
|
|
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
|
|
|
|
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
|
|
|
|
defp safe_to_integer(value, _default) when is_integer(value), do: value
|
|
|
|
defp safe_to_integer(value, default) when is_binary(value) do
|
|
case Integer.parse(value) do
|
|
{int, _} -> int
|
|
:error -> default
|
|
end
|
|
end
|
|
|
|
defp safe_to_integer(_, default), do: default
|
|
end
|