defmodule Phoenix.LiveDashboard.PageBuilder do @moduledoc """ Page builder is the default mechanism for building custom dashboard pages. Each dashboard page is a LiveView with additional callbacks for customizing the menu appearance and the automatic refresh. A simple and straight-forward example of a custom page is the `Phoenix.LiveDashboard.EtsPage` that ships with the dashboard: defmodule Phoenix.LiveDashboard.EtsPage do @moduledoc false use Phoenix.LiveDashboard.PageBuilder @impl true def menu_link(_, _) do {:ok, "ETS"} end @impl true def render(assigns) do ~H\""" <.live_table id="ets-table" dom_id="ets-table" page={@page} title="ETS" row_fetcher={&fetch_ets/2} row_attrs={&row_attrs/1} rows_name="tables" > <:col field={:name} header="Name or module" /> <:col field={:protection} /> <:col field={:type} /> <:col field={:size} text_align="right" sortable={:desc} /> <:col field={:memory} text_align="right" sortable={:desc} :let={ets}> <%= format_words(ets[:memory]) %> <:col field={:owner} :let={ets} > <%= encode_pid(ets[:owner]) %> \""" end defp fetch_ets(params, node) do %{search: search, sort_by: sort_by, sort_dir: sort_dir, limit: limit} = params # Here goes the code that goes through all ETS tables, searches # (if not nil), sorts, and limits them. # # It must return a tuple where the first element is list with # the current entries (up to limit) and an integer with the # total amount of entries. # ... end defp row_attrs(table) do [ {"phx-click", "show_info"}, {"phx-value-info", encode_ets(table[:id])}, {"phx-page-loading", true} ] end end Once a page is defined, it must be declared in your `live_dashboard` route as follows: live_dashboard "/dashboard", additional_pages: [ route_name: MyAppWeb.MyCustomPage ] Or alternatively: live_dashboard "/dashboard", additional_pages: [ route_name: {MyAppWeb.MyCustomPage, some_option: ...} ] The second argument of the tuple will be given to the `c:init/1` callback. If not tuple is given, `c:init/1` will receive an empty list. ## Options for the use macro The following options can be given when using the `PageBuilder` module: * `refresher?` - Boolean to enable or disable the automatic refresh in the page. ## Components A page can return any valid HEEx template in the `render/1` callback, and it can use the components listed with this page too. We currently support `card/1`, `fields_card/1`, `row/1`, `shared_usage_card/1`, and `usage_card/1`; and the live components `live_layered_graph/1`, `live_nav_bar/1`, and `live_table/1`. ## Helpers Some helpers are available for page building. The supported helpers are: `live_dashboard_path/2`, `live_dashboard_path/3`, `encode_app/1`, `encode_ets/1`, `encode_pid/1`, `encode_port/1`, and `encode_socket/1`. ## Custom Hooks If your page needs to register custom hooks, you can use the `register_after_opening_head_tag/2` function. Because the hooks need to be available on the dead render in the layout, before the LiveView's LiveSocket is configured, your need to do this inside an `on_mount` hook: ```elixir defmodule MyAppWeb.MyLiveDashboardHooks do import Phoenix.LiveView import Phoenix.Component alias Phoenix.LiveDashboard.PageBuilder def on_mount(:default, _params, _session, socket) do {:cont, PageBuilder.register_after_opening_head_tag(socket, &after_opening_head_tag/1)} end defp after_opening_head_tag(assigns) do ~H\"\"\" \"\"\" end end defmodule MyAppWeb.MyCustomPage do ... end ``` And then add it to the list of `on_mount` hooks in the `live_dashboard` router configuration: ```elixir live_dashboard "/dashboard", additional_pages: [ route_name: MyAppWeb.MyCustomPage ], on_mount: [ MyAppWeb.MyLiveDashboardHooks ] ``` The LiveDashboard provides a function `window.LiveDashboard.registerCustomHooks({ ... })` that you can call with an object of hook declarations. Note that in order to use external libraries, you will either need to include them from a CDN, or bundle them yourself and include them from your app's static paths. > #### A note on CSPs and libraries {: .info} > > Phoenix LiveDashboard supports CSP nonces for its own assets, configurable using the > `Phoenix.LiveDashboard.Router.live_dashboard/2` macro by setting the `:csp_nonce_assign_key` > option. If you are building a library, ensure that you render those CSP nonces on any scripts, > styles or images of your page. The nonces are passed to your custom page under the `:csp_nonces` assign > and also available in the `after_opening_head_tag` component. > > You should use those when including scripts or styles like this: > > ```heex > > > > > ``` > > This ensures that your custom page can be used when a CSP is in place using the mechanism > supported by Phoenix LiveDashboard. > > If your custom page needs a different CSP policy, for example due to inline styles set by scripts, > please consider documenting these requirements. """ use Phoenix.Component defstruct info: nil, module: nil, node: nil, params: nil, route: nil, tick: 0, allow_destructive_actions: false @type session :: map @type requirements :: [{:application | :process | :module, atom()}] @type unsigned_params :: map @type capabilities :: %{ applications: [atom()], modules: [atom()], processes: [atom()], dashboard_running?: boolean(), system_info: nil | binary() } alias Phoenix.LiveDashboard.{ ChartComponent, LayeredGraphComponent, NavBarComponent, TableComponent } @doc """ Callback invoked when a page is declared in the router. It receives the router options and it must return the tuple `{:ok, session, requirements}`. The page session will be serialized to the client and received on `mount`. The requirements is an optional keyword to detect the state of the node. The result of this detection will be passed as second argument in the `c:menu_link/2` callback. The possible values are: * `:applications` list of applications that are running or not. * `:modules` list of modules that are loaded or not. * `:pids` list of processes that alive or not. """ @callback init(term()) :: {:ok, session()} | {:ok, session(), requirements()} @doc """ Callback invoked when a page is declared in the router. It receives the session returned by the `c:init/1` callback and the capabilities of the current node. The possible return values are: * `{:ok, text}` when the link should be enable and text to be shown. * `{:disabled, text}` when the link should be disable and text to be shown. * `{:disabled, text, more_info_url}` similar to the previous one but it also includes a link to provide more information to the user. * `:skip` when the link should not be shown at all. """ @callback menu_link(session(), capabilities()) :: {:ok, String.t()} | {:disabled, String.t()} | {:disabled, String.t(), String.t()} | :skip @callback mount(unsigned_params(), session(), socket :: Socket.t()) :: {:ok, Socket.t()} | {:ok, Socket.t(), keyword()} @callback render(assigns :: Socket.assigns()) :: Phoenix.LiveView.Rendered.t() @callback handle_params(unsigned_params(), uri :: String.t(), socket :: Socket.t()) :: {:noreply, Socket.t()} @doc """ Callback invoked when an event is called. Note that `show_info` event is handled automatically by `Phoenix.LiveDashboard.PageBuilder`, but the `info` parameter (`phx-value-info`) needs to be encoded with one of the `encode_*` helper functions. For more details, see [`Phoenix.LiveView bindings`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#module-bindings) """ @callback handle_event(event :: binary, unsigned_params(), socket :: Socket.t()) :: {:noreply, Socket.t()} | {:reply, map, Socket.t()} @callback handle_info(msg :: term, socket :: Socket.t()) :: {:noreply, Socket.t()} @doc """ Callback invoked when the automatic refresh is enabled. """ @callback handle_refresh(socket :: Socket.t()) :: {:noreply, Socket.t()} @optional_callbacks mount: 3, handle_params: 3, handle_event: 3, handle_info: 2, handle_refresh: 1 @doc """ Table live component. You can see it in use the applications, processes, sockets pages and many others. """ @doc type: :component attr :id, :any, required: true, doc: "Because is a stateful `Phoenix.LiveComponent` an unique id is needed." attr :page, __MODULE__, required: true, doc: "Dashboard page" slot :col, required: true, doc: "Columns for the table" do attr :field, :atom, required: true, doc: "Identifier for the column" attr :sortable, :atom, values: [:asc, :desc], doc: """ When set, the column header is clickable and it fetches again rows with the new order. Required for at least one column. """ attr :header, :string, doc: "Label to show in the current column. Default value is calculated from `:field`." attr :text_align, :string, values: ~w[left center right justify], doc: "Text align for text in the column. Default: `nil`." end attr :row_fetcher, :any, required: true, doc: """ A function which receives the params and the node and returns a tuple with the rows and the total number: `(params(), node() -> {list(), integer() | binary()})`. Optionally, if the function needs to keep a state, it can be defined as a tuple where the first element is a function and the second is the initial state. In this case, the function will receive the state as third argument and must return a tuple with the rows, the total number, and the new state for the following call: `{(params(), node(), term() -> {list(), integer() | binary(), term()}), term()}` """ attr :rows_name, :string, doc: "A string to name the representation of the rows. Default is calculated from the current page." attr :row_attrs, :any, default: nil, doc: """ A list with the HTML attributes for the table row. It can be also a function that receive the row as argument and returns a list of 2 element tuple with HTML attribute name and value. """ attr :default_sort_by, :any, default: nil, doc: "The default column to sort by to. Defaults to the first sortable column." attr :title, :string, required: true, doc: "The title of the table." attr :limit, :any, default: [50, 100, 500, 1000, 5000], doc: "May be set to `false` to disable the `limit`." attr :search, :boolean, default: true, doc: "A boolean indicating if the search functionality is enabled." attr :hint, :string, default: nil, doc: "A textual hint to show close to the title." attr :dom_id, :string, default: nil, doc: "id attribute for the HTML the main tag." @spec live_table(assigns :: Socket.assigns()) :: Phoenix.LiveView.Rendered.t() def live_table(assigns) do ~H""" <.live_component module={TableComponent} {assigns} /> """ end @doc """ Nav bar live component. You can see it in use the Metrics and Ecto info pages. """ @doc type: :component attr :id, :any, required: true, doc: "Because is a stateful `Phoenix.LiveComponent` an unique id is needed." attr :page, __MODULE__, required: true, doc: "Dashboard page" attr :nav_param, :string, default: "nav", doc: """ An atom that configures the navigation parameter. It is useful when two nav bars are present in the same page. """ attr :extra_params, :list, default: [], doc: """ A list of strings representing the parameters that should stay when a tab is clicked. By default the nav ignores all params, except the current node if any. """ attr :style, :atom, values: [:pills, :bar], doc: "Style for the nav bar" slot :item, required: true, doc: "HTML to be rendered when the tab is selected" do attr :name, :string, required: true, doc: "Value used in the URL when the tab is selected" attr :label, :string, doc: "Title of the tab. If it is not present, it will be calculated from `name`" attr :method, :string, values: ~w(patch navigate href redirect), doc: "Method used to update" end @spec live_nav_bar(assigns :: Socket.assigns()) :: Phoenix.LiveView.Rendered.t() def live_nav_bar(assigns) do ~H""" <.live_component module={NavBarComponent} {assigns} /> """ end @doc """ Hint pop-up text component """ @doc type: :component attr :text, :string, required: true, doc: "Text to show in the hint" @spec hint(assigns :: Socket.assigns()) :: Phoenix.LiveView.Rendered.t() def hint(assigns) do ~H"""
<%= @text %>
""" end @doc """ Card title component. """ @doc type: :component attr :title, :string, default: nil, doc: "The title above the card." attr :hint, :string, default: nil, doc: "A textual hint to show close to the title." @spec card_title(assigns :: Socket.assigns()) :: Phoenix.LiveView.Rendered.t() def card_title(assigns) do ~H"""
<%= @title %> <.hint :if={@hint} text={@hint} />
""" end @doc """ Card component. You can see it in use the Home and OS Data pages. """ @doc type: :component slot :inner_block, required: true, doc: "The value that the card will show." attr :title, :string, default: nil, doc: "The title above the card." attr :hint, :string, default: nil, doc: "A textual hint to show close to the title." attr :inner_title, :string, default: nil, doc: "The title inside the card." attr :inner_hint, :string, default: nil, doc: "A textual hint to show close to the inner title." attr :dom_id, :string, default: nil, doc: "id attribute for the HTML the main tag." @spec card(assigns :: Socket.assigns()) :: Phoenix.LiveView.Rendered.t() def card(assigns) do ~H""" <.card_title title={@title} hint={@hint} /> """ end @doc """ Fields card component. You can see it in use the Home page in the Environment section. """ @doc type: :component attr :fields, :list, required: true, doc: "A list of key-value elements that will be shown inside the card." attr :title, :string, default: nil, doc: "The title above the card." attr :hint, :string, default: nil, doc: "A textual hint to show close to the title." attr :inner_title, :string, default: nil, doc: "The title inside the card." attr :inner_hint, :string, default: nil, doc: "A textual hint to show close to the inner title." def fields_card(assigns) do ~H""" <%= if @fields && not Enum.empty?(@fields) do %> <.card_title title={@title} hint={@hint} />
<%= @inner_title %> <.hint :if={@inner_hint} text={@inner_hint} />
<%= k %>
<% end %> """ end @doc """ Row component. You can see it in use the Home page and OS Data pages. """ @doc type: :component slot :col, required: true, doc: "A list of components. It can receive up to 3 components." <> " Each element will be one column." @spec row(assigns :: Socket.assigns()) :: Phoenix.LiveView.Rendered.t() def row(assigns) do assigns = row_validate_columns_length(assigns) ~H"""
<%= render_slot(col) %>
""" end defp row_validate_columns_length(assigns) do columns_length = length(assigns[:col] || []) if columns_length > 0 and columns_length < 4 do assign(assigns, :columns_class, div(12, columns_length)) else raise ArgumentError, "row component must have at least 1 and at most 3 :col, got: " <> inspect(columns_length) end end @doc """ Usage card component. You can see it in use the Home page and OS Data pages. """ @doc type: :component attr :title, :string, default: nil, doc: "The title above the card." attr :hint, :string, default: nil, doc: "A textual hint to show close to the title." attr :dom_id, :string, required: true, doc: "A unique identifier for all usages in this card." attr :csp_nonces, :any, required: true, doc: "A copy of CSP nonces (`@csp_nonces`) used to render the page safely" slot :usage, required: true, doc: "List of usages to show" do attr :current, :integer, required: true, doc: "The current value of the usage." attr :limit, :integer, required: true, doc: "The max value of usage." attr :dom_id, :string, required: true, doc: "An unique identifier for the usage that will be concatenated to `dom_id`." attr :percent, :string, doc: "The used percent of the usage." attr :title, :string, doc: "The title of the usage." attr :hint, :string, doc: "A textual hint to show close to the usage title." end @spec usage_card(assigns :: Socket.assigns()) :: Phoenix.LiveView.Rendered.t() def usage_card(assigns) do ~H""" <.card_title title={@title} hint={@hint} />
<%= for usage <- @usage do %> <.title_bar_component dom_id={"#{@dom_id}-#{usage.dom_id}"} percent={usage.percent} csp_nonces={@csp_nonces} >
<%= usage.title %> <.hint :if={usage[:hint]} text={usage[:hint]} />
<%= usage.current %> / <%= usage.limit %> <%= usage[:percent] %>%
<% end %>
""" end @doc false attr :color, :string, default: "blue" attr :dom_id, :string, required: true attr :percent, :float, required: true attr :csp_nonces, :any, required: true slot :inner_block, required: true defp title_bar_component(assigns) do ~H"""
<%= render_slot(@inner_block) %>
""" end @doc """ Shared usage card component. You can see it in use the Home page and OS Data pages. """ @doc type: :component attr :usages, :list, required: true, doc: """ A list of `Map` with the following keys: * `:data` - A list of tuples with 4 elements with the following data: `{usage_name, usage_percent, color, hint}` * `:dom_id` - Required. Usage identifier. * `:title`- Bar title. """ attr :total_data, :any, required: true, doc: "A list of tuples with 4 elements with following data: `{usage_name, usage_value, color, hint}`" attr :total_legend, :string, required: true, doc: "The legent of the total usage." attr :total_usage, :string, required: true, doc: "The value of the total usage." attr :dom_id, :string, default: nil, doc: "id attribute for the HTML the main tag." attr :csp_nonces, :any, required: true, doc: "A copy of CSP nonces (`@csp_nonces`) used to render the page safely" attr :title, :string, default: nil, doc: "The title above the card." attr :hint, :string, default: nil, doc: "A textual hint to show close to the title." attr :inner_title, :string, default: nil, doc: "The title inside the card." attr :inner_hint, :string, default: nil, doc: "A textual hint to show close to the inner title." attr :total_formatter, :any, default: nil, doc: ~s @spec shared_usage_card(assigns :: Socket.assigns()) :: Phoenix.LiveView.Rendered.t() def shared_usage_card(assigns) do ~H""" <.card_title title={@title} hint={@hint} />
<.card_title title={@inner_title} hint={@inner_hint} />
<%= usage[:title] %> <%= for {{name, value, color, _desc}, index} <- Enum.with_index(usage.data) do %>
<% end %>
<%= for {name, value, color, hint} <- @total_data do %>
<%= name %><.hint :if={hint} text={hint} /> <%= if @total_formatter, do: @total_formatter.(value), else: total_formatter(value) %>
<% end %>
<%= @total_legend %> <%= @total_usage %>
""" end defp total_formatter(value), do: "#{value} %" @doc """ A component for drawing layered graphs. This is useful to represent pipelines like we have on [BroadwayDashboard](https://hexdocs.pm/broadway_dashboard) where each layer points to nodes of the layer below. It draws the layers from top to bottom. The calculation of layers and positions is done automatically based on options. [INSERT LVATTRDOCS] ## Examples iex> layers = [ ...> [ ...> %{ ...> id: "a1", ...> data: "a1", ...> children: ["b1"] ...> } ...> ], ...> [ ...> %{ ...> id: "b1" ...> data: %{ ...> detail: 0, ...> label: "b1" ...> }, ...> children: [] ...> } ...> ] ...> ] """ @doc type: :component attr :id, :any, required: true, doc: "Because is a stateful `Phoenix.LiveComponent` an unique id is needed." attr :title, :string, default: nil, doc: "The title of the component." attr :hint, :string, default: nil, doc: "A textual hint to show close to the title." attr :layers, :list, required: true, doc: """ A graph of layers with nodes. They represent our graph structure (see example). Each layer is a list of nodes, where each node has the following fields: - `:id` - The ID of the given node. - `:children` - The IDs of children nodes. - `:data` - A string or a map. If it's a map, the required fields are `detail` and `label`. """ attr :show_grid?, :boolean, default: false, doc: "Enable or disable the display of a grid. This is useful for development." attr :y_label_offset, :integer, default: 5, doc: "The \"y\" offset of label position relative to the center of its circle." attr :y_detail_offset, :integer, default: 18, doc: "The \"y\" offset of detail position relative to the center of its circle." attr :background, :any, doc: """ A function that calculates the background for a node based on it's data. Default: `fn _node_data -> \"gray\" end`." """ attr :format_label, :any, doc: """ A function that formats the label. Defaults to a function that returns the label or data if data is binary. """ attr :format_detail, :any, doc: """ A function that formats the detail field. This is only going to be called if data is a map. Default: `fn node_data -> node_data.detail end`. """ @spec live_layered_graph(assigns :: Socket.assigns()) :: Phoenix.LiveView.Rendered.t() def live_layered_graph(assigns) do ~H""" <.live_component module={LayeredGraphComponent} id={@id} {assigns} /> """ end @doc """ List of label value. You can see it in use in the modal in Ports or Processes page. """ @doc type: :component slot :elem, required: true, doc: "Value for each element of the list" do attr :label, :string, required: true, doc: "Label for the elem" end def label_value_list(assigns) do ~H"""
<%= elem.label %>
<%= render_slot(elem) %>
""" end defp first_elem_class(0), do: "border-top-0" defp first_elem_class(_), do: nil @doc false attr :id, :string, required: true, doc: "Because is a stateful `Phoenix.LiveComponent` an unique id is needed." attr :title, :string, required: true, doc: "Title of the modal" attr :return_to, :string, required: true, doc: "Path to return when closing the modal" slot :inner_block, required: true, doc: "Content to show in the modal" def live_modal(assigns) do ~H""" <.live_component module={Phoenix.LiveDashboard.ModalComponent} id={@id} title={@title} return_to={@return_to} > <%= render_slot(@inner_block) %> """ end @doc false attr :id, :string, required: true, doc: "Because is a stateful `Phoenix.LiveComponent` an unique id is needed." attr :data, :list, default: [], doc: """ Temporary list of points to show in the chart. Each element should be the format `{optional_label, x, y}`. New points can be added using the function `send_data_to_chart/2` in real time. """ attr :title, :string, required: true, doc: "Title of the chart" attr :hint, :string, default: nil, doc: "A textual hint to show close to the title." attr :kind, :atom, values: [:counter, :last_value, :sum, :summary, :distribution], doc: "Kind of chart to use." attr :label, :string, default: nil, doc: "Default label to use in the chart." attr :tags, :list, default: [], doc: "Optional list of tags." attr :prune_threshold, :integer, default: 1_000, doc: "Number of points to keep before pruning." attr :unit, :string, default: "", doc: "The unit that represent the chart." attr :bucket_size, :integer, doc: "Bucket size for histogram. Default: 20 when `kind = :histogram`, otherwise `nil`." attr :full_width, :boolean, default: false, doc: "Size of the chart" def live_chart(assigns) do assigns = assign_new(assigns, :bucket_size, fn -> if assigns.kind == :histogram, do: 20, else: nil end) ~H""" <.live_component module={ChartComponent} id={@id} title={@title} hint={@hint} kind={@kind} label={@label} tags={@tags} prune_threshold={@prune_threshold} unit={@unit} bucket_size={@bucket_size} full_width={@full_width} /> """ end @doc false def send_data_to_chart(id, data) do Phoenix.LiveView.send_update(ChartComponent, id: id, data: data) end ## Helpers @doc """ Encodes Sockets for URLs. ## Example This function can be used to encode `@socket` for an event value: