Repository: livebook-dev/kino
Pull request #162 (2022-06-29 15:58:33)

Improve edge cases in Kino.DataTable
:cat2:


Patch:
diff --git a/lib/kino/data_table.ex b/lib/kino/data_table.ex
index fd62c1f..e1f2550 100644
--- a/lib/kino/data_table.ex
+++ b/lib/kino/data_table.ex
@@ -101,8 +101,10 @@ defmodule Kino.DataTable do
   defp infer_count({:columns, _, _}, [{_, series} | _]) when is_list(series), do: length(series)
 
   defp infer_count({:columns, _, _}, %{} = tabular) do
-    {_, series} = Enum.at(tabular, 0)
-    length(series)
+    case Enum.at(tabular, 0) do
+      {_, series} when is_list(series) -> length(series)
+      _ -> nil
+    end
   end
 
   defp infer_count({:rows, _, enum}, _) do
@@ -113,12 +115,10 @@ defmodule Kino.DataTable do
   end
 
   defp infer_count({:columns, _, enum}, _) do
-    series = Enum.at(enum, 0)
-
-    case Enumerable.count(series) do
-      {:ok, count} -> count
-      _ -> nil
-    end
+    with {:ok, series} <- Enum.fetch(enum, 0),
+         {:ok, count} <- Enumerable.count(series),
+         do: count,
+         else: (_ -> nil)
   end
 
   defp infer_count(_, _), do: nil
@@ -150,6 +150,8 @@ defmodule Kino.DataTable do
 
     if slicing_fun do
       slicing_fun = fn start, length, cache ->
+        max_length = max(count - start, 0)
+        length = min(length, max_length)
         {slicing_fun.(start, length), count, cache}
       end
 
diff --git a/lib/kino/process.ex b/lib/kino/process.ex
new file mode 100644
index 0000000..ea6060d
--- /dev/null
+++ b/lib/kino/process.ex
@@ -0,0 +1,296 @@
+defmodule Kino.Process do
+  @moduledoc """
+  This module contains kinos for generating visualizations to help
+  introspect your running processes.
+  """
+
+  alias Kino.Markdown
+
+  @type supervisor :: pid() | atom()
+
+  @doc """
+  Generates a visualization of an application tree.
+
+  Given the name of an application as an atom, this function will render the
+  application tree. It is displayed with solid lines denoting supervisor-worker
+  relationships and dotted lines denoting links between processes. The graph
+  rendering supports the following options:
+
+  ## Options
+
+    * `:direction` - defines the direction of the graph visual. The
+      value can either be `:top_down` or `:left_right`. Defaults to `:top_down`.
+
+  ## Examples
+
+  To view the applications running in your instance run:
+
+      :application_controller.which_applications()
+
+  You can then call `Kino.Process.app_tree/1` to render
+  the application tree using using the atom of the application.
+
+      Kino.Process.app_tree(:logger)
+
+  You can also change the direction of the rendering by calling
+  `Kino.Process.app_tree/2` with the `:direction` option.
+
+      Kino.Process.app_tree(:logger, direction: :left_right)
+  """
+  @spec app_tree(atom(), keyword()) :: Markdown.t()
+  def app_tree(application, opts \\ []) when is_atom(application) do
+    {master, root_supervisor} =
+      case :application_controller.get_master(application) do
+        :undefined ->
+          raise ArgumentError,
+                "the provided application #{inspect(application)} does not reference an application"
+
+        master ->
+          case :application_master.get_child(master) do
+            {root, _application} when is_pid(root) ->
+              {master, root}
+
+            _ ->
+              raise ArgumentError,
+                    "the provided application #{inspect(application)} does not have a root supervisor"
+          end
+      end
+
+    direction = direction_from_opts(opts)
+    edges = traverse_supervisor(root_supervisor)
+
+    {:dictionary, dictionary} = Process.info(root_supervisor, :dictionary)
+    [ancestor] = dictionary[:"$ancestors"]
+
+    Kino.Markdown.new("""
+    ```mermaid
+    graph #{direction};
+    application_master(#{inspect(master)}):::supervisor ---> supervisor_ancestor;
+    supervisor_ancestor(#{inspect(ancestor)}):::supervisor ---> 0;
+    #{edges}
+    classDef root fill:#c4b5fd, stroke:#374151, stroke-width:4px;
+    classDef supervisor fill:#c4b5fd, stroke:#374151, stroke-width:1px;
+    classDef worker fill:#93c5fd, stroke:#374151, stroke-width:1px;
+    ```
+    """)
+  end
+
+  @doc """
+  Generates a visualization of a supervision tree.
+
+  The provided supervisor can be either a named process or a PID. The supervision tree
+  is displayed with solid lines denoting supervisor-worker relationships and dotted
+  lines denoting links between processes. The graph rendering supports the following
+  options:
+
+  ## Options
+
+    * `:direction` - defines the direction of the graph visual. The
+      value can either be `:top_down` or `:left_right`. Defaults to `:top_down`.
+
+  ## Examples
+
+  With a supervisor definition like so:
+
+      {:ok, supervisor_pid} =
+        Supervisor.start_link(
+          [
+            {DynamicSupervisor, strategy: :one_for_one, name: MyApp.DynamicSupervisor},
+            {Agent, fn -> [] end}
+          ],
+          strategy: :one_for_one,
+          name: MyApp.Supervisor
+        )
+
+      Enum.each(1..3, fn _ ->
+        DynamicSupervisor.start_child(MyApp.DynamicSupervisor, {Agent, fn -> %{} end})
+      end)
+
+  You can then call `Kino.Process.sup_tree/1` to render
+  the supervision tree using using the PID of the supervisor.
+
+      Kino.Process.sup_tree(supervisor_pid)
+
+  You can also render the supervisor by passing the name of the supervisor
+  if the supervisor was started with a name.
+
+      Kino.Process.sup_tree(MyApp.Supervisor)
+
+  You can also change the direction of the rendering by calling
+  `Kino.Process.sup_tree/2` with the `:direction` option.
+
+      Kino.Process.sup_tree(MyApp.Supervisor, direction: :left_right)
+  """
+  @spec sup_tree(supervisor(), keyword()) :: Markdown.t()
+  def sup_tree(supervisor, opts \\ []) do
+    direction = direction_from_opts(opts)
+    edges = traverse_supervisor(supervisor)
+
+    Kino.Markdown.new("""
+    ```mermaid
+    graph #{direction};
+    #{edges}
+    classDef root fill:#c4b5fd, stroke:#374151, stroke-width:4px;
+    classDef supervisor fill:#c4b5fd, stroke:#374151, stroke-width:1px;
+    classDef worker fill:#93c5fd, stroke:#374151, stroke-width:1px;
+    ```
+    """)
+  end
+
+  defp direction_from_opts(opts) do
+    opts
+    |> Keyword.get(:direction, :top_down)
+    |> convert_direction()
+  end
+
+  defp traverse_supervisor(supervisor) do
+    supervisor =
+      case GenServer.whereis(supervisor) do
+        supervisor_pid when is_pid(supervisor_pid) ->
+          supervisor_pid
+
+        _ ->
+          raise ArgumentError,
+                "the provided identifier #{inspect(supervisor)} does not reference a running process"
+      end
+
+    supervisor_children =
+      try do
+        Supervisor.which_children(supervisor)
+      catch
+        _, _ ->
+          raise ArgumentError, "the provided process #{inspect(supervisor)} is not a supervisor"
+      end
+
+    supervisor_children
+    |> traverse_processes(supervisor, {%{}, 1, %{supervisor => {0, :supervisor}}})
+    |> traverse_links()
+    |> Enum.map_join("\n", fn {_pid_pair, edge} ->
+      generate_mermaid_entry(edge)
+    end)
+  end
+
+  defp convert_direction(:top_down), do: "TD"
+  defp convert_direction(:left_right), do: "LR"
+
+  defp convert_direction(invalid_direction),
+    do: raise(ArgumentError, "expected a valid direction, got: #{inspect(invalid_direction)}")
+
+  defp traverse_processes([{_, pid, :supervisor, _} | rest], supervisor, {rels, idx, pid_keys}) do
+    pid_keys = Map.put(pid_keys, pid, {idx, :supervisor})
+    children = Supervisor.which_children(pid)
+
+    {supervisor_idx, _type} = Map.get(pid_keys, supervisor)
+    supervisor_node = graph_node(supervisor_idx, supervisor, :supervisor)
+
+    worker_node = graph_node(idx, pid, :supervisor)
+    new_connection = graph_edge(supervisor_node, worker_node, :supervisor)
+
+    {subtree_rels, idx, pid_keys} = traverse_processes(children, pid, {%{}, idx + 1, pid_keys})
+
+    updated_rels =
+      rels
+      |> add_rels(subtree_rels)
+      |> add_rel(new_connection)
+
+    traverse_processes(rest, supervisor, {updated_rels, idx, pid_keys})
+  end
+
+  defp traverse_processes([{_, pid, :worker, _} | rest], supervisor, {rels, idx, pid_keys}) do
+    pid_keys = Map.put(pid_keys, pid, {idx, :worker})
+
+    {supervisor_idx, _type} = Map.get(pid_keys, supervisor)
+    supervisor_node = graph_node(supervisor_idx, supervisor, :supervisor)
+
+    worker_node = graph_node(idx, pid, :worker)
+    new_connection = graph_edge(supervisor_node, worker_node, :supervisor)
+
+    traverse_processes(rest, supervisor, {add_rel(rels, new_connection), idx + 1, pid_keys})
+  end
+
+  defp traverse_processes([], _, acc) do
+    acc
+  end
+
+  defp add_rels(rels, additional_rels) do
+    Map.merge(rels, additional_rels, fn _key, edge_1, _edge_2 -> edge_1 end)
+  end
+
+  defp add_rel(rels, edge) do
+    lookup = Enum.sort([edge.node_1.pid, edge.node_2.pid])
+
+    Map.put_new(rels, lookup, edge)
+  end
+
+  defp traverse_links({rels, _idx, pid_keys}) do
+    rels_with_links =
+      Enum.reduce(pid_keys, rels, fn {pid, _idx}, rels_with_links ->
+        {:links, links} = Process.info(pid, :links)
+
+        Enum.reduce(links, rels_with_links, fn link, acc ->
+          add_new_links_to_acc(pid_keys, pid, link, acc)
+        end)
+      end)
+
+    rels_with_links
+  end
+
+  defp add_new_links_to_acc(pid_keys, pid, link_pid, acc) do
+    case pid_keys do
+      %{^pid => {idx_1, type_1}, ^link_pid => {idx_2, type_2}} ->
+        process_1 = graph_node(idx_1, pid, type_1)
+        process_2 = graph_node(idx_2, link_pid, type_2)
+        link_edge = graph_edge(process_1, process_2, :link)
+
+        add_rel(acc, link_edge)
+
+      _ ->
+        acc
+    end
+  end
+
+  # Mermaid rendering helper functions
+
+  defp graph_edge(node_1, node_2, relationship) do
+    %{
+      node_1: node_1,
+      node_2: node_2,
+      relationship: relationship
+    }
+  end
+
+  defp graph_node(id, pid, type) do
+    %{
+      id: id,
+      pid: pid,
+      type: type
+    }
+  end
+
+  defp generate_mermaid_entry(%{node_1: node_1, node_2: node_2, relationship: :link}) do
+    "#{graph_node(node_1)} -..- #{graph_node(node_2)}"
+  end
+
+  defp generate_mermaid_entry(%{node_1: node_1, node_2: node_2, relationship: :supervisor}) do
+    "#{graph_node(node_1)} ---> #{graph_node(node_2)}"
+  end
+
+  defp graph_node(%{id: id, pid: pid, type: type}) do
+    type =
+      if id == 0 do
+        :root
+      else
+        type
+      end
+
+    display =
+      pid
+      |> Process.info(:registered_name)
+      |> case do
+        {:registered_name, []} -> inspect(pid)
+        {:registered_name, name} -> inspect(name)
+      end
+
+    "#{id}(#{display}):::#{type}"
+  end
+end
diff --git a/mix.exs b/mix.exs
index 438305f..5f28a7d 100644
--- a/mix.exs
+++ b/mix.exs
@@ -48,7 +48,8 @@ defmodule Kino.MixProject do
           Kino.ETS,
           Kino.Frame,
           Kino.Image,
-          Kino.Markdown
+          Kino.Markdown,
+          Kino.Process
         ],
         Inputs: [
           Kino.Input,
diff --git a/mix.lock b/mix.lock
index c56791b..14442dc 100644
--- a/mix.lock
+++ b/mix.lock
@@ -5,5 +5,5 @@
   "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"},
   "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
   "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"},
-  "table": {:hex, :table, "0.1.2", "a002e3c32bbff0682dcafedfa0c92e98166ae1feed234346f0e82efbe7f90fb6", [:mix], [], "hexpm", "f9e7223ca019ad1d4d6a24c55257b6eac5b4d8cce243c8b86d1ec0c26972fc48"},
+  "table": {:hex, :table, "0.1.2", "87ad1125f5b70c5dea0307aa633194083eb5182ec537efc94e96af08937e14a8", [:mix], [], "hexpm", "7e99bc7efef806315c7e65640724bf165c3061cdc5d854060f74468367065029"},
 }


Test patch:
diff --git a/test/kino/data_table_test.exs b/test/kino/data_table_test.exs
index 5e37ac2..67eab65 100644
--- a/test/kino/data_table_test.exs
+++ b/test/kino/data_table_test.exs
@@ -23,7 +23,8 @@ defmodule Kino.DataTableTest do
                  %{fields: %{"0" => "2", "1" => ~s/"Terry Jeffords"/}}
                ],
                order: :asc,
-               order_by: nil
+               order_by: nil,
+               total_rows: 3
              }
            } = data
 
@@ -46,7 +47,8 @@ defmodule Kino.DataTableTest do
                  %{fields: %{"0" => "1", "1" => ~s/"Jake Peralta"/}}
                ],
                order: :desc,
-               order_by: "0"
+               order_by: "0",
+               total_rows: 3
              }
            } = data
   end
@@ -62,7 +64,8 @@ defmodule Kino.DataTableTest do
 
     assert %{
              content: %{
-               columns: [%{key: "0", label: ":b"}, %{key: "1", label: ":a"}]
+               columns: [%{key: "0", label: ":b"}, %{key: "1", label: ":a"}],
+               total_rows: 2
              }
            } = data
   end
@@ -78,7 +81,8 @@ defmodule Kino.DataTableTest do
 
     assert %{
              content: %{
-               columns: [%{key: "0", label: ~s/"b"/}, %{key: "1", label: ~s/"a"/}]
+               columns: [%{key: "0", label: ~s/"b"/}, %{key: "1", label: ~s/"a"/}],
+               total_rows: 2
              }
            } = data
   end
@@ -101,7 +105,8 @@ defmodule Kino.DataTableTest do
                columns: [
                  %{key: "0", label: ":id"},
                  %{key: "1", label: ":name"}
-               ]
+               ],
+               total_rows: 2
              }
            } = data
   end
@@ -205,4 +210,52 @@ defmodule Kino.DataTableTest do
     data = connect(kino)
     assert %{name: "Example"} = data
   end
+
+  test "supports sliceable data" do
+    entries = %{x: 1..3, y: 1..3}
+
+    kino = Kino.DataTable.new(entries)
+    data = connect(kino)
+
+    assert %{
+             content: %{
+               columns: [%{key: "0", label: ":x"}, %{key: "1", label: ":y"}],
+               rows: [
+                 %{fields: %{"0" => "1", "1" => "1"}},
+                 %{fields: %{"0" => "2", "1" => "2"}},
+                 %{fields: %{"0" => "3", "1" => "3"}}
+               ],
+               total_rows: 3
+             }
+           } = data
+  end
+
+  test "correctly paginates sliceable data" do
+    entries = %{x: 1..30, y: 1..30}
+
+    kino = Kino.DataTable.new(entries)
+    data = connect(kino)
+
+    assert %{
+             content: %{
+               columns: [%{key: "0", label: ":x"}, %{key: "1", label: ":y"}],
+               rows: [%{fields: %{"0" => "1", "1" => "1"}} | _] = rows,
+               total_rows: 30
+             }
+           } = data
+
+    assert length(rows) == 10
+
+    push_event(kino, "show_page", %{"page" => 2})
+
+    assert_broadcast_event(kino, "update_content", %{
+      rows: [%{fields: %{"0" => "11", "1" => "11"}} | _]
+    })
+
+    push_event(kino, "show_page", %{"page" => 1})
+
+    assert_broadcast_event(kino, "update_content", %{
+      rows: [%{fields: %{"0" => "1", "1" => "1"}} | _]
+    })
+  end
 end