DEV Community

Cover image for Phoenix Live Dashboard custom page using the table component
Daniel Kukula
Daniel Kukula

Posted on • Edited on

8 3

Phoenix Live Dashboard custom page using the table component

Today I played with custom live dashboard pages, That's a short documentation how to use it. We will implement a page that shows the currently running system processes on the box running our elixir system.
Documentation can be found on hexdocs.
Most up to date code is attached in the gist.
First we need to create a new module:

defmodule Phoenix.LiveDashboard.OsProcesses do
  @moduledoc false
  use Phoenix.LiveDashboard.PageBuilder

end
Enter fullscreen mode Exit fullscreen mode

To get the list I'll use the output of ps aux command.
I'll cheat here a bit and use jq to convert the page to json which later can be easily decoded by Jason. This also shows how to use more complicated commands. We can of course parse the string in elixir.
To use pipes inside the command I had to use the os module with a character list passed as an option. In the gist the ps parser is written in pure elixir but for simplicity I'll leave the jq version here. On windows you can play with tasklist the output is a bit similar but there are spaces on the first line so you may want to parse it a bit different.

  defp fetch_processes(_params, _node) do
    data = 
    :os.cmd('ps aux | jq -sR \'[sub("\n$";"") | splits("\n") | sub("^ +";"") | [splits(" +")]] | .[0] as $header | .[1:] | [.[] | [. as $x | range($header | length) | {"key": $header[.], "value": $x[.]}] | from_entries]\'')
    |> Jason.decode!(%{keys: :atoms!})

    {data, length(data)}
  end
Enter fullscreen mode Exit fullscreen mode

The return value of this function must be a tuple with rows and the length of the rows.
Next function we need to write is the columns definitions

  defp columns() do
    [
      %{
        field: :"PID",
        sortable: :asc
      },
      %{
        field: :"TTY"
      },
      %{
        field: :"TIME",
        cell_attrs: [class: "text-right"],
      },
      %{
        field: :"USER",
      },
      %{
        field: :"%CPU",
        sortable: :desc
      },
      %{
        field: :"%MEM",
        sortable: :desc
      },
      %{
        field: :"VSZ",
        sortable: :asc
      },
      %{
        field: :"RSS",
        sortable: :asc
      },
      %{
        field: :"STAT",
      },
      %{
        field: :"START",
        sortable: :asc
      },
      %{
        field: :"COMMAND",
      },
    ]
  end
Enter fullscreen mode Exit fullscreen mode

Both of this functions need to be passed to the render_page/1 callback

  @impl true
  def render_page(_assigns) do
    table(
      columns: columns(),
      id: :os_processes,
      row_attrs: &row_attrs/1,
      row_fetcher: &fetch_processes/2,
      rows_name: "tables",
      title: "OS processes",
      sort_by: :"PID"
    )
  end
Enter fullscreen mode Exit fullscreen mode

as we see there is another function required, the row_attrs that returns html attributes for our table rows.
And thats basically it.
Last two pieces of code is the menu_link/2 callback

  @impl true
  def menu_link(_, _) do
    {:ok, "OS Processes"}
  end
Enter fullscreen mode Exit fullscreen mode

and the router entry for our page

    scope "/" do
      pipe_through :browser
    live_dashboard "/dashboard", metrics: ChatterWeb.Telemetry,
    additional_pages: [
    os_processes: Phoenix.LiveDashboard.OsProcesses
  ]
Enter fullscreen mode Exit fullscreen mode

and that basically it - The code can be found on github, with the params used for sorting, limiting and search. Have fun and link any pages you have created.

defmodule Phoenix.LiveDashboard.OsProcesses do
@moduledoc false
use Phoenix.LiveDashboard.PageBuilder
# module requires:
# {:table_parser, "~> 0.1.1"},
# {:number, "~> 1.0.3"},
@ps_cmd "ps"
@stat_map %{
"D" => "uninterruptible sleep (IO)",
"I" => "Idle kernel thread",
"R" => "running or runnable",
"S" => "interruptible sleep (waiting)",
"T" => "stopped, by a job control signal",
"t" => "stopped by debugger",
"W" => "paging",
"X" => "dead",
"Z" => "zombie process",
"<" => "high-priority",
"N" => "low-priority",
"L" => "has pages locked into memory",
"s" => "is a session leader",
"l" => "is multi-threaded",
"+" => "foreground process group."
}
@impl true
def menu_link(_, _) do
{:ok, "OS Processes"}
end
@impl true
def render_page(assigns) do
table(
columns: columns(),
id: :os_processes,
row_attrs: &row_attrs/1,
row_fetcher: &fetch_processes/2,
rows_name: "processes",
title: "OS processes",
sort_by: :"%cpu"
)
end
def processes_callback(ps_params, limit, search, sort_by, sort_dir) do
@ps_cmd
|> System.cmd(ps_params)
|> elem(0)
|> TableParser.parse_table()
|> Enum.sort_by(&sort_value(&1, sort_by), sort_dir)
|> filter_rows(search)
|> Enum.take(limit)
end
def fetch_processes(params, node) do
%{limit: limit, search: search, sort_by: sort_by, sort_dir: sort_dir} = params
ps_params =
columns()
|> Enum.map(&Map.get(&1, :ps, Map.get(&1, :field)))
|> Enum.join(",")
data =
:rpc.call(node, __MODULE__, :processes_callback, [["-eo", ps_params], limit, search, sort_by, sort_dir])
{data, length(data)}
end
defp filter_rows(rows, nil), do: rows
defp filter_rows(rows, search) do
Enum.filter(rows, fn row -> include_row?(row, search) end)
end
defp include_row?(nil, search), do: false
defp include_row?("", search), do: false
defp include_row?(row, search) do
row |> Map.values() |> Enum.join() =~ search
end
defp sort_value(map, key) do
map[key] |> Float.parse() |> elem(0)
end
def decode_stat(stat) do
{:safe,
String.graphemes(stat)
|> Enum.map(fn char -> Map.get(@stat_map, char) end)
|> Enum.join("<br>")}
end
def columns() do
[
%{
field: :pid,
sortable: :asc
},
%{
field: :user
},
%{
field: :"%cpu",
ps: :pcpu,
sortable: :desc,
format: &Kernel.<>(&1, "%")
},
%{
field: :"%mem",
ps: :pmem,
sortable: :desc,
format: &Kernel.<>(&1, "%")
},
%{
field: :vsz,
sortable: :asc,
format: &humanize_kilobyte_number/1
},
%{
field: :rss,
sortable: :asc,
format: &humanize_kilobyte_number/1
},
%{
field: :stat,
format: &decode_stat/1
},
%{
field: :command,
ps: :comm
}
]
end
defp row_attrs(table) do
[
{"phx-page-loading", true}
]
end
defp humanize_number(num_str) do
num_str
|> String.to_integer()
|> Number.SI.number_to_si( unit: "B")
end
defp humanize_kilobyte_number(num_str) do
num_str
|> String.to_integer()
|> Kernel.*(1024)
|> Number.SI.number_to_si( unit: "B")
end
end
view raw os_processes.ex hosted with ❤ by GitHub

Sentry image

Hands-on debugging session: instrument, monitor, and fix

Join Lazar for a hands-on session where you’ll build it, break it, debug it, and fix it. You’ll set up Sentry, track errors, use Session Replay and Tracing, and leverage some good ol’ AI to find and fix issues fast.

RSVP here →

Top comments (2)

Collapse
 
allanmacgregor profile image
Allan MacGregor 🇨🇦

Excellent Great article!!!!

Collapse
 
dkuku profile image
Daniel Kukula

Thanks Allan. I updated it so jq is not needed, I'm parsing the ps output in elixir and I also hooked up the search and filter params

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay