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. One notable difference, however,
is that a page implements a `render_page/1` callback, which must
return one or more page builder components, instead of a `render/1`
callback that returns `~H` or `~L` (deprecated).
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_page(_assigns) do
table(
columns: table_columns(),
id: :ets_table,
row_attrs: &row_attrs/1,
row_fetcher: &fetch_ets/2,
rows_name: "tables",
title: "ETS"
)
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 table_columns() do
[
%{
field: :name,
header: "Name or module",
},
%{
field: :protection
},
%{
field: :type
},
%{
field: :size,
cell_attrs: [class: "text-right"],
sortable: :desc
},
%{
field: :memory,
format: &format_words/1,
sortable: :desc
},
%{
field: :owner,
format: &encode_pid/1
}
]
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.
## Components
A page can only have the components listed with this page.
We currently support `card/1`, `columns/1`, `fields_card/1`,
`layered_graph/1`, `nav_bar/1`, `row/1`, `shared_usage_card/1`, `table/1`,
and `usage_card/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`.
"""
defstruct info: nil,
module: nil,
node: nil,
params: nil,
route: nil,
tick: 0,
allow_destructive_actions: false
@opaque component :: {module, map}
@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.{
CardComponent,
ColumnsComponent,
FieldsCardComponent,
LayeredGraphComponent,
NavBarComponent,
RowComponent,
SharedUsageCardComponent,
TableComponent,
UsageCardComponent
}
@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_page(assigns :: Socket.assigns()) :: component()
@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()}
@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 """
Renders a table component.
It can be rendered in any dashboard page via the `render_page/1` function:
def render_page(assigns) do
table(
columns: table_columns(),
id: @table_id,
row_attrs: &row_attrs/1,
row_fetcher: &fetch_applications/2,
title: "Applications"
)
end
You can see it in use the applications, processes, sockets pages and
many others.
# Options
These are the options supported by the component:
* `:id` - Required. Because is a stateful `Phoenix.LiveComponent` an unique id is needed.
* `:columns` - Required. A `Keyword` list with the following keys:
* `:field` - Required. An identifier for the column.
* `:sortable` - Required for at least one column. Either `:asc` or
`:desc` with the default sorting. When set, the column header is
clickable and it fetches again rows with the new order. Default: `nil`.
* `:header` - Label to show in the current column. Default value is calculated from `:field`.
* `:header_attrs` - A list with HTML attributes for the column header. Default: `[]`.
* `:format` - Function which receives the value and returns the cell information.
Default is the field value itself.
* `:cell_attrs` - A list with HTML attributes for the table cell. Default: `[]`.
* `:row_fetcher` - Required. 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()}`
* `:rows_name` - A string to name the representation of the rows.
Default is calculated from the current page.
* `:row_attrs` - A function that return a list with HTML attributes for the table row. It
receive the row as argument and return a list of 2 element tuple with HTML attribute name
and value. The default function returns an empty list `[]`.
* `:default_sort_by` - The default columnt to sort by to.
Defaults to the first sortable column.
* `:title` - Required. The title of the table.
* `:limit` - A list of integers to limit the number of rows to show.
Default: `[50, 100, 500, 1000, 5000]`. May be set to `false` to disable the `limit`.
* `:search` - A boolean indicating if the search functionality is enabled.
Default: `true`.
* `:hint` - A textual hint to show close to the title. Default: `nil`.
"""
@spec table(keyword()) :: component()
def table(assigns) do
assigns =
assigns
|> Map.new()
|> TableComponent.normalize_params()
{TableComponent, assigns}
end
@doc """
Renders a nav bar.
It can be rendered in any dashboard page via the `render_page/1` function:
def render_page(assigns) do
nav_bar(
items: [
phoenix_metrics: [
name: "Phoenix Metrics",
render: fn -> table(...) end
],
vm_metrics: [
name: "VM Metrics",
render: fn -> table(...expensive_parameters) end
]
]
)
end
You can see it in use the Metrics and Ecto info pages.
## Options
* `nav_param` - An atom that configures the navigation parameter.
It is useful when two nav bars are present in the same page.
* `extra_params` - 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.
"""
@spec nav_bar(keyword()) :: component()
def nav_bar(assigns) do
assigns =
assigns
|> Map.new()
|> NavBarComponent.normalize_params()
{NavBarComponent, assigns}
end
@doc """
Renders a card component.
It can be rendered in any dashboard page via the `render_page/1` function:
def render_page(assigns) do
card(
title: "Run queues",
inner_title: "Total",
class: ["additional-class"],
value: 1.5
)
end
You can see it in use the Home and OS Data pages.
# Options
These are the options supported by the component:
* `:value` - Required. The value that the card will show.
* `:title` - The title above the card.
Default: `nil`.
* `:inner_title` - The title inside the card.
Default: `nil`.
* `:hint` - A textual hint to show close to the title.
Default: `nil`.
* `:inner_hint` - A textual hint to show close to the inner title.
Default: `nil`.
* `:class` - A list of additional css classes that will be added along banner-card class.
Default: `[]`.
"""
@spec card(keyword()) :: component()
def card(assigns) do
assigns =
assigns
|> Map.new()
|> CardComponent.normalize_params()
{CardComponent, assigns}
end
@doc """
Renders a fields card component.
It can be rendered in any dashboard page via the `render_page/1` function:
def render_page(assigns) do
fields_card(
title: "Run queues",
inner_title: "Total",
fields: ["USER": "...", "ROOTDIR": "..."]
)
end
You can see it in use the Home page in the Environment section.
# Options
These are the options supported by the component:
* `:fields` - Required. A list of key-value elements that will be shown inside the card.
* `:title` - The title above the card.
Default: `nil`.
* `:inner_title` - The title inside the card.
Default: `nil`.
* `:hint` - A textual hint to show close to the title.
Default: `nil`.
* `:inner_hint` - A textual hint to show close to the inner title.
Default: `nil`.
"""
@spec fields_card(keyword()) :: component()
def fields_card(assigns) do
assigns =
assigns
|> Map.new()
|> FieldsCardComponent.normalize_params()
{FieldsCardComponent, assigns}
end
@doc """
Renders a column component.
It can be rendered in any dashboard page via the `render_page/1` function:
def render_page(assigns) do
columns(
components: [
card(...),
card_usage(...)
]
)
end
You can see it in use the Home page and OS Data pages.
# Options
These are the options supported by the component:
* `:components` - Required. A list of components.
It can receive up to 3 components.
Each element will be one column.
"""
@spec columns(keyword()) :: component()
def columns(assigns) do
assigns =
assigns
|> Map.new()
|> ColumnsComponent.normalize_params()
{ColumnsComponent, assigns}
end
@doc """
Renders a row component.
It can be rendered in any dashboard page via the `render_page/1` function:
def render_page(assigns) do
row(
components: [
card(...),
columns(...)
]
)
end
You can see it in use the Home page and OS Data pages.
# Options
These are the options supported by the component:
* `:components` - Required. A list of components.
It can receive up to 3 components.
Each element will be one column.
"""
@spec row(keyword()) :: component()
def row(assigns) do
assigns =
assigns
|> Map.new()
|> RowComponent.normalize_params()
{RowComponent, assigns}
end
@doc """
Renders a usage card component.
It can be rendered in any dashboard page via the `render_page/1` function:
def render_page(assigns) do
usage_card(
usages: [
%{
current: 10,
limit: 150,
dom_sub_id: "1",
title: "Memory",
percent: "13"
}
],
dom_id: "memory"
)
end
You can see it in use the Home page and OS Data pages.
# Options
These are the options supported by the component:
* `:usages` - Required. A list of `Map` with the following keys:
* `:current` - Required. The current value of the usage.
* `:limit` - Required. The max value of usage.
* `:dom_sub_id` - Required. An unique identifier for the usage that will be concatenated to `dom_id`.
* `:percent` - The used percent if the usage. Default: `nil`.
* `:title` - Required. The title of the usage.
* `:hint` - A textual hint to show close to the usage title. Default: `nil`.
* `:dom_id` - Required. A unique identifier for all usages in this card.
* `:title` - The title of the card. Default: `nil`.
* `:hint` - A textual hint to show close to the card title. Default: `nil`.
"""
@spec usage_card(keyword()) :: component()
def usage_card(assigns) do
assigns =
assigns
|> Map.new()
|> UsageCardComponent.normalize_params()
{UsageCardComponent, assigns}
end
@doc """
Renders a shared usage card component.
It can be rendered in any dashboard page via the `render_page/1` function:
def render_page(assigns) do
shared_usage_card(
usages: [
%{
data: [
{"Atoms", 1.4, "green", nil},
{"Binary", 9.1, "blue", nil},
{"Code", 31.5, "purple", nil},
{"ETS", 3.6, "yellow", nil},
{"Processes", 25.8, "orange", nil},
{"Other", 28.5, "dark-gray", nil}
],
dom_sub_id: "total"
}
],
dom_id: "memory",
total_data: [
{"Atoms", 737513, "green", nil},
{"Binary", 4646392, "blue", nil},
{"Code", 16060819, "purple", nil},
{"ETS", 1845584, "yellow", nil},
{"Processes", 13146728, "orange", nil},
{"Other", 14559276, "dark-gray", nil}
],
total_legend: "Total usage:"
total_usage: "47.4 MB"
)
end
You can see it in use the Home page and OS Data pages.
# Options
These are the options supported by the component:
* `:usages` - Required. 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_sub_id` - Required. Usage identifier.
* `:title`- Bar title.
* `:total_data` - Required. A list of tuples with 4 elements with following data:
`{usage_name, usage_value, color, hint}`
* `:total_legend` - Required. The legent of the total usage.
* `:total_usage` - Required. The value of the total usage.
* `:dom_id` - Required. A unique identifier for all usages in this card.
* `:title` - The title above the card. Default: `nil`.
* `:inner_title` - The title inside the card. Default: `nil`.
* `:hint` - A textual hint to show close to the title. Default: `nil`.
* `:inner_hint` - A textual hint to show close to the inner title. Default: `nil`.
* `:total_formatter` - A function that format the `total_usage`. Default: `&("\#{&1} %")`.
"""
@spec shared_usage_card(keyword()) :: component()
def shared_usage_card(assigns) do
assigns =
assigns
|> Map.new()
|> SharedUsageCardComponent.normalize_params()
{SharedUsageCardComponent, assigns}
end
@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.
## Options
* `:title` - The title of the component. Default: `nil`.
* `:hint` - A textual hint to show close to the title. Default: `nil`.
* `:layers` - 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`.
* `:show_grid?` - Enable or disable the display of a grid. This
is useful for development. Default: `false`.
* `:y_label_offset` - The "y" offset of label position relative to the
center of its circle. Default: `5`.
* `:y_detail_offset` - The "y" offset of detail position relative to the
center of its circle. Default: `18`.
* `:background` - A function that calculates the background for a
node based on it's data. Default: `fn _node_data -> "gray" end`.
* `:format_label` - A function that formats the label. Defaults
to a function that returns the label or data if data is binary.
* `:format_detail` - 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`.
## Examples
iex> layers = [
...> [
...> %{
...> id: "a1",
...> data: "a1",
...> children: ["b1"]
...> }
...> ],
...> [
...> %{
...> id: "b1"
...> data: %{
...> detail: 0,
...> label: "b1"
...> },
...> children: []
...> }
...> ]
...> ]
iex> layered_graph(layers: layers, title: "My Graph", hint: "A simple graph")
"""
@spec layered_graph(keyword()) :: component()
def layered_graph(assigns) do
assigns =
assigns
|> Map.new()
|> LayeredGraphComponent.normalize_params()
{LayeredGraphComponent, assigns}
end
## Helpers
@doc """
Encodes Sockets for URLs.
## Example
This function can be used to encode `@socket` for an event value:
<button phx-click="show-info" phx-value-info=<%= encode_socket(@socket) %>/>
"""
@spec encode_socket(port() | binary()) :: binary()
def encode_socket(ref) when is_port(ref) do
~c"#Port" ++ rest = :erlang.port_to_list(ref)
"Socket#{rest}"
end
def encode_socket(ref) when is_binary(ref) do
ref
end
@doc """
Encodes ETSs references for URLs.
## Example
This function can be used to encode an ETS reference for an event value:
<button phx-click="show-info" phx-value-info=<%= encode_ets(@reference) %>/>
"""
@spec encode_ets(reference()) :: binary()
def encode_ets(ref) when is_reference(ref) do
~c"#Ref" ++ rest = :erlang.ref_to_list(ref)
"ETS#{rest}"
end
@doc """
Encodes PIDs for URLs.
## Example
This function can be used to encode a PID for an event value:
<button phx-click="show-info" phx-value-info=<%= encode_pid(@pid) %>/>
"""
@spec encode_pid(pid()) :: binary()
def encode_pid(pid) when is_pid(pid) do
"PID#{:erlang.pid_to_list(pid)}"
end
@doc """
Encodes Port for URLs.
## Example
This function can be used to encode a Port for an event value:
<button phx-click="show-info" phx-value-info=<%= encode_port(@port) %>/>
"""
@spec encode_port(port()) :: binary()
def encode_port(port) when is_port(port) do
port
|> :erlang.port_to_list()
|> tl()
|> List.to_string()
end
@doc """
Encodes an application for URLs.
## Example
This function can be used to encode an application for an event value:
<button phx-click="show-info" phx-value-info=<%= encode_app(@my_app) %>/>
"""
@spec encode_app(atom()) :: binary()
def encode_app(app) when is_atom(app) do
"App<#{app}>"
end
@doc """
Computes a router path to the current page.
"""
@spec live_dashboard_path(Socket.t(), page :: %__MODULE__{}) :: binary()
def live_dashboard_path(socket, %{route: route, node: node, params: params}) do
live_dashboard_path(socket, route, node, params, params)
end
@doc """
Computes a router path to the current page with merged params.
"""
@spec live_dashboard_path(Socket.t(), page :: %__MODULE__{}, map() | Keyword.t()) :: binary()
def live_dashboard_path(socket, %{route: route, node: node, params: old_params}, extra) do
new_params = Enum.into(extra, old_params, fn {k, v} -> {Atom.to_string(k), v} end)
live_dashboard_path(socket, route, node, old_params, new_params)
end
# TODO: Remove this and the conditional on Phoenix v1.7+
@compile {:no_warn_undefined, Phoenix.VerifiedRoutes}
@doc false
def live_dashboard_path(socket, route, node, old_params, new_params) when is_atom(node) do
if function_exported?(socket.router, :__live_dashboard_prefix__, 0) do
new_params = for {key, val} <- new_params, key not in ~w(page node), do: {key, val}
prefix = socket.router.__live_dashboard_prefix__()
path =
if node == node() and is_nil(old_params["node"]) do
"#{prefix}/#{route}"
else
"#{prefix}/#{URI.encode_www_form(to_string(node))}/#{route}"
end
Phoenix.VerifiedRoutes.unverified_path(socket, socket.router, path, new_params)
else
apply(
socket.router.__helpers__(),
:live_dashboard_path,
if node == node() and is_nil(old_params["node"]) do
[socket, :page, route, new_params]
else
[socket, :page, node, route, new_params]
end
)
end
end
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
import Phoenix.LiveView
use Phoenix.Component
import Phoenix.LiveDashboard.PageBuilder
@behaviour Phoenix.LiveDashboard.PageBuilder
refresher? = Keyword.get(opts, :refresher?, true)
def __page_live__(:refresher?) do
unquote(refresher?)
end
def init(opts), do: {:ok, opts}
defoverridable init: 1
end
end
end