defmodule Phoenix.LiveDashboard.EctoStatsPage do @moduledoc false use Phoenix.LiveDashboard.PageBuilder import Phoenix.LiveDashboard.Helpers @compile {:no_warn_undefined, [Decimal, Duration, EctoPSQLExtras, {Ecto.Repo, :all_running, 0}]} @disabled_link "https://hexdocs.pm/phoenix_live_dashboard/ecto_stats.html" @page_title "Ecto Stats" @impl true def init(%{ repos: repos, ecto_psql_extras_options: ecto_psql_extras_options, ecto_mysql_extras_options: ecto_mysql_extras_options, ecto_sqlite3_extras_options: ecto_sqlite3_extras_options }) do capabilities = for repo <- List.wrap(repos), do: {:process, repo} repos = repos || :auto_discover {:ok, %{ repos: repos, ecto_options: [ ecto_psql_extras_options: ecto_psql_extras_options, ecto_mysql_extras_options: ecto_mysql_extras_options, ecto_sqlite3_extras_options: ecto_sqlite3_extras_options ] }, capabilities} end @impl true def mount(_params, %{repos: repos, ecto_options: ecto_options}, socket) do socket = assign(socket, ecto_options: ecto_options) result = case repos do :auto_discover -> auto_discover(socket.assigns.page.node) [_ | _] = repos -> {:ok, repos} _ -> {:error, :no_ecto_repos_available} end case result do {:ok, repos} -> {:ok, assign(socket, :repos, repos)} {:error, error} -> {:ok, assign(socket, :error, error)} end end defp auto_discover(node) do case named_stats_available_repos(node) do {:ok, [_ | _] = repos} -> {:ok, repos} {:ok, []} -> {:error, :no_ecto_repos_available} {:error, _} = error -> error end end defp named_stats_available_repos(node) do case :rpc.call(node, Ecto.Repo, :all_running, []) do repos when is_list(repos) -> {:ok, Enum.filter(repos, fn repo -> extra_available?(node, repo) end)} {:badrpc, _error} -> {:error, :cannot_list_running_repos} end end @impl true def menu_link(%{repos: []}, _capabilities) do :skip end @impl true def menu_link(%{repos: :auto_discover}, _caps) do # We require the extras plugins to be on this node, # which means we also require Ecto.Adapters.SQL on this node. if Code.ensure_loaded?(Ecto.Adapters.SQL) do {:ok, @page_title} else :skip end end @impl true def menu_link(%{repos: repos}, capabilities) do cond do Enum.all?(repos, fn repo -> repo not in capabilities.processes end) -> :skip extra_available_for_any?(Node.self(), repos) -> {:ok, @page_title} true -> {:disabled, @page_title, @disabled_link} end end defp extra_available_for_any?(node, repos) do Enum.any?(repos, fn repo -> extra_available?(node, repo) end) end defp extra_available?(node, repo) when is_atom(repo) do extra = info_module_for(node, repo) extra && extra_loaded?(extra) end defp extra_available?(_node, _repo_pid), do: false # We check if the extra module is available locally, because # that module should be able to send RPC calls. Therefore the # user does not need to have extra module installed on every node. defp extra_loaded?(extra) do Code.ensure_loaded?(extra) end defp info_module_for(node, repo) do case :rpc.call(node, repo, :__adapter__, []) do Ecto.Adapters.Postgres -> EctoPSQLExtras Ecto.Adapters.MyXQL -> EctoMySQLExtras Ecto.Adapters.SQLite3 -> EctoSQLite3Extras _ -> nil end end @impl true def render(assigns) do if assigns[:error] do render_error(assigns) else ~H""" <.live_nav_bar id="repos_nav_bar" page={@page} nav_param="repo" style={:bar} extra_params={["nav"]} > <:item :for={repo <- @repos} name={inspect(repo)} label={inspect(repo)}> <.render_repo_tab page={@page} repo={repo} ecto_options={@ecto_options} info_module={info_module_for(@page.node, repo)} /> """ end end defp render_repo_tab(assigns) do ~H""" <.live_nav_bar id="queries_nav_bar" page={@page} extra_params={["repo"]}> <:item :for={{table_name, info} <- queries(@page.node, @repo, @info_module)} name={to_string(table_name)} > <.live_table id={"table_#{table_name}"} page={@page} title={Phoenix.Naming.humanize(table_name)} hint={info.title} limit={false} search={info.searchable != []} default_sort_by={info.default_sort_by} rows_name="entries" row_fetcher={ &row_fetcher(@repo, @info_module, table_name, info.searchable, @ecto_options, &1, &2) } > <:col :let={row} :for={col <- info.columns} field={col.name} sortable={sortable(col.type)}> <%= format(col.type, row[col.name]) %> """ end @forbidden_tables [:kill_all, :mandelbrot] defp queries(node, repo, info_module) do info_module.queries({repo, node}) |> Enum.reject(fn {table_name, _table_module} -> table_name in @forbidden_tables end) |> Enum.map(fn {table_name, table_module} -> {table_name, table_module.info()} end) |> Enum.sort(fn {_, a_info}, {_, b_info} -> a_info[:index] < b_info[:index] end) |> Enum.map(fn {table_name, info} -> {table_name, normalize_info(info)} end) end defp normalize_info(info) do searchable = for %{type: :string, name: name} <- info.columns, do: name default_sort_by = with [{column, _} | _] <- info[:order_by], do: to_string(column) info |> Map.put(:searchable, searchable) |> Map.put(:default_sort_by, default_sort_by) end defp sortable(:string), do: :asc defp sortable(_), do: :desc defp row_fetcher(repo, info_module, table_name, searchable, ecto_options, params, node) do ecto_db_extras_options = case info_module do EctoPSQLExtras -> Keyword.fetch!(ecto_options, :ecto_psql_extras_options) EctoMySQLExtras -> Keyword.fetch!(ecto_options, :ecto_mysql_extras_options) EctoSQLite3Extras -> Keyword.fetch!(ecto_options, :ecto_sqlite3_extras_options) _ -> [] end opts = case Keyword.fetch(ecto_db_extras_options, table_name) do {:ok, args} -> [args: args] :error -> [] end |> Keyword.merge(format: :raw) %{columns: columns, rows: rows} = info_module.query(table_name, {repo, node}, opts) mapped = for row <- rows do columns |> Enum.zip(row) |> Map.new(fn {key, value} -> {String.to_atom(key), value} end) end %{search: search, sort_by: sort_by, sort_dir: sort_dir} = params mapped = if search do Enum.filter(mapped, fn map -> Enum.any?(searchable, fn column -> value = Map.fetch!(map, column) value && value =~ search end) end) else mapped end sorted = Enum.sort_by(mapped, &Map.fetch!(&1, sort_by), fn # Handle structs %struct{} = left, %struct{} = right -> case struct.compare(left, right) do :gt when sort_dir == :asc -> false :lt when sort_dir == :desc -> false _ -> true end # Nils are always last regardless of ordering nil, _ -> false _, nil -> true # Handle all other types left, right when sort_dir == :asc -> left <= right left, right when sort_dir == :desc -> left >= right end) {sorted, length(rows)} end defp format(_, %struct{} = value) when struct in [Decimal, Duration, Postgrex.Interval], do: struct.to_string(value) defp format(:bytes, value) when is_integer(value), do: format_bytes(value) defp format(:percent, value) when is_number(value), do: value |> Kernel.*(100.0) |> Float.round(1) |> Float.to_string() defp format(_type, value), do: value defp render_error(assigns) do case assigns.error do :no_ecto_repos_available -> ~H""" <.card> No Ecto repository was found running on this node. Currently, only PostgreSQL, MySQL, and SQLite databases are supported. Depending on the database, ecto_psql_extras, ecto_mysql_extras, or ecto_sqlite3_extras should be installed. Check the documentation for details. """ :cannot_list_running_repos -> ~H""" <.card> Cannot list running repositories. Make sure that Ecto is running with version ~> 3.7. """ end end end