From 4d79706c01c379e21ec67d1a5197167ab0178d8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Mon, 17 Jan 2022 17:14:25 +0100 Subject: [PATCH] Revamp Explorer section for Kino (#879) Pong and custom Kinos chapters are still pending. --- lib/livebook/notebook/explore.ex | 29 ++- .../distributed_portals_with_elixir.livemd | 41 +-- .../explore/elixir_and_livebook.livemd | 12 +- .../notebook/explore/intro_to_livebook.livemd | 42 ++- .../explore/intro_to_vega_lite.livemd | 14 +- .../notebook/explore/kino/chat_app.livemd | 153 +++++++++++ ...tom_widgets.livemd => custom_kinos.livemd} | 6 +- .../explore/kino/intro_to_kino.livemd | 244 ++++++++---------- .../notebook/explore/kino/pong.livemd | 2 +- .../{ => kino}/vm_introspection.livemd | 95 ++++--- static/images/vm_introspection.png | Bin 14726 -> 0 bytes 11 files changed, 385 insertions(+), 253 deletions(-) create mode 100644 lib/livebook/notebook/explore/kino/chat_app.livemd rename lib/livebook/notebook/explore/kino/{creating_custom_widgets.livemd => custom_kinos.livemd} (98%) rename lib/livebook/notebook/explore/{ => kino}/vm_introspection.livemd (63%) delete mode 100644 static/images/vm_introspection.png diff --git a/lib/livebook/notebook/explore.ex b/lib/livebook/notebook/explore.ex index effcb1a1702..9f075176c02 100644 --- a/lib/livebook/notebook/explore.ex +++ b/lib/livebook/notebook/explore.ex @@ -86,24 +86,25 @@ defmodule Livebook.Notebook.Explore do # cover_url: "/images/axon.png" # } # }, - %{ - path: Path.join(__DIR__, "explore/vm_introspection.livemd"), - details: %{ - description: "Extract and visualize information about a remote running node.", - cover_url: "/images/vm_introspection.png" - } - }, %{ ref: :kino_intro, path: Path.join(__DIR__, "explore/kino/intro_to_kino.livemd") }, + %{ + ref: :kino_vm_introspection, + path: Path.join(__DIR__, "explore/kino/vm_introspection.livemd") + }, + %{ + ref: :kino_chat_app, + path: Path.join(__DIR__, "explore/kino/chat_app.livemd") + }, %{ ref: :kino_pong, path: Path.join(__DIR__, "explore/kino/pong.livemd") }, %{ - ref: :kino_custom_widgets, - path: Path.join(__DIR__, "explore/kino/creating_custom_widgets.livemd") + ref: :kino_custom_kinos, + path: Path.join(__DIR__, "explore/kino/custom_kinos.livemd") } ] @@ -202,9 +203,15 @@ defmodule Livebook.Notebook.Explore do %{ title: "Interactions with Kino", description: - "Kino is an Elixir package that allows for displaying and controlling rich, interactieve widgets in Livebook. Learn how to make your notebooks more engaging with inputs, plots, tables, and much more!", + "Kino is an Elixir package for displaying and controlling rich, interactive widgets in Livebook. Learn how to make your notebooks more engaging with inputs, plots, tables, and much more!", cover_url: "/images/kino.png", - notebook_refs: [:kino_intro, :kino_pong, :kino_custom_widgets] + notebook_refs: [ + :kino_intro, + :kino_vm_introspection, + :kino_chat_app, + :kino_pong, + :kino_custom_kinos + ] } ] diff --git a/lib/livebook/notebook/explore/distributed_portals_with_elixir.livemd b/lib/livebook/notebook/explore/distributed_portals_with_elixir.livemd index 644dd035b82..b63e387d466 100644 --- a/lib/livebook/notebook/explore/distributed_portals_with_elixir.livemd +++ b/lib/livebook/notebook/explore/distributed_portals_with_elixir.livemd @@ -194,35 +194,11 @@ Date.from_iso8601("2020-02-30") Now, what happens if we want our code to behave differently depending if the date is valid or not? We can use `case` to pattern match on -the different tuples. This is also a good opportunity to use Livebook's -inputs to pass different values to our code. To render inputs, we need to -install the [Kino](https://github.com/livebook-dev/kino) library: +the different tuples: ```elixir -Mix.install( - [ - {:kino, github: "livebook-dev/kino"} - ], - consolidate_protocols: false -) -``` - -> Note: the `consolidate_protocols: false` option is not usually -> given, but it will be handy when we discuss protocols later -> in this notebook. - -Kino allows our code notebooks to control Livebook itself. Let's render -an input by evaluating the cell below: - -```elixir -date_input = Kino.Input.text("Date") -``` - -Now we can read its value and parse it: - -```elixir -# Read the date input, which returns something like "2020-02-30" -input = Kino.Input.read(date_input) +# Give a random date as input +input = "2020-02-30" # And then match on the return value case Date.from_iso8601(input) do @@ -234,12 +210,11 @@ case Date.from_iso8601(input) do end ``` -Now try adding a date to the input above, such as `2020-02-30` and -reevaluate the cell accordingly. In this example, we are using `case` -to pattern match on the different outcomes of the `Date.from_iso8601` -function. We say the `case` above has two clauses, one matching on -`{:ok, date}` and another on `{:error, reason}`. Try changing the input -and re-executing the cell to see how the outcome changes. +In this example, we are using `case` to pattern match on the different +outcomes of the `Date.from_iso8601`. We say the `case` above has two +clauses, one matching on `{:ok, date}` and another on `{:error, reason}`. +Now try changing the `input` variable above and reevaluate the cell +accordingly. What happens when you give it an invalid date? Finally, we can also pattern match on maps. This is used to extract the values for the given keys: diff --git a/lib/livebook/notebook/explore/elixir_and_livebook.livemd b/lib/livebook/notebook/explore/elixir_and_livebook.livemd index 4819e127773..eb51cb69d29 100644 --- a/lib/livebook/notebook/explore/elixir_and_livebook.livemd +++ b/lib/livebook/notebook/explore/elixir_and_livebook.livemd @@ -9,9 +9,11 @@ and more. If you are not familiar with Elixir, there is a fast paced introduction to the language in the [Distributed portals with Elixir](/explore/notebooks/distributed-portals-with-elixir) -notebook. +notebook. For a more structured introduction to the language, +see [Elixir's Getting Started guide](https://elixir-lang.org/getting-started/introduction.html) +and [the many learning resources available](https://elixir-lang.org/learning.html). -Let's move on. +Let's move forward. ## Autocompletion @@ -70,10 +72,10 @@ data = [ Kino.DataTable.new(data) ``` -See the [Interactions with Kino](/explore/notebooks/intro-to-kino) notebook -to learn all the ways you can interact with Livebook from Kino. +There is much more to `Kino` and we have [a series of Kino guides +in the Explore section to teach you more](/explore). -It is a good idea to specify versions of the installed packages, +Note that it is a good idea to specify versions of the installed packages, so that the notebook is easily reproducible later on. The install command goes beyond simply installing dependencies, it also caches them, consolidates protocols, and more. Check diff --git a/lib/livebook/notebook/explore/intro_to_livebook.livemd b/lib/livebook/notebook/explore/intro_to_livebook.livemd index 4ea8fe30c84..3bff2f34cc2 100644 --- a/lib/livebook/notebook/explore/intro_to_livebook.livemd +++ b/lib/livebook/notebook/explore/intro_to_livebook.livemd @@ -86,22 +86,35 @@ Process.sleep(300_000) Having this cell running, feel free to insert another Elixir cell in the section below and see it evaluates immediately. -## Notebook files +## Saving notebooks By default notebooks are kept in memory, which is fine for interactive hacking, but oftentimes you will want to save your work for later. Fortunately, notebooks can be persisted by clicking on the "Disk" icon () in the bottom-right corner and selecting the file location. -Notebooks are stored in **live markdown** format, which is essentially the markdown you know, +Notebooks are stored in **live markdown** format, which is the Markdown you know, with just a few assumptions on how particular elements are represented. Thanks to this approach you can easily keep notebooks under version control and get readable diffs. You can also easily preview those files, reuse for blog posts, and even edit in a text editor. -## Math +## Stepping up your workflow + +Once you start using notebooks more, it's gonna be beneficial +to optimise how you move around. Livebook leverages the concept of +**navigation**/**insert** modes and offers many shortcuts for common operations. +Make sure to check out the shortcuts by clicking the "Keyboard" icon +() in the sidebar or +by pressing ?. + +## Markdown extensions + +Livebook also include supports for Math expressions and Mermaid diagrams. -Livebook uses $\TeX$ syntax for math. -It supports both inline math like $e^{\pi i} + 1 = 0$, as well as display math: +### Math expressions + +Livebook uses $\TeX$ syntax for math inside your Markdown cells. +It supports both inline math, like $e^{\pi i} + 1 = 0$, as well as display math: $$ S(x) = \frac{1}{1 + e^{-x}} = \frac{e^{x}}{e^{x} + 1} @@ -109,14 +122,19 @@ $$ You can explore all supported expressions [here](https://katex.org/docs/supported.html). -## Stepping up your workflow +### Mermaid diagrams -Once you start using notebooks more, it's gonna be beneficial -to optimise how you move around. Livebook leverages the concept of -**navigation**/**insert** modes and offers many shortcuts for common operations. -Make sure to check out the shortcuts by clicking the "Keyboard" icon -() in the sidebar or -by pressing ?. +[Mermaid](https://mermaid-js.github.io/) is a library for creating diagrams +and visualizations using text and code. You can define those diagrams in +your Markdown cells via ```` ```mermaid ```` blocks. Let's see an example: + +```mermaid +graph TD; + A-->B; + A-->C; + B-->D; + C-->D; +``` ## Next steps diff --git a/lib/livebook/notebook/explore/intro_to_vega_lite.livemd b/lib/livebook/notebook/explore/intro_to_vega_lite.livemd index 96001b755d6..f339c63eb23 100644 --- a/lib/livebook/notebook/explore/intro_to_vega_lite.livemd +++ b/lib/livebook/notebook/explore/intro_to_vega_lite.livemd @@ -2,11 +2,15 @@ ## Setup -To render graphs in Livebook, we need the -[`vega_lite`](https://github.com/elixir-nx/vega_lite) package -for defining our graph specification and -[`kino`](https://github.com/elixir-nx/kino). We won't use Kino -directly, but it is required to render VegaLite: +We need two libraries for plotting in Livebook: + + * The [`vega_lite`](https://github.com/elixir-nx/vega_lite) + package allows us to define our graph specifications + + * The [`kino`](https://github.com/elixir-nx/kino) package + renders our specifications + +Let's install them: ```elixir Mix.install([ diff --git a/lib/livebook/notebook/explore/kino/chat_app.livemd b/lib/livebook/notebook/explore/kino/chat_app.livemd new file mode 100644 index 00000000000..5525c292339 --- /dev/null +++ b/lib/livebook/notebook/explore/kino/chat_app.livemd @@ -0,0 +1,153 @@ +# Building a chat app with Kino.Control + +## Setup + +In this guide, we will build a chat application using +[`kino`](https://github.com/livebook-dev/kino). Let's +install it and get started: + +```elixir +Mix.install([ + {:kino, github: "livebook-dev/kino"} +]) +``` + +## Kino.Control + +In our [introduction to Kino](/explore/notebooks/intro_to_kino), +we learned about inputs and several different outputs, such as +tables, frames, and more. In particular, we learned how to use +inputs to capture values directly into our notebooks: + +```elixir +name = Kino.Input.text("Your name") +``` + +and use them to print something back: + +```elixir +IO.puts("Hello, #{Kino.Input.read(name)}!") +``` + +Inputs have one special power: they are shared across all users +accessing the notebook. For example, if you copy and paste the +URL of this notebook into another tab, the input will share the +same value. This plays into Livebook's strengths of being an +interactive and collaborative tool. + +Sometimes, however, you don't want the input to be shared. +You want each different user to get their own inputs and perform +individual actions. That's exactly how +[`Kino.Control`](https://hexdocs.pm/kino/Kino.Control.html) works. +Each control is specific to each user on the page. You then receive +each user interaction as a message. + +## The button control + +The simplest control is `Kino.Control.button/1`. Let's give it a try: + +```elixir +click_me = Kino.Control.button("Click me!") +``` + +Execute the cell above and the button will be rendered. You can click +it, but nothing will happen. Luckily, we can subscribe to the button +events: + +```elixir +Kino.Control.subscribe(click_me, :click_me) +``` + +Now that we have subscribed, every time the button is clicked, we will +receive a message tagged with `:click_me`. Let's print all messages +in our inbox: + +```elixir +Process.info(self(), :messages) +``` + +Now execute the cell above, click the button a couple times, and +re-execute the cell above. For each click, there is a new message in +our inbox. There are several ways we can consume this message. +Let's see a different one in the next example. + + + +## The form control + +Whenever we want to submit multiple inputs at once, we can use +`Kino.Control.form/2`. + +```elixir +inputs = [ + first_name: Kino.Input.text("First name"), + last_name: Kino.Input.text("Last name") +] + +form = Kino.Control.form(inputs, submit: "Greet") +``` + +Execute the cell above and you will see a form rendered. +You can now fill in the form and press the submit button. +Each submission will trigger a new event. Let's consume +them as a stream. Elixir streams are lazy collections that +are consumed as they happen: + +```elixir +for event <- Kino.Control.stream(form) do + IO.inspect(event) +end +``` + +Now, as you submit the form, you should see a new event +printed. However, there is a downside: we are now stuck +inside this infinite loop of events. Luckily, we started +this particular section as a branched section, which means +the main execution flow will not be interrupted. But it +is something you should keep in mind in the future. You +can also stop it by pressing the "Stop" button above the +Elixir cell. + + + +## The chat application + +We are now equipped with all knowledge necessary to build +our chat application. First, we will need a frame. Every +time a new message is received, we will append it to the +frame: + +```elixir +frame = Kino.Frame.new() +``` + +Now we need a form with the user name and their message: + +```elixir +inputs = [ + name: Kino.Input.text("Name"), + message: Kino.Input.text("Message") +] + +form = Kino.Control.form(inputs, submit: "Send", reset_on_submit: [:message]) +``` + +Notice we used a new option, called `:reset_on_submit`, +that automatically clears the input once submitted. +Finally, let's stream the form events and post each +message to the frame: + +```elixir +for %{data: %{name: name, message: message}} <- Kino.Control.stream(form) do + content = Kino.Markdown.new("**#{name}**: #{message}") + Kino.Frame.append(frame, content) +end +``` + +Execute the cell above and your chat app should be +fully operational. Open up this same notebook across +on different tabs and each different user can post +their messages. + +In the next guide we will go one step further and +[develop a multiplayer pong game](/explore/notebooks/pong)! diff --git a/lib/livebook/notebook/explore/kino/creating_custom_widgets.livemd b/lib/livebook/notebook/explore/kino/custom_kinos.livemd similarity index 98% rename from lib/livebook/notebook/explore/kino/creating_custom_widgets.livemd rename to lib/livebook/notebook/explore/kino/custom_kinos.livemd index dc6c7a18c51..3aac4ff56f9 100644 --- a/lib/livebook/notebook/explore/kino/creating_custom_widgets.livemd +++ b/lib/livebook/notebook/explore/kino/custom_kinos.livemd @@ -1,9 +1,9 @@ -# Creating custom widgets +# Custom kinos with JavaScript ## Introduction The `Kino.JS` and `Kino.JS.Live` docs outline the API that enables -developing custom JavaScript powered widgets. The examples discussed +developing custom JavaScript powered kinos. The examples discussed there are kept minimal to introduce the basic concepts without much overhead. In this notebook we take things a bit further and showcase a couple more elaborate use cases. @@ -14,7 +14,7 @@ Mix.install([ ]) ``` -## Diagrams with Mermaid +## HTML rendering As a quick recap let's define a widget for rendering diagrams from text specification using [Mermaid](https://mermaid-js.github.io/mermaid/#/). diff --git a/lib/livebook/notebook/explore/kino/intro_to_kino.livemd b/lib/livebook/notebook/explore/kino/intro_to_kino.livemd index b4955d40765..359e8586da4 100644 --- a/lib/livebook/notebook/explore/kino/intro_to_kino.livemd +++ b/lib/livebook/notebook/explore/kino/intro_to_kino.livemd @@ -6,123 +6,76 @@ In this notebook we will explore the possibilities that [`kino`](https://github.com/elixir-nx/kino) brings into your notebooks. Kino can be thought of as Livebook's friend that instructs it how to render certain widgets -and interact with them. +and interact with them. Let's install it: ```elixir Mix.install([ - {:kino, github: "livebook-dev/kino"}, - {:vega_lite, "~> 0.1.2"} + {:kino, github: "livebook-dev/kino"} ]) ``` -```elixir -alias VegaLite, as: Vl -``` - -## Kino.VegaLite - -In the [Plotting with VegaLite](/explore/notebooks/intro-to-vega-lite) notebook we show -numerous ways in which you can visualize your data. However, all of the plots -there are static. - -Using Kino, we can dynamically stream data to the plot, so that it keeps updating! -To do that, all you need is a regular VegaLite specification that you then pass -to `Kino.VegaLite.new/1`. You don't have to specify any data up-front. - -```elixir -widget = - Vl.new(width: 400, height: 400) - |> Vl.mark(:line) - |> Vl.encode_field(:x, "x", type: :quantitative) - |> Vl.encode_field(:y, "y", type: :quantitative) - |> Kino.VegaLite.new() -``` +## Kino.Input -Then you can push data to the plot widget at any point and see it update dynamically: +The [`Kino.Input`](https://hexdocs.pm/kino/Kino.Input.html) +module contains the most common kinos you will use. They are +used to define inputs in one cell, which you can read in a +future cell: ```elixir -for i <- 1..300 do - point = %{x: i / 10, y: :math.sin(i / 10)} - - # The :window option ensures we only show the latest - # 100 data points on the plot - Kino.VegaLite.push(widget, point, window: 100) - - Process.sleep(25) -end +name = Kino.Input.text("Your name") ``` -You can also explicitly clear the data: +and now we can greet the user back: ```elixir -Kino.VegaLite.clear(widget) +IO.puts("Hello, #{Kino.Input.read(name)}!") ``` -### Periodical updates - -You may want to have a plot running forever and updating in the background. -There is a dedicated `Kino.VegaLite.periodically/4` function that allows you do do just that! -You just need to specify the interval and the reducer callback like this, -then you interact with the widget as usually. - -```elixir -widget = - Vl.new(width: 400, height: 400) - |> Vl.mark(:line) - |> Vl.encode_field(:x, "x", type: :quantitative) - |> Vl.encode_field(:y, "y", type: :quantitative) - |> Kino.VegaLite.new() - |> Kino.render() - -# Add a callback to run every 25ms -Kino.VegaLite.periodically(widget, 25, 0, fn i -> - point = %{x: i / 10, y: :math.sin(i / 10)} - # Interacting with the widget is as usual - Kino.VegaLite.push(widget, point, window: 100) - # Continue with the new accumulator value - {:cont, i + 1} -end) -``` +There are multiple types of inputs, such as text areas, +color dialogs, selects, and more. Feel free to explore them. -## Kino.ETS +## Kino.Markdown -You can use `Kino.ETS.new/1` to render ETS tables and easily -browse their contents. Let's first create our own table: +Given our notebooks already know how to render Markdown, +you won't be surprised to find we can also render Markdown +directly from our Elixir cells. This is done by wrapping +the Markdown contents in [`Kino.Markdown.new/1`](https://hexdocs.pm/kino/Kino.Markdown.html): -```elixir -tid = :ets.new(:users, [:set, :public]) -Kino.ETS.new(tid) -``` +````elixir +Kino.Markdown.new(""" +# Example -In fact, Livebook automatically recognises an ETS table and -renders it as such: +A regular Markdown file. + +## Code ```elixir -tid +"Elixir" |> String.graphemes() |> Enum.frequencies() ``` -Currently the table is empty, so it's time to insert some rows. +## Table -```elixir -for id <- 1..24 do - :ets.insert(tid, {id, "User #{id}", :rand.uniform(100), "Description #{id}"}) -end -``` +| ID | Name | Website | +| -- | ------ | ----------------------- | +| 1 | Elixir | https://elixir-lang.org | +| 2 | Erlang | https://www.erlang.org | +""") +```` -Having the rows inserted, click on the "Refetch" icon in the table output -above to see them. +The way it works is that Livebook automatically detects +the output is a kino and renders it in Markdown. That's +the first of many kinos we will learn today. Let's move +forward. ## Kino.DataTable -When it comes to tables, we are not limited to ETS! You can render -arbitrary tabular data using `Kino.DataTable.new/1`, let's have -a look: +You can render arbitrary tabular data using [`Kino.DataTable.new/1`](https://hexdocs.pm/kino/Kino.DataTable.html), let's have a look: ```elixir data = [ @@ -133,17 +86,19 @@ data = [ Kino.DataTable.new(data) ``` -The data must be an enumerable, with records being maps, -keyword lists or tuples. +The data must be an enumerable, with records being maps or +keyword lists. -Now, let's get some more realistic data: +Now, let's get some more realistic data. Whenever you run +Elixir code, you have several lightweight processes running +side-by-side. We can actually gather information about these +processes and render it as a table: ```elixir processes = Process.list() |> Enum.map(&Process.info/1) ``` -We can easily pick only the data keys that are relevant -for us: +We can pick the data keys that are relevant for us: ```elixir Kino.DataTable.new( @@ -152,37 +107,42 @@ Kino.DataTable.new( ) ``` -We can sort by the number of reductions to identify the -most busy processes! +Now you can use the table above to sort by the number of +reductions and identify the most busy processes! -## Kino.Markdown +## Kino.ETS -Sometimes you may want to render arbitrary content as rich-text, -that's when `Kino.Markdown.new/1` comes into play: +Kino supports multiple other data structures to be rendered +as tables. For example, you can use [`Kino.ETS`](https://hexdocs.pm/kino/Kino.ETS.html) +to render ETS tables and easily browse their contents. +Let's first create our own table: -````elixir -""" -# Example +```elixir +tid = :ets.new(:users, [:set, :public]) +Kino.ETS.new(tid) +``` -A regular Markdown file. +In fact, Livebook automatically recognises an ETS table and +renders it as such: -## Code +```elixir +tid +``` + +Currently the table is empty, so it's time to insert some rows. ```elixir -"Elixir" |> String.graphemes() |> Enum.frequencies() +for id <- 1..24 do + :ets.insert(tid, {id, "User #{id}", :rand.uniform(100), "Description #{id}"}) +end ``` -## Table +Having the rows inserted, click on the "Refetch" icon in the table output +above to see them. -| ID | Name | Website | -| -- | ------ | ----------------------- | -| 1 | Elixir | https://elixir-lang.org | -| 2 | Erlang | https://www.erlang.org | -""" -|> Kino.Markdown.new() -```` +Similar functionality is available for database queries via [Ecto](https://github.com/elixir-ecto/ecto) and the [`Kino.Ecto`](https://hexdocs.pm/kino/Kino.Ecto.html) module. @@ -191,56 +151,53 @@ A regular Markdown file. As we saw, Livebook automatically recognises widgets returned from each cell and renders them accordingly. However, sometimes it's useful to explicitly render a widget in the middle of the cell, -similarly to `IO.puts/1` and that's exactly what `Kino.render/1` +similarly to `IO.puts/1`, and that's exactly what `Kino.render/1` does! It works with any type and tells Livebook to render the value in its special manner. ```elixir # Arbitrary data structures Kino.render([%{name: "Ada Lovelace"}, %{name: "Alan Turing"}]) +Kino.render("Plain text") -# Static plots -vl = - Vl.new(width: 400, height: 400) - |> Vl.data_from_series(x: 1..100, y: 1..100) - |> Vl.mark(:line) - |> Vl.encode_field(:x, "x", type: :quantitative) - |> Vl.encode_field(:y, "y", type: :quantitative) +# Some kinos +Kino.render(Kino.Markdown.new("**Hello world**")) -Kino.render(vl) -Kino.render(vl) +"Cell result 🚀" +``` -Kino.render("Plain text") + -"Cell result 🚀" +## Kino.Frame and animations + +`Kino.Frame` allows us to render an empty frame and update it +as we progress. Let's render an empty frame: + +```elixir +frame = Kino.Frame.new() ``` -Before we saw how you can render and stream data to the plot -from a separate cell, the same could be rewritten in one go -like this: +Now, let's render a random number between 1 and 100 directly +in the frame: ```elixir -widget = - Vl.new(width: 400, height: 400) - |> Vl.mark(:line) - |> Vl.encode_field(:x, "x", type: :quantitative) - |> Vl.encode_field(:y, "y", type: :quantitative) - |> Kino.VegaLite.new() - |> Kino.render() - -for i <- 1..300 do - point = %{x: i / 10, y: :math.sin(i / 10)} - Kino.VegaLite.push(widget, point, window: 100) - Process.sleep(25) -end +Kino.Frame.render(frame, "Got: #{Enum.random(1..100)}") ``` - +Notice how every time you reevaluate the cell above it updates +the frame. You can also use `Kino.Frame.append/2` to append to +the frame: -## Kino.animate/3 +```elixir +Kino.Frame.append(frame, "Got: #{Enum.random(1..100)}") +``` -If you want to continuously update the output as time passes, -you can use `Kino.animate/3`: +Appending multiple times will always add new contents. The content +can be reset by calling `Kino.Frame.render/2` or `Kino.Frame.clear/1`. + +By using loops, you can use `Kino.Frame` to dynamically add contents +or animate your livebooks. In fact, there is a convenience function +called `Kino.animate/3` to be used exactly for this purpose: ```elixir Kino.animate(100, 0, fn i -> @@ -251,6 +208,9 @@ end) The above example renders new Markdown output every 100ms. You can use the same approach to render regular output -or images too! Also note some elements may have specific -functions for periodic updates, such as `Kino.VegaLite.periodically/4` -seen in previous sections. +or images too! + +With this, we finished our introduction to Kino. Now we are +ready to bring two concepts we have already learned together: +`Kino` and `VegaLite`. [Let's use them to introspect the Elixir +runtime your livebooks run on](/explore/notebooks/vm-introspection). diff --git a/lib/livebook/notebook/explore/kino/pong.livemd b/lib/livebook/notebook/explore/kino/pong.livemd index 09bbde4c956..e89ffcd0326 100644 --- a/lib/livebook/notebook/explore/kino/pong.livemd +++ b/lib/livebook/notebook/explore/kino/pong.livemd @@ -1,4 +1,4 @@ -# Building multiplayer Pong +# Multiplayer pong game from scratch ## Introduction diff --git a/lib/livebook/notebook/explore/vm_introspection.livemd b/lib/livebook/notebook/explore/kino/vm_introspection.livemd similarity index 63% rename from lib/livebook/notebook/explore/vm_introspection.livemd rename to lib/livebook/notebook/explore/kino/vm_introspection.livemd index e251cf522f6..500e02f1e3c 100644 --- a/lib/livebook/notebook/explore/vm_introspection.livemd +++ b/lib/livebook/notebook/explore/kino/vm_introspection.livemd @@ -1,15 +1,15 @@ -# Fun with VM introspection +# Runtime introspection with VegaLite ## Introduction -In this notebook we manually establish connection to a running node, -and then we try to retrieve and plot some interesting information -about the system. +In this chapter we will use `Kino` and `VegaLite` +to introspect and plot how our system behaves over +time. If you are not familiar with VegaLite, [read +our introductory chapter](/explore/notebooks/intro-to-vega-lite). ## Setup -We are definitely gonna plot some data in this notebook, -so let's add `:vega_lite` and `:kino` for that. +Let's add `:vega_lite` and `:kino` as dependencies: ```elixir Mix.install([ @@ -18,46 +18,53 @@ Mix.install([ ]) ``` +Let's also define a convenience shortcut for the +VegaLite module: + ```elixir alias VegaLite, as: Vl ``` ## Connecting to a remote node -The first thing we need is a separate Elixir node. In practice, -you would start an external Elixir system, such as by running the -following in your production app: +Our goal is to introspect an Elixir node. The code we will +write in this notebook can be used to introspect any running +Elixir node. It can be a development environment that you would +start with: ``` iex --name my_app@IP -S mix TASK ``` -Or by connecting to a production node assembled via +Or a production node assembled via [`mix release`](https://hexdocs.pm/mix/Mix.Tasks.Release.html). -For convenience, however, you can simply start [a new notebook](/explore/notebooks/new), -since Livebook automatically starts each notebook as a remote node. - -Once you start a new notebook, you can find its node name and -cookie by running the following inside an Elixir cell: - - +In order to connect two nodes, we need to know their node name +and their cookie. We can get this information for the Livebook +runtime like this: ```elixir IO.puts node() IO.puts Node.get_cookie() ``` -Now render the inputs below: +We will capture this information using Kino inputs. However, +for convenience, we will use the node and cookie of the current +notebook as default values. This means that, if you don't have +a separate Elixir, the runtime will connect and introspect itself. +Let's render the inputs: ```elixir -node_input = Kino.Input.text("Node") -cookie_input = Kino.Input.text("Cookie") -``` +node_input = Kino.Input.text("Node", default: node()) +cookie_input = Kino.Input.text("Cookie", default: Node.get_cookie()) -And paste the node name and the cookie value from the other node inside. +Kino.render(node_input) +Kino.render(cookie_input) +:ok +``` -Now let's read the inputs, configure the cookie, and connect to the other notebook: +Now let's read the inputs, configure the cookie, and connect to the +other node: ```elixir node = @@ -83,9 +90,6 @@ Node.spawn(node, fn -> end) ``` -From the result of `node/1` it's clear that the function was evaluated -remotely, but note that we still get the standard output back. - ## Inspecting processes Now we are going to extract some information from the running node on our own! @@ -127,7 +131,8 @@ processes = end) ``` -Having all that data, we can now visualize it on a scatter plot! +Having all that data, we can now visualize it on a scatter plot +using VegaLite: ```elixir Vl.new(width: 600, height: 400) @@ -144,17 +149,22 @@ and take the most memory. ## Tracking memory usage +So far we have used VegaLite to draw static plots. However, we can +Kino to dynamically push data to VegaLite. Let's use them together +to plot the runtime memory usage over time. + There's a very simple way to determine current memory usage in the VM: ```elixir :erlang.memory() ``` -We can use `Kino.VegaLite.periodically/4` to create a self-updating -plot of memory usage over time on the remote node! +Now let's build a dynamic VegaLite graph. Instead of returning the +VegaLite specification as is, we will wrap it in `Kino.VegaLite.new/1` +to make it dynamic: ```elixir -widget = +memory_plot = Vl.new(width: 600, height: 400, padding: 20) |> Vl.repeat( [layer: ["total", "processes", "atom", "binary", "code", "ets"]], @@ -165,38 +175,38 @@ widget = |> Vl.encode(:color, datum: [repeat: :layer], type: :nominal) ) |> Kino.VegaLite.new() - |> Kino.render() +``` -Kino.VegaLite.periodically(widget, 200, 1, fn i -> +Now we can use `Kino.VegaLite.periodically/4` to create a self-updating +plot of memory usage over time on the remote node: + +```elixir +Kino.VegaLite.periodically(memory_plot, 200, 1, fn i -> point = :rpc.call(node, :erlang, :memory, []) |> Enum.map(fn {type, bytes} -> {type, bytes / 1_000_000} end) |> Map.new() |> Map.put(:iter, i) - Kino.VegaLite.push(widget, point, window: 1000) + Kino.VegaLite.push(memory_plot, point, window: 1000) {:cont, i + 1} end) ``` Unless you connected to a production node, the memory usage most likely doesn't change, so to emulate some spikes you can -run the following code in the remote node: +run the following code: **Binary usage** - - ```elixir -x = Enum.reduce(1..10_000, [], fn i, acc -> - [String.duplicate("cat", i) | acc] -end) +for i <- 1..10_000 do + String.duplicate("cat", i) +end ``` **ETS usage** - - ```elixir tid = :ets.new(:users, [:set, :public]) @@ -204,3 +214,6 @@ for i <- 1..1_000_000 do :ets.insert(tid, {i, "User #{i}"}) end ``` + +In the next chapter, we will learn [how to use `Kino.Control` +to build a chat app](/explore/notebooks/chat-app)! diff --git a/static/images/vm_introspection.png b/static/images/vm_introspection.png deleted file mode 100644 index b07f9e2818455141c99090c946a7927275f2982c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14726 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 Dny{zq