mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-09 21:16:26 +08:00
Revamp Explorer section for Kino (#879)
Pong and custom Kinos chapters are still pending.
This commit is contained in:
parent
fe0bf660a2
commit
4d79706c01
11 changed files with 405 additions and 273 deletions
|
@ -86,24 +86,25 @@ defmodule Livebook.Notebook.Explore do
|
||||||
# cover_url: "/images/axon.png"
|
# 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,
|
ref: :kino_intro,
|
||||||
path: Path.join(__DIR__, "explore/kino/intro_to_kino.livemd")
|
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,
|
ref: :kino_pong,
|
||||||
path: Path.join(__DIR__, "explore/kino/pong.livemd")
|
path: Path.join(__DIR__, "explore/kino/pong.livemd")
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
ref: :kino_custom_widgets,
|
ref: :kino_custom_kinos,
|
||||||
path: Path.join(__DIR__, "explore/kino/creating_custom_widgets.livemd")
|
path: Path.join(__DIR__, "explore/kino/custom_kinos.livemd")
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -202,9 +203,15 @@ defmodule Livebook.Notebook.Explore do
|
||||||
%{
|
%{
|
||||||
title: "Interactions with Kino",
|
title: "Interactions with Kino",
|
||||||
description:
|
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",
|
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
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -194,35 +194,11 @@ Date.from_iso8601("2020-02-30")
|
||||||
|
|
||||||
Now, what happens if we want our code to behave differently depending
|
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
|
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
|
the different tuples:
|
||||||
inputs to pass different values to our code. To render inputs, we need to
|
|
||||||
install the [Kino](https://github.com/livebook-dev/kino) library:
|
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
Mix.install(
|
# Give a random date as input
|
||||||
[
|
input = "2020-02-30"
|
||||||
{: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)
|
|
||||||
|
|
||||||
# And then match on the return value
|
# And then match on the return value
|
||||||
case Date.from_iso8601(input) do
|
case Date.from_iso8601(input) do
|
||||||
|
@ -234,12 +210,11 @@ case Date.from_iso8601(input) do
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
Now try adding a date to the input above, such as `2020-02-30` and
|
In this example, we are using `case` to pattern match on the different
|
||||||
reevaluate the cell accordingly. In this example, we are using `case`
|
outcomes of the `Date.from_iso8601`. We say the `case` above has two
|
||||||
to pattern match on the different outcomes of the `Date.from_iso8601`
|
clauses, one matching on `{:ok, date}` and another on `{:error, reason}`.
|
||||||
function. We say the `case` above has two clauses, one matching on
|
Now try changing the `input` variable above and reevaluate the cell
|
||||||
`{:ok, date}` and another on `{:error, reason}`. Try changing the input
|
accordingly. What happens when you give it an invalid date?
|
||||||
and re-executing the cell to see how the outcome changes.
|
|
||||||
|
|
||||||
Finally, we can also pattern match on maps. This is used to extract the
|
Finally, we can also pattern match on maps. This is used to extract the
|
||||||
values for the given keys:
|
values for the given keys:
|
||||||
|
|
|
@ -9,9 +9,11 @@ and more.
|
||||||
If you are not familiar with Elixir, there is a fast paced
|
If you are not familiar with Elixir, there is a fast paced
|
||||||
introduction to the language in the [Distributed portals with
|
introduction to the language in the [Distributed portals with
|
||||||
Elixir](/explore/notebooks/distributed-portals-with-elixir)
|
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
|
## Autocompletion
|
||||||
|
|
||||||
|
@ -70,10 +72,10 @@ data = [
|
||||||
Kino.DataTable.new(data)
|
Kino.DataTable.new(data)
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [Interactions with Kino](/explore/notebooks/intro-to-kino) notebook
|
There is much more to `Kino` and we have [a series of Kino guides
|
||||||
to learn all the ways you can interact with Livebook from Kino.
|
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
|
so that the notebook is easily reproducible later on. The install
|
||||||
command goes beyond simply installing dependencies, it also caches
|
command goes beyond simply installing dependencies, it also caches
|
||||||
them, consolidates protocols, and more. Check
|
them, consolidates protocols, and more. Check
|
||||||
|
|
|
@ -86,29 +86,18 @@ Process.sleep(300_000)
|
||||||
Having this cell running, feel free to insert another Elixir cell
|
Having this cell running, feel free to insert another Elixir cell
|
||||||
in the section below and see it evaluates immediately.
|
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,
|
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
|
but oftentimes you will want to save your work for later. Fortunately, notebooks
|
||||||
can be persisted by clicking on the "Disk" icon (<i class="ri-livebook-save"></i>)
|
can be persisted by clicking on the "Disk" icon (<i class="ri-livebook-save"></i>)
|
||||||
in the bottom-right corner and selecting the file location.
|
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
|
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.
|
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.
|
You can also easily preview those files, reuse for blog posts, and even edit in a text editor.
|
||||||
|
|
||||||
## Math
|
|
||||||
|
|
||||||
Livebook uses $\TeX$ syntax for math.
|
|
||||||
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}
|
|
||||||
$$
|
|
||||||
|
|
||||||
You can explore all supported expressions [here](https://katex.org/docs/supported.html).
|
|
||||||
|
|
||||||
## Stepping up your workflow
|
## Stepping up your workflow
|
||||||
|
|
||||||
Once you start using notebooks more, it's gonna be beneficial
|
Once you start using notebooks more, it's gonna be beneficial
|
||||||
|
@ -118,6 +107,35 @@ Make sure to check out the shortcuts by clicking the "Keyboard" icon
|
||||||
(<i class="ri-livebook-shortcuts"></i>) in the sidebar or
|
(<i class="ri-livebook-shortcuts"></i>) in the sidebar or
|
||||||
by pressing <kbd>?</kbd>.
|
by pressing <kbd>?</kbd>.
|
||||||
|
|
||||||
|
## Markdown extensions
|
||||||
|
|
||||||
|
Livebook also include supports for Math expressions and Mermaid diagrams.
|
||||||
|
|
||||||
|
### 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}
|
||||||
|
$$
|
||||||
|
|
||||||
|
You can explore all supported expressions [here](https://katex.org/docs/supported.html).
|
||||||
|
|
||||||
|
### Mermaid diagrams
|
||||||
|
|
||||||
|
[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
|
## Next steps
|
||||||
|
|
||||||
That's our quick intro to Livebook! Where to go next?
|
That's our quick intro to Livebook! Where to go next?
|
||||||
|
|
|
@ -2,11 +2,15 @@
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
To render graphs in Livebook, we need the
|
We need two libraries for plotting in Livebook:
|
||||||
[`vega_lite`](https://github.com/elixir-nx/vega_lite) package
|
|
||||||
for defining our graph specification and
|
* The [`vega_lite`](https://github.com/elixir-nx/vega_lite)
|
||||||
[`kino`](https://github.com/elixir-nx/kino). We won't use Kino
|
package allows us to define our graph specifications
|
||||||
directly, but it is required to render VegaLite:
|
|
||||||
|
* The [`kino`](https://github.com/elixir-nx/kino) package
|
||||||
|
renders our specifications
|
||||||
|
|
||||||
|
Let's install them:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
Mix.install([
|
Mix.install([
|
||||||
|
|
153
lib/livebook/notebook/explore/kino/chat_app.livemd
Normal file
153
lib/livebook/notebook/explore/kino/chat_app.livemd
Normal file
|
@ -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.
|
||||||
|
|
||||||
|
<!-- livebook:{"branch_parent_index":0} -->
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
<!-- livebook:{"branch_parent_index":0} -->
|
||||||
|
|
||||||
|
## 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)!
|
|
@ -1,9 +1,9 @@
|
||||||
# Creating custom widgets
|
# Custom kinos with JavaScript
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
The `Kino.JS` and `Kino.JS.Live` docs outline the API that enables
|
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
|
there are kept minimal to introduce the basic concepts without much
|
||||||
overhead. In this notebook we take things a bit further and showcase
|
overhead. In this notebook we take things a bit further and showcase
|
||||||
a couple more elaborate use cases.
|
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
|
As a quick recap let's define a widget for rendering diagrams
|
||||||
from text specification using [Mermaid](https://mermaid-js.github.io/mermaid/#/).
|
from text specification using [Mermaid](https://mermaid-js.github.io/mermaid/#/).
|
|
@ -6,92 +6,118 @@ In this notebook we will explore the possibilities that
|
||||||
[`kino`](https://github.com/elixir-nx/kino) brings
|
[`kino`](https://github.com/elixir-nx/kino) brings
|
||||||
into your notebooks. Kino can be thought of as Livebook's
|
into your notebooks. Kino can be thought of as Livebook's
|
||||||
friend that instructs it how to render certain widgets
|
friend that instructs it how to render certain widgets
|
||||||
and interact with them.
|
and interact with them. Let's install it:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
Mix.install([
|
Mix.install([
|
||||||
{:kino, github: "livebook-dev/kino"},
|
{:kino, github: "livebook-dev/kino"}
|
||||||
{:vega_lite, "~> 0.1.2"}
|
|
||||||
])
|
])
|
||||||
```
|
```
|
||||||
|
|
||||||
```elixir
|
|
||||||
alias VegaLite, as: Vl
|
|
||||||
```
|
|
||||||
|
|
||||||
<!-- livebook:{"branch_parent_index":0} -->
|
<!-- livebook:{"branch_parent_index":0} -->
|
||||||
|
|
||||||
## Kino.VegaLite
|
## Kino.Input
|
||||||
|
|
||||||
In the [Plotting with VegaLite](/explore/notebooks/intro-to-vega-lite) notebook we show
|
The [`Kino.Input`](https://hexdocs.pm/kino/Kino.Input.html)
|
||||||
numerous ways in which you can visualize your data. However, all of the plots
|
module contains the most common kinos you will use. They are
|
||||||
there are static.
|
used to define inputs in one cell, which you can read in a
|
||||||
|
future cell:
|
||||||
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
|
```elixir
|
||||||
widget =
|
name = Kino.Input.text("Your name")
|
||||||
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()
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then you can push data to the plot widget at any point and see it update dynamically:
|
and now we can greet the user back:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
for i <- 1..300 do
|
IO.puts("Hello, #{Kino.Input.read(name)}!")
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also explicitly clear the data:
|
There are multiple types of inputs, such as text areas,
|
||||||
|
color dialogs, selects, and more. Feel free to explore them.
|
||||||
|
|
||||||
|
<!-- livebook:{"branch_parent_index":0} -->
|
||||||
|
|
||||||
|
## Kino.Markdown
|
||||||
|
|
||||||
|
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
|
||||||
|
Kino.Markdown.new("""
|
||||||
|
# Example
|
||||||
|
|
||||||
|
A regular Markdown file.
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
Kino.VegaLite.clear(widget)
|
"Elixir" |> String.graphemes() |> Enum.frequencies()
|
||||||
```
|
```
|
||||||
|
|
||||||
### Periodical updates
|
## Table
|
||||||
|
|
||||||
You may want to have a plot running forever and updating in the background.
|
| ID | Name | Website |
|
||||||
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,
|
| 1 | Elixir | https://elixir-lang.org |
|
||||||
then you interact with the widget as usually.
|
| 2 | Erlang | https://www.erlang.org |
|
||||||
|
""")
|
||||||
|
````
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<!-- livebook:{"branch_parent_index":0} -->
|
||||||
|
|
||||||
|
## Kino.DataTable
|
||||||
|
|
||||||
|
You can render arbitrary tabular data using [`Kino.DataTable.new/1`](https://hexdocs.pm/kino/Kino.DataTable.html), let's have a look:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
widget =
|
data = [
|
||||||
Vl.new(width: 400, height: 400)
|
%{id: 1, name: "Elixir", website: "https://elixir-lang.org"},
|
||||||
|> Vl.mark(:line)
|
%{id: 2, name: "Erlang", website: "https://www.erlang.org"}
|
||||||
|> 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.DataTable.new(data)
|
||||||
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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The data must be an enumerable, with records being maps or
|
||||||
|
keyword lists.
|
||||||
|
|
||||||
|
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 pick the data keys that are relevant for us:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
Kino.DataTable.new(
|
||||||
|
processes,
|
||||||
|
keys: [:registered_name, :initial_call, :reductions, :stack_size]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can use the table above to sort by the number of
|
||||||
|
reductions and identify the most busy processes!
|
||||||
|
|
||||||
<!-- livebook:{"branch_parent_index":0} -->
|
<!-- livebook:{"branch_parent_index":0} -->
|
||||||
|
|
||||||
## Kino.ETS
|
## Kino.ETS
|
||||||
|
|
||||||
You can use `Kino.ETS.new/1` to render ETS tables and easily
|
Kino supports multiple other data structures to be rendered
|
||||||
browse their contents. Let's first create our own table:
|
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
|
```elixir
|
||||||
tid = :ets.new(:users, [:set, :public])
|
tid = :ets.new(:users, [:set, :public])
|
||||||
|
@ -116,73 +142,7 @@ end
|
||||||
Having the rows inserted, click on the "Refetch" icon in the table output
|
Having the rows inserted, click on the "Refetch" icon in the table output
|
||||||
above to see them.
|
above to see them.
|
||||||
|
|
||||||
<!-- livebook:{"branch_parent_index":0} -->
|
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.
|
||||||
|
|
||||||
## 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:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
data = [
|
|
||||||
%{id: 1, name: "Elixir", website: "https://elixir-lang.org"},
|
|
||||||
%{id: 2, name: "Erlang", website: "https://www.erlang.org"}
|
|
||||||
]
|
|
||||||
|
|
||||||
Kino.DataTable.new(data)
|
|
||||||
```
|
|
||||||
|
|
||||||
The data must be an enumerable, with records being maps,
|
|
||||||
keyword lists or tuples.
|
|
||||||
|
|
||||||
Now, let's get some more realistic data:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
processes = Process.list() |> Enum.map(&Process.info/1)
|
|
||||||
```
|
|
||||||
|
|
||||||
We can easily pick only the data keys that are relevant
|
|
||||||
for us:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
Kino.DataTable.new(
|
|
||||||
processes,
|
|
||||||
keys: [:registered_name, :initial_call, :reductions, :stack_size]
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
We can sort by the number of reductions to identify the
|
|
||||||
most busy processes!
|
|
||||||
|
|
||||||
<!-- livebook:{"branch_parent_index":0} -->
|
|
||||||
|
|
||||||
## Kino.Markdown
|
|
||||||
|
|
||||||
Sometimes you may want to render arbitrary content as rich-text,
|
|
||||||
that's when `Kino.Markdown.new/1` comes into play:
|
|
||||||
|
|
||||||
````elixir
|
|
||||||
"""
|
|
||||||
# Example
|
|
||||||
|
|
||||||
A regular Markdown file.
|
|
||||||
|
|
||||||
## Code
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
"Elixir" |> String.graphemes() |> Enum.frequencies()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Table
|
|
||||||
|
|
||||||
| ID | Name | Website |
|
|
||||||
| -- | ------ | ----------------------- |
|
|
||||||
| 1 | Elixir | https://elixir-lang.org |
|
|
||||||
| 2 | Erlang | https://www.erlang.org |
|
|
||||||
"""
|
|
||||||
|> Kino.Markdown.new()
|
|
||||||
````
|
|
||||||
|
|
||||||
<!-- livebook:{"branch_parent_index":0} -->
|
<!-- livebook:{"branch_parent_index":0} -->
|
||||||
|
|
||||||
|
@ -191,56 +151,53 @@ A regular Markdown file.
|
||||||
As we saw, Livebook automatically recognises widgets returned
|
As we saw, Livebook automatically recognises widgets returned
|
||||||
from each cell and renders them accordingly. However, sometimes
|
from each cell and renders them accordingly. However, sometimes
|
||||||
it's useful to explicitly render a widget in the middle of the cell,
|
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
|
does! It works with any type and tells Livebook to render the value
|
||||||
in its special manner.
|
in its special manner.
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
# Arbitrary data structures
|
# Arbitrary data structures
|
||||||
Kino.render([%{name: "Ada Lovelace"}, %{name: "Alan Turing"}])
|
Kino.render([%{name: "Ada Lovelace"}, %{name: "Alan Turing"}])
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
Kino.render(vl)
|
|
||||||
Kino.render(vl)
|
|
||||||
|
|
||||||
Kino.render("Plain text")
|
Kino.render("Plain text")
|
||||||
|
|
||||||
|
# Some kinos
|
||||||
|
Kino.render(Kino.Markdown.new("**Hello world**"))
|
||||||
|
|
||||||
"Cell result 🚀"
|
"Cell result 🚀"
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
<!-- livebook:{"branch_parent_index":0} -->
|
<!-- livebook:{"branch_parent_index":0} -->
|
||||||
|
|
||||||
## Kino.animate/3
|
## Kino.Frame and animations
|
||||||
|
|
||||||
If you want to continuously update the output as time passes,
|
`Kino.Frame` allows us to render an empty frame and update it
|
||||||
you can use `Kino.animate/3`:
|
as we progress. Let's render an empty frame:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
frame = Kino.Frame.new()
|
||||||
|
```
|
||||||
|
|
||||||
|
Now, let's render a random number between 1 and 100 directly
|
||||||
|
in the frame:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
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:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
Kino.Frame.append(frame, "Got: #{Enum.random(1..100)}")
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
```elixir
|
||||||
Kino.animate(100, 0, fn i ->
|
Kino.animate(100, 0, fn i ->
|
||||||
|
@ -251,6 +208,9 @@ end)
|
||||||
|
|
||||||
The above example renders new Markdown output every 100ms.
|
The above example renders new Markdown output every 100ms.
|
||||||
You can use the same approach to render regular output
|
You can use the same approach to render regular output
|
||||||
or images too! Also note some elements may have specific
|
or images too!
|
||||||
functions for periodic updates, such as `Kino.VegaLite.periodically/4`
|
|
||||||
seen in previous sections.
|
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).
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
# Building multiplayer Pong
|
# Multiplayer pong game from scratch
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
# Fun with VM introspection
|
# Runtime introspection with VegaLite
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
In this notebook we manually establish connection to a running node,
|
In this chapter we will use `Kino` and `VegaLite`
|
||||||
and then we try to retrieve and plot some interesting information
|
to introspect and plot how our system behaves over
|
||||||
about the system.
|
time. If you are not familiar with VegaLite, [read
|
||||||
|
our introductory chapter](/explore/notebooks/intro-to-vega-lite).
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
We are definitely gonna plot some data in this notebook,
|
Let's add `:vega_lite` and `:kino` as dependencies:
|
||||||
so let's add `:vega_lite` and `:kino` for that.
|
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
Mix.install([
|
Mix.install([
|
||||||
|
@ -18,46 +18,53 @@ Mix.install([
|
||||||
])
|
])
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Let's also define a convenience shortcut for the
|
||||||
|
VegaLite module:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
alias VegaLite, as: Vl
|
alias VegaLite, as: Vl
|
||||||
```
|
```
|
||||||
|
|
||||||
## Connecting to a remote node
|
## Connecting to a remote node
|
||||||
|
|
||||||
The first thing we need is a separate Elixir node. In practice,
|
Our goal is to introspect an Elixir node. The code we will
|
||||||
you would start an external Elixir system, such as by running the
|
write in this notebook can be used to introspect any running
|
||||||
following in your production app:
|
Elixir node. It can be a development environment that you would
|
||||||
|
start with:
|
||||||
|
|
||||||
```
|
```
|
||||||
iex --name my_app@IP -S mix TASK
|
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).
|
[`mix release`](https://hexdocs.pm/mix/Mix.Tasks.Release.html).
|
||||||
|
|
||||||
For convenience, however, you can simply start [a new notebook](/explore/notebooks/new),
|
In order to connect two nodes, we need to know their node name
|
||||||
since Livebook automatically starts each notebook as a remote node.
|
and their cookie. We can get this information for the Livebook
|
||||||
|
runtime like this:
|
||||||
Once you start a new notebook, you can find its node name and
|
|
||||||
cookie by running the following inside an Elixir cell:
|
|
||||||
|
|
||||||
<!-- livebook:{"force_markdown":true} -->
|
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
IO.puts node()
|
IO.puts node()
|
||||||
IO.puts Node.get_cookie()
|
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
|
```elixir
|
||||||
node_input = Kino.Input.text("Node")
|
node_input = Kino.Input.text("Node", default: node())
|
||||||
cookie_input = Kino.Input.text("Cookie")
|
cookie_input = Kino.Input.text("Cookie", default: Node.get_cookie())
|
||||||
|
|
||||||
|
Kino.render(node_input)
|
||||||
|
Kino.render(cookie_input)
|
||||||
|
:ok
|
||||||
```
|
```
|
||||||
|
|
||||||
And paste the node name and the cookie value from the other node inside.
|
Now let's read the inputs, configure the cookie, and connect to the
|
||||||
|
other node:
|
||||||
Now let's read the inputs, configure the cookie, and connect to the other notebook:
|
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
node =
|
node =
|
||||||
|
@ -83,9 +90,6 @@ Node.spawn(node, fn ->
|
||||||
end)
|
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
|
## Inspecting processes
|
||||||
|
|
||||||
Now we are going to extract some information from the running node on our own!
|
Now we are going to extract some information from the running node on our own!
|
||||||
|
@ -127,7 +131,8 @@ processes =
|
||||||
end)
|
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
|
```elixir
|
||||||
Vl.new(width: 600, height: 400)
|
Vl.new(width: 600, height: 400)
|
||||||
|
@ -144,17 +149,22 @@ and take the most memory.
|
||||||
|
|
||||||
## Tracking memory usage
|
## 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:
|
There's a very simple way to determine current memory usage in the VM:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
:erlang.memory()
|
:erlang.memory()
|
||||||
```
|
```
|
||||||
|
|
||||||
We can use `Kino.VegaLite.periodically/4` to create a self-updating
|
Now let's build a dynamic VegaLite graph. Instead of returning the
|
||||||
plot of memory usage over time on the remote node!
|
VegaLite specification as is, we will wrap it in `Kino.VegaLite.new/1`
|
||||||
|
to make it dynamic:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
widget =
|
memory_plot =
|
||||||
Vl.new(width: 600, height: 400, padding: 20)
|
Vl.new(width: 600, height: 400, padding: 20)
|
||||||
|> Vl.repeat(
|
|> Vl.repeat(
|
||||||
[layer: ["total", "processes", "atom", "binary", "code", "ets"]],
|
[layer: ["total", "processes", "atom", "binary", "code", "ets"]],
|
||||||
|
@ -165,38 +175,38 @@ widget =
|
||||||
|> Vl.encode(:color, datum: [repeat: :layer], type: :nominal)
|
|> Vl.encode(:color, datum: [repeat: :layer], type: :nominal)
|
||||||
)
|
)
|
||||||
|> Kino.VegaLite.new()
|
|> 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 =
|
point =
|
||||||
:rpc.call(node, :erlang, :memory, [])
|
:rpc.call(node, :erlang, :memory, [])
|
||||||
|> Enum.map(fn {type, bytes} -> {type, bytes / 1_000_000} end)
|
|> Enum.map(fn {type, bytes} -> {type, bytes / 1_000_000} end)
|
||||||
|> Map.new()
|
|> Map.new()
|
||||||
|> Map.put(:iter, i)
|
|> Map.put(:iter, i)
|
||||||
|
|
||||||
Kino.VegaLite.push(widget, point, window: 1000)
|
Kino.VegaLite.push(memory_plot, point, window: 1000)
|
||||||
{:cont, i + 1}
|
{:cont, i + 1}
|
||||||
end)
|
end)
|
||||||
```
|
```
|
||||||
|
|
||||||
Unless you connected to a production node, the memory usage
|
Unless you connected to a production node, the memory usage
|
||||||
most likely doesn't change, so to emulate some spikes you can
|
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**
|
**Binary usage**
|
||||||
|
|
||||||
<!-- livebook:{"force_markdown":true} -->
|
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
x = Enum.reduce(1..10_000, [], fn i, acc ->
|
for i <- 1..10_000 do
|
||||||
[String.duplicate("cat", i) | acc]
|
String.duplicate("cat", i)
|
||||||
end)
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
**ETS usage**
|
**ETS usage**
|
||||||
|
|
||||||
<!-- livebook:{"force_markdown":true} -->
|
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
tid = :ets.new(:users, [:set, :public])
|
tid = :ets.new(:users, [:set, :public])
|
||||||
|
|
||||||
|
@ -204,3 +214,6 @@ for i <- 1..1_000_000 do
|
||||||
:ets.insert(tid, {i, "User #{i}"})
|
:ets.insert(tid, {i, "User #{i}"})
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In the next chapter, we will learn [how to use `Kino.Control`
|
||||||
|
to build a chat app](/explore/notebooks/chat-app)!
|
Binary file not shown.
Before Width: | Height: | Size: 14 KiB |
Loading…
Add table
Reference in a new issue