defmodule Phoenix.LiveDashboard.Router do @moduledoc """ Provides LiveView routing for LiveDashboard. """ @doc """ Defines a LiveDashboard route. It expects the `path` the dashboard will be mounted at and a set of options. You can then link to the route directly: Dashboard ## Options * `:live_socket_path` - Configures the socket path. it must match the `socket "/live", Phoenix.LiveView.Socket` in your endpoint. * `:csp_nonce_assign_key` - an assign key to find the CSP nonce value used for assets. Supports either `atom()` or a map of type `%{optional(:img) => atom(), optional(:script) => atom(), optional(:style) => atom()}` * `:ecto_repos` - the repositories to show database information. Currently only PostgreSQL, MySQL, and SQLite databases are supported. If you don't specify but your app is running Ecto, we will try to auto-discover the available repositories. You can disable this behavior by setting `[]` to this option. * `:env_keys` - Configures environment variables to display. It is defined as a list of string keys. If not set, the environment information will not be displayed * `:home_app` - A tuple with the app name and version to show on the home page. Defaults to `{"Dashboard", :phoenix_live_dashboard}` * `:metrics` - Configures the module to retrieve metrics from. It can be a `module` or a `{module, function}`. If nothing is given, the metrics functionality will be disabled. If `false` is passed, then the menu item won't be visible. * `:metrics_history` - Configures a callback for retrieving metric history. It must be an "MFA" tuple of `{Module, :function, arguments}` such as metrics_history: {MyStorage, :metrics_history, []} If not set, metrics will start out empty/blank and only display data that occurs while the browser page is open. * `:on_mount` - Declares a custom list of `Phoenix.LiveView.on_mount/1` callbacks to add to the dashboard's `Phoenix.LiveView.Router.live_session/3`. A single value may also be declared. * `:request_logger` - By default the Request Logger page is enabled. Passing `false` will disable this page. * `:request_logger_cookie_domain` - Configures the domain the request_logger cookie will be written to. It can be a string or `:parent` atom. When a string is given, it will directly set cookie domain to the given value. When `:parent` is given, it will take the parent domain from current endpoint host (if host is "www.acme.com" the cookie will be scoped on "acme.com"). When not set, the cookie will be scoped to current domain. * `:allow_destructive_actions` - When true, allow destructive actions directly from the UI. Defaults to `false`. The following destructive actions are available in the dashboard: * "Kill process" - a "Kill process" button on the process modal Note that custom pages given to "Additional pages" may support their own destructive actions. * `:additional_pages` - A keyword list of additional pages ## Examples defmodule MyAppWeb.Router do use Phoenix.Router import Phoenix.LiveDashboard.Router scope "/", MyAppWeb do pipe_through [:browser] live_dashboard "/dashboard", metrics: {MyAppWeb.Telemetry, :metrics}, env_keys: ["APP_USER", "VERSION"], metrics_history: {MyStorage, :metrics_history, []}, request_logger_cookie_domain: ".acme.com" end end """ defmacro live_dashboard(path, opts \\ []) do opts = if Macro.quoted_literal?(opts) do Macro.prewalk(opts, &expand_alias(&1, __CALLER__)) else opts end scope = quote bind_quoted: binding() do scope path, alias: false, as: false do {session_name, session_opts, route_opts} = Phoenix.LiveDashboard.Router.__options__(opts) import Phoenix.Router, only: [get: 4] import Phoenix.LiveView.Router, only: [live: 4, live_session: 3] live_session session_name, session_opts do # LiveDashboard assets get "/css-:md5", Phoenix.LiveDashboard.Assets, :css, as: :live_dashboard_asset get "/js-:md5", Phoenix.LiveDashboard.Assets, :js, as: :live_dashboard_asset # All helpers are public contracts and cannot be changed live "/", Phoenix.LiveDashboard.PageLive, :home, route_opts live "/:page", Phoenix.LiveDashboard.PageLive, :page, route_opts live "/:node/:page", Phoenix.LiveDashboard.PageLive, :page, route_opts end end end # TODO: Remove check once we require Phoenix v1.7 if Code.ensure_loaded?(Phoenix.VerifiedRoutes) do quote do unquote(scope) unless Module.get_attribute(__MODULE__, :live_dashboard_prefix) do @live_dashboard_prefix Phoenix.Router.scoped_path(__MODULE__, path) |> String.replace_suffix("/", "") def __live_dashboard_prefix__, do: @live_dashboard_prefix end end else scope end end defp expand_alias({:__aliases__, _, _} = alias, env), do: Macro.expand(alias, %{env | function: {:live_dashboard, 2}}) defp expand_alias(other, _env), do: other @doc false def __options__(options) do live_socket_path = Keyword.get(options, :live_socket_path, "/live") metrics = case options[:metrics] do nil -> nil false -> :skip mod when is_atom(mod) -> {mod, :metrics} {mod, fun} when is_atom(mod) and is_atom(fun) -> {mod, fun} other -> raise ArgumentError, ":metrics must be a tuple with {Mod, fun}, " <> "such as {MyAppWeb.Telemetry, :metrics}, got: #{inspect(other)}" end env_keys = case options[:env_keys] do nil -> nil keys when is_list(keys) -> keys other -> raise ArgumentError, ":env_keys must be a list of strings, got: " <> inspect(other) end home_app = case options[:home_app] do nil -> {"Dashboard", :phoenix_live_dashboard} {app_title, app_name} when is_binary(app_title) and is_atom(app_name) -> {app_title, app_name} other -> raise ArgumentError, ":home_app must be a tuple with a binary title and atom app, got: " <> inspect(other) end metrics_history = case options[:metrics_history] do nil -> nil {module, function, args} when is_atom(module) and is_atom(function) and is_list(args) -> {module, function, args} other -> raise ArgumentError, ":metrics_history must be a tuple of {module, function, args}, got: " <> inspect(other) end additional_pages = case options[:additional_pages] do nil -> [] pages when is_list(pages) -> normalize_additional_pages(pages) other -> raise ArgumentError, ":additional_pages must be a keyword, got: " <> inspect(other) end request_logger_cookie_domain = case options[:request_logger_cookie_domain] do nil -> nil domain when is_binary(domain) -> domain :parent -> :parent other -> raise ArgumentError, ":request_logger_cookie_domain must be a binary or :parent atom, got: " <> inspect(other) end request_logger_flag = case options[:request_logger] do nil -> true bool when is_boolean(bool) -> bool other -> raise ArgumentError, ":request_logger must be a boolean, got: " <> inspect(other) end request_logger = {request_logger_flag, request_logger_cookie_domain} ecto_repos = options[:ecto_repos] ecto_psql_extras_options = case options[:ecto_psql_extras_options] do nil -> [] args -> unless Keyword.keyword?(args) and args |> Keyword.values() |> Enum.all?(&Keyword.keyword?/1) do raise ArgumentError, ":ecto_psql_extras_options must be a keyword where each value is a keyword, got: " <> inspect(args) end args end ecto_mysql_extras_options = case options[:ecto_mysql_extras_options] do nil -> [] args -> unless Keyword.keyword?(args) and args |> Keyword.values() |> Enum.all?(&Keyword.keyword?/1) do raise ArgumentError, ":ecto_mysql_extras_options must be a keyword where each value is a keyword, got: " <> inspect(args) end args end ecto_sqlite3_extras_options = case options[:ecto_sqlite3_extras_options] do nil -> [] args -> unless Keyword.keyword?(args) and args |> Keyword.values() |> Enum.all?(&Keyword.keyword?/1) do raise ArgumentError, ":ecto_sqlite3_extras_options must be a keyword where each value is a keyword, got: " <> inspect(args) end args end csp_nonce_assign_key = case options[:csp_nonce_assign_key] do nil -> nil key when is_atom(key) -> %{style: key, script: key} %{} = keys -> Map.take(keys, [:img, :style, :script]) end allow_destructive_actions = options[:allow_destructive_actions] || false session_args = [ env_keys, home_app, allow_destructive_actions, metrics, metrics_history, additional_pages, request_logger, ecto_repos, ecto_psql_extras_options, ecto_mysql_extras_options, ecto_sqlite3_extras_options, csp_nonce_assign_key ] { options[:live_session_name] || :live_dashboard, [ session: {__MODULE__, :__session__, session_args}, root_layout: {Phoenix.LiveDashboard.LayoutView, :dash}, on_mount: options[:on_mount] || nil ], [ private: %{live_socket_path: live_socket_path, csp_nonce_assign_key: csp_nonce_assign_key}, as: :live_dashboard ] } end defp normalize_additional_pages(pages) do Enum.map(pages, fn {path, module} when is_atom(path) and is_atom(module) -> {path, {module, []}} {path, {module, args}} when is_atom(path) and is_atom(module) -> {path, {module, args}} other -> msg = "invalid value in :additional_pages, " <> "must be a tuple {path, {module, args}} or {path, module}, where path " <> "is an atom and the module implements Phoenix.LiveDashboard.PageBuilder, got: " raise ArgumentError, msg <> inspect(other) end) end @doc false def __session__( conn, env_keys, home_app, allow_destructive_actions, metrics, metrics_history, additional_pages, request_logger, ecto_repos, ecto_psql_extras_options, ecto_mysql_extras_options, ecto_sqlite3_extras_options, csp_nonce_assign_key ) do ecto_session = %{ repos: ecto_repos(ecto_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 } {pages, requirements} = [ home: {Phoenix.LiveDashboard.HomePage, %{env_keys: env_keys, home_app: home_app}}, os_mon: {Phoenix.LiveDashboard.OSMonPage, %{}}, memory_allocators: {Phoenix.LiveDashboard.MemoryAllocatorsPage, %{}} ] |> Enum.concat(metrics_page(metrics, metrics_history)) |> Enum.concat(request_logger_page(conn, request_logger)) |> Enum.concat( applications: {Phoenix.LiveDashboard.ApplicationsPage, %{}}, processes: {Phoenix.LiveDashboard.ProcessesPage, %{}}, ports: {Phoenix.LiveDashboard.PortsPage, %{}}, sockets: {Phoenix.LiveDashboard.SocketsPage, %{}}, ets: {Phoenix.LiveDashboard.EtsPage, %{}}, ecto_stats: {Phoenix.LiveDashboard.EctoStatsPage, ecto_session} ) |> Enum.concat(additional_pages) |> Enum.map(fn {key, {module, opts}} -> {session, requirements} = initialize_page(module, opts) {{key, {module, session}}, requirements} end) |> Enum.unzip() %{ "pages" => pages, "allow_destructive_actions" => allow_destructive_actions, "requirements" => requirements |> Enum.concat() |> Enum.uniq(), "csp_nonces" => %{ style: conn.assigns[csp_nonce_assign_key[:style]], script: conn.assigns[csp_nonce_assign_key[:script]] } } end defp metrics_page(:skip, _), do: [] defp metrics_page(metrics, metrics_history) do session = %{ metrics: metrics, metrics_history: metrics_history } [metrics: {Phoenix.LiveDashboard.MetricsPage, session}] end defp request_logger_page(_conn, {false, _}), do: [] defp request_logger_page(conn, {true, cookie_domain}) do session = %{ request_logger: Phoenix.LiveDashboard.RequestLogger.param_key(conn), cookie_domain: cookie_domain } [request_logger: {Phoenix.LiveDashboard.RequestLoggerPage, session}] end defp ecto_repos(nil), do: nil defp ecto_repos(false), do: [] defp ecto_repos(repos), do: List.wrap(repos) defp initialize_page(module, opts) do case module.init(opts) do {:ok, session} -> {session, []} {:ok, session, requirements} -> validate_requirements(module, requirements) {session, requirements} end end defp validate_requirements(module, requirements) do Enum.each(requirements, fn {key, value} when key in [:application, :module, :process] and is_atom(value) -> :ok other -> raise "unknown requirement #{inspect(other)} from #{inspect(module)}" end) end end