Add Livebook Hub with Fly.IO integration (#1319)

This commit is contained in:
Alexandre de Souza 2022-08-18 10:34:27 -03:00 committed by GitHub
parent e68c72febb
commit 5c050e640f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1238 additions and 52 deletions

View file

@ -275,4 +275,25 @@
.info-box {
@apply p-4 bg-gray-100 text-sm text-gray-500 font-medium rounded-lg;
}
/* Cards */
.card-item {
@apply cursor-pointer;
}
.card-item:not(.selected) .card-item-logo {
@apply border-gray-100;
}
.card-item:not(.selected) .card-item-body {
@apply bg-gray-100;
}
.card-item.selected .card-item-logo {
@apply border-gray-200;
}
.card-item.selected .card-item-body {
@apply bg-gray-200;
}
}

View file

@ -24,6 +24,7 @@ config :livebook,
app_service_url: nil,
authentication_mode: :token,
explore_notebooks: [],
feature_flags: [],
plugs: [],
shutdown_enabled: false,
storage: Livebook.Storage.Ets

View file

@ -25,6 +25,7 @@ config :livebook, LivebookWeb.Endpoint,
config :livebook, :iframe_port, 4001
config :livebook, :shutdown_enabled, true
config :livebook, :feature_flags, hub: true
# ## SSL Support
#

View file

@ -22,6 +22,7 @@ if File.exists?(data_path) do
end
config :livebook, :data_path, data_path
config :livebook, :feature_flags, hub: true
# Use longnames when running tests in CI, so that no host resolution is required,
# see https://github.com/livebook-dev/livebook/pull/173#issuecomment-819468549

122
lib/livebook/hubs.ex Normal file
View file

@ -0,0 +1,122 @@
defmodule Livebook.Hubs do
@moduledoc false
alias Livebook.Storage
alias Livebook.Hubs.{Fly, Metadata, Provider}
defmodule NotFoundError do
@moduledoc false
defexception [:id, plug_status: 404]
def message(%{id: id}) do
"could not find a hub matching #{inspect(id)}"
end
end
@namespace :hubs
@doc """
Gets a list of hubs from storage.
"""
@spec fetch_hubs() :: list(Provider.t())
def fetch_hubs do
for fields <- Storage.current().all(@namespace) do
to_struct(fields)
end
end
@doc """
Gets a list of metadatas from storage.
"""
@spec fetch_metadatas() :: list(Metadata.t())
def fetch_metadatas do
for hub <- fetch_hubs() do
Provider.normalize(hub)
end
end
@doc """
Gets one hub from storage.
Raises `NotFoundError` if the hub does not exist.
"""
@spec fetch_hub!(String.t()) :: Provider.t()
def fetch_hub!(id) do
case Storage.current().fetch(@namespace, id) do
:error -> raise NotFoundError, id: id
{:ok, fields} -> to_struct(fields)
end
end
@doc """
Checks if hub already exists.
"""
@spec hub_exists?(String.t()) :: boolean()
def hub_exists?(id) do
case Storage.current().fetch(@namespace, id) do
:error -> false
{:ok, _} -> true
end
end
@doc """
Saves a new hub to the configured ones.
"""
@spec save_hub(Provider.t()) :: Provider.t()
def save_hub(struct) do
attributes = struct |> Map.from_struct() |> Map.to_list()
with :ok <- Storage.current().insert(@namespace, struct.id, attributes),
:ok <- broadcast_hubs_change() do
struct
end
end
@doc false
def delete_hub(id) do
Storage.current().delete(@namespace, id)
end
@doc false
def clean_hubs do
for hub <- fetch_hubs(), do: delete_hub(hub.id)
:ok
end
@doc """
Subscribes to updates in hubs information.
## Messages
* `{:hubs_metadata_changed, hubs}`
"""
@spec subscribe() :: :ok | {:error, term()}
def subscribe do
Phoenix.PubSub.subscribe(Livebook.PubSub, "hubs")
end
@doc """
Unsubscribes from `subscribe/0`.
"""
@spec unsubscribe() :: :ok
def unsubscribe do
Phoenix.PubSub.unsubscribe(Livebook.PubSub, "hubs")
end
@doc """
Notifies interested processes about hubs data change.
Broadcasts `{:hubs_metadata_changed, hubs}` message under the `"hubs"` topic.
"""
@spec broadcast_hubs_change() :: :ok
def broadcast_hubs_change do
Phoenix.PubSub.broadcast(Livebook.PubSub, "hubs", {:hubs_metadata_changed, fetch_metadatas()})
end
defp to_struct(%{id: "fly-" <> _} = fields) do
Provider.load(%Fly{}, fields)
end
end

57
lib/livebook/hubs/fly.ex Normal file
View file

@ -0,0 +1,57 @@
defmodule Livebook.Hubs.Fly do
@moduledoc false
defstruct [
:id,
:access_token,
:hub_name,
:hub_color,
:organization_id,
:organization_type,
:organization_name,
:application_id
]
@type t :: %__MODULE__{
id: Livebook.Utils.id(),
access_token: String.t(),
hub_name: String.t(),
hub_color: Livebook.Users.User.hex_color(),
organization_id: String.t(),
organization_type: String.t(),
organization_name: String.t(),
application_id: String.t()
}
def save_fly(fly, params) do
fly = %{fly | hub_name: params["hub_name"], hub_color: params["hub_color"]}
Livebook.Hubs.save_hub(fly)
end
end
defimpl Livebook.Hubs.Provider, for: Livebook.Hubs.Fly do
def load(%Livebook.Hubs.Fly{} = fly, fields) do
%{
fly
| id: fields.id,
access_token: fields.access_token,
hub_name: fields.hub_name,
hub_color: fields.hub_color,
organization_id: fields.organization_id,
organization_type: fields.organization_type,
organization_name: fields.organization_name,
application_id: fields.application_id
}
end
def normalize(%Livebook.Hubs.Fly{} = fly) do
%Livebook.Hubs.Metadata{
id: fly.id,
name: fly.hub_name,
provider: fly,
color: fly.hub_color
}
end
def type(_), do: "fly"
end

View file

@ -0,0 +1,68 @@
defmodule Livebook.Hubs.FlyClient do
@moduledoc false
alias Livebook.Hubs.Fly
alias Livebook.Utils.HTTP
def fetch_apps(access_token) do
query = """
query {
apps {
nodes {
id
organization {
id
name
type
}
}
}
}
"""
with {:ok, body} <- graphql(access_token, query) do
apps =
for node <- body["apps"]["nodes"] do
%Fly{
id: "fly-" <> node["id"],
access_token: access_token,
organization_id: node["organization"]["id"],
organization_type: node["organization"]["type"],
organization_name: node["organization"]["name"],
application_id: node["id"]
}
end
{:ok, apps}
end
end
defp graphql(access_token, query) do
headers = [{"Authorization", "Bearer #{access_token}"}]
body = {"application/json", Jason.encode!(%{query: query})}
case HTTP.request(:post, graphql_endpoint(), headers: headers, body: body) do
{:ok, 200, _, body} ->
case Jason.decode!(body) do
%{"errors" => [%{"extensions" => %{"code" => code}}]} ->
{:error, "request failed with code: #{code}"}
%{"errors" => [%{"message" => message}]} ->
{:error, message}
%{"data" => data} ->
{:ok, data}
end
{:ok, _, _, body} ->
{:error, body}
{:error, _} = error ->
error
end
end
defp graphql_endpoint do
Application.get_env(:livebook, :fly_graphql_endpoint, "https://api.fly.io/graphql")
end
end

View file

@ -0,0 +1,11 @@
defmodule Livebook.Hubs.Metadata do
@moduledoc false
defstruct [:id, :name, :provider, :color]
@type t :: %__MODULE__{
id: String.t(),
name: String.t(),
provider: struct(),
color: String.t()
}
end

View file

@ -0,0 +1,27 @@
defprotocol Livebook.Hubs.Provider do
@moduledoc false
@type t :: %{
required(:__struct__) => module(),
required(:id) => String.t(),
optional(any()) => any()
}
@doc """
Normalize given struct to `Livebook.Hubs.Metadata` struct.
"""
@spec normalize(t()) :: Livebook.Hubs.Metadata.t()
def normalize(struct)
@doc """
Loads fields into given struct.
"""
@spec load(t(), map() | keyword()) :: t()
def load(struct, fields)
@doc """
Gets the type from struct.
"""
@spec type(t()) :: String.t()
def type(struct)
end

View file

@ -7,14 +7,14 @@ defmodule LivebookWeb.ExploreLive do
alias LivebookWeb.{SidebarHelpers, ExploreHelpers, PageHelpers}
alias Livebook.Notebook.Explore
on_mount LivebookWeb.SidebarHook
@impl true
def mount(_params, _session, socket) do
[lead_notebook_info | notebook_infos] = Explore.visible_notebook_infos()
{:ok,
socket
|> SidebarHelpers.sidebar_handlers()
|> assign(
assign(socket,
lead_notebook_info: lead_notebook_info,
notebook_infos: notebook_infos,
page_title: "Livebook - Explore"
@ -29,6 +29,7 @@ defmodule LivebookWeb.ExploreLive do
socket={@socket}
current_page={Routes.explore_path(@socket, :page)}
current_user={@current_user}
saved_hubs={@saved_hubs}
/>
<div class="grow overflow-y-auto">
<SidebarHelpers.toggle socket={@socket} />

View file

@ -7,6 +7,8 @@ defmodule LivebookWeb.HomeLive do
alias LivebookWeb.{ExploreHelpers, PageHelpers, SidebarHelpers}
alias Livebook.{Sessions, Session, LiveMarkdown, Notebook, FileSystem}
on_mount LivebookWeb.SidebarHook
@impl true
def mount(params, _session, socket) do
if connected?(socket) do
@ -18,9 +20,7 @@ defmodule LivebookWeb.HomeLive do
notebook_infos = Notebook.Explore.visible_notebook_infos() |> Enum.take(3)
{:ok,
socket
|> SidebarHelpers.sidebar_handlers()
|> assign(
assign(socket,
self_path: Routes.home_path(socket, :page),
file: determine_file(params),
file_info: %{exists: true, access: :read_write},
@ -43,6 +43,7 @@ defmodule LivebookWeb.HomeLive do
socket={@socket}
current_page={Routes.home_path(@socket, :page)}
current_user={@current_user}
saved_hubs={@saved_hubs}
/>
<div class="grow overflow-y-auto">
<SidebarHelpers.toggle socket={@socket}>

View file

@ -0,0 +1,34 @@
defmodule LivebookWeb.SidebarHook do
import Phoenix.LiveView
def on_mount(:default, _params, _session, socket) do
if connected?(socket) do
Livebook.Hubs.subscribe()
end
socket =
socket
|> assign(saved_hubs: Livebook.Hubs.fetch_metadatas())
|> attach_hook(:hubs, :handle_info, &handle_info/2)
|> attach_hook(:shutdown, :handle_event, &handle_event/3)
{:cont, socket}
end
defp handle_info({:hubs_metadata_changed, hubs}, socket) do
{:halt, assign(socket, :saved_hubs, hubs)}
end
defp handle_info(_event, socket), do: {:cont, socket}
defp handle_event("shutdown", _params, socket) do
if Livebook.Config.shutdown_enabled?() do
System.stop()
{:halt, put_flash(socket, :info, "Livebook is shutting down. You can close this page.")}
else
socket
end
end
defp handle_event(_event, _params, socket), do: {:cont, socket}
end

View file

@ -0,0 +1,145 @@
defmodule LivebookWeb.HubLive do
use LivebookWeb, :live_view
import LivebookWeb.UserHelpers
alias Livebook.Hubs
alias Livebook.Hubs.Provider
alias LivebookWeb.{PageHelpers, SidebarHelpers}
alias Phoenix.LiveView.JS
on_mount LivebookWeb.SidebarHook
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, selected_provider: nil, hub: nil, page_title: "Livebook - Hub")}
end
@impl true
def render(assigns) do
~H"""
<div class="flex grow h-full">
<SidebarHelpers.sidebar
socket={@socket}
current_user={@current_user}
current_page=""
saved_hubs={@saved_hubs}
/>
<div class="grow px-6 py-8 overflow-y-auto">
<div class="max-w-screen-md w-full mx-auto px-4 pb-8 space-y-8">
<div>
<PageHelpers.title text="Hub" socket={@socket} />
<p class="mt-4 text-gray-700">
Manage your Livebooks in the cloud with Hubs.
</p>
</div>
<div class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-semibold pb-2 border-b border-gray-200">
1. Select your Hub service
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<.card_item id="fly" selected={@selected_provider} title="Fly">
<:logo>
<%= Phoenix.HTML.raw(File.read!("static/images/fly.svg")) %>
</:logo>
<:headline>
Deploy notebooks to your Fly account.
</:headline>
</.card_item>
<.card_item id="enterprise" selected={@selected_provider} title="Livebook Enterprise">
<:logo>
<img src="/images/logo.png" class="max-h-full max-w-[75%]" alt="Fly logo" />
</:logo>
<:headline>
Control access, manage secrets, and deploy notebooks within your team and company.
</:headline>
</.card_item>
</div>
</div>
<%= if @selected_provider do %>
<div class="flex flex-col space-y-4">
<h2 class="text-xl text-gray-800 font-semibold pb-2 border-b border-gray-200">
2. Configure your Hub
</h2>
<%= if @selected_provider == "fly" do %>
<.live_component
module={LivebookWeb.HubLive.FlyComponent}
id="fly-form"
operation={@operation}
hub={@hub}
/>
<% end %>
<%= if @selected_provider == "enterprise" do %>
<div>
Livebook Enterprise is currently in closed beta. If you want to learn more, <a
href="https://livebook.dev/#livebook-plans"
class="pointer-events-auto text-blue-600"
target="_blank"
>click here</a>.
</div>
<% end %>
</div>
<% end %>
</div>
</div>
<.current_user_modal current_user={@current_user} />
</div>
"""
end
defp card_item(assigns) do
~H"""
<div
id={@id}
class={"flex card-item flex-col " <> card_item_bg_color(@id, @selected)}
phx-click={JS.push("select_provider", value: %{value: @id})}
>
<div class="flex items-center justify-center card-item-logo p-6 border-2 rounded-t-2xl h-[150px]">
<%= render_slot(@logo) %>
</div>
<div class="card-item-body px-6 py-4 rounded-b-2xl grow">
<p class="text-gray-800 font-semibold cursor-pointer mt-2 text-sm text-gray-600">
<%= @title %>
</p>
<p class="mt-2 text-sm text-gray-600">
<%= render_slot(@headline) %>
</p>
</div>
</div>
"""
end
defp card_item_bg_color(id, selected) when id == selected, do: "selected"
defp card_item_bg_color(_id, _selected), do: ""
@impl true
def handle_params(%{"id" => id}, _url, socket) do
hub = Hubs.fetch_hub!(id)
provider = Provider.type(hub)
{:noreply, assign(socket, operation: :edit, hub: hub, selected_provider: provider)}
end
def handle_params(_params, _url, socket) do
{:noreply, assign(socket, operation: :new)}
end
@impl true
def handle_event("select_provider", %{"value" => service}, socket) do
{:noreply, assign(socket, selected_provider: service)}
end
@impl true
def handle_info({:flash_error, message}, socket) do
{:noreply, put_flash(socket, :error, message)}
end
end

View file

@ -0,0 +1,213 @@
defmodule LivebookWeb.HubLive.FlyComponent do
use LivebookWeb, :live_component
alias Livebook.Hubs
alias Livebook.Hubs.{Fly, FlyClient}
alias Livebook.Users.User
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> load_data()}
end
@impl true
def render(assigns) do
~H"""
<div>
<.form
id={@id}
class="flex flex-col space-y-4"
let={f}
for={:fly}
phx-submit="save_hub"
phx-change="update_data"
phx-target={@myself}
phx-debounce="blur"
>
<div class="flex flex-col space-y-1">
<h3 class="text-gray-800 font-semibold">
Access Token
</h3>
<%= password_input(f, :access_token,
phx_change: "fetch_data",
phx_debounce: "blur",
phx_target: @myself,
disabled: @operation == :edit,
value: @data["access_token"],
class: "input w-full",
autofocus: true,
spellcheck: "false",
required: true,
autocomplete: "off"
) %>
</div>
<%= if length(@apps) > 0 do %>
<div class="flex flex-col space-y-1">
<h3 class="text-gray-800 font-semibold">
Application
</h3>
<%= select(f, :application_id, @select_options,
class: "input",
required: true,
phx_target: @myself,
phx_change: "select_app",
disabled: @operation == :edit
) %>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="flex flex-col space-y-1">
<h3 class="text-gray-800 font-semibold">
Name
</h3>
<%= text_input(f, :hub_name, value: @data["hub_name"], class: "input", required: true) %>
</div>
<div class="flex flex-col space-y-1">
<h3 class="text-gray-800 font-semibold">
Color
</h3>
<div class="flex space-x-4 items-center">
<div
class="border-[3px] rounded-lg p-1 flex justify-center items-center"
style={"border-color: #{@data["hub_color"]}"}
>
<div class="rounded h-5 w-5" style={"background-color: #{@data["hub_color"]}"}>
</div>
</div>
<div class="relative grow">
<%= text_input(f, :hub_color,
value: @data["hub_color"],
class: "input",
spellcheck: "false",
required: true,
maxlength: 7
) %>
<button
class="icon-button absolute right-2 top-1"
type="button"
phx-click="randomize_color"
phx-target={@myself}
>
<.remix_icon icon="refresh-line" class="text-xl" />
</button>
</div>
</div>
</div>
</div>
<%= submit("Save", class: "button-base button-blue", phx_disable_with: "Saving...") %>
<% end %>
</.form>
</div>
"""
end
defp load_data(%{assigns: %{operation: :new}} = socket) do
assign(socket, data: %{}, select_options: [], apps: [])
end
defp load_data(%{assigns: %{operation: :edit, hub: fly}} = socket) do
data = %{
"access_token" => fly.access_token,
"application_id" => fly.application_id,
"hub_name" => fly.hub_name,
"hub_color" => fly.hub_color
}
{:ok, apps} = FlyClient.fetch_apps(fly.access_token)
opts = select_options(apps, fly.application_id)
assign(socket, data: data, selected_app: fly, select_options: opts, apps: apps)
end
@impl true
def handle_event("fetch_data", %{"fly" => %{"access_token" => token}}, socket) do
case FlyClient.fetch_apps(token) do
{:ok, apps} ->
data = %{"access_token" => token, "hub_color" => User.random_hex_color()}
opts = select_options(apps)
{:noreply, assign(socket, data: data, select_options: opts, apps: apps)}
{:error, _} ->
send(self(), {:flash_error, "Invalid Access Token"})
{:noreply, assign(socket, data: %{}, select_options: [], apps: [])}
end
end
def handle_event("randomize_color", _, socket) do
data = Map.replace!(socket.assigns.data, "hub_color", User.random_hex_color())
{:noreply, assign(socket, data: data)}
end
def handle_event("save_hub", %{"fly" => params}, socket) do
params =
if socket.assigns.data do
Map.merge(socket.assigns.data, params)
else
params
end
case socket.assigns.operation do
:new -> create_fly(socket, params)
:edit -> save_fly(socket, params)
end
end
def handle_event("select_app", %{"fly" => %{"application_id" => app_id}}, socket) do
selected_app = Enum.find(socket.assigns.apps, &(&1.application_id == app_id))
opts = select_options(socket.assigns.apps, app_id)
{:noreply, assign(socket, selected_app: selected_app, select_options: opts)}
end
def handle_event("update_data", %{"fly" => data}, socket) do
data =
if socket.assigns.data do
Map.merge(socket.assigns.data, data)
else
data
end
opts = select_options(socket.assigns.apps, data["application_id"])
{:noreply, assign(socket, data: data, select_options: opts)}
end
defp select_options(hubs, app_id \\ nil) do
disabled_option = [key: "Select one application", value: "", selected: true, disabled: true]
options =
for fly <- hubs do
[
key: "#{fly.organization_name} - #{fly.application_id}",
value: fly.application_id,
selected: fly.application_id == app_id
]
end
Enum.reverse(options ++ [disabled_option])
end
defp create_fly(socket, params) do
if Hubs.hub_exists?(socket.assigns.selected_app.id) do
send(self(), {:flash_error, "Application already exists"})
{:noreply, assign(socket, data: params)}
else
save_fly(socket, params)
end
end
defp save_fly(socket, params) do
Fly.save_fly(socket.assigns.selected_app, params)
opts = select_options(socket.assigns.apps, params["application_id"])
{:noreply, assign(socket, data: params, select_options: opts)}
end
end

View file

@ -5,12 +5,12 @@ defmodule LivebookWeb.SettingsLive do
alias LivebookWeb.{SidebarHelpers, PageHelpers}
on_mount LivebookWeb.SidebarHook
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> SidebarHelpers.sidebar_handlers()
|> assign(
assign(socket,
file_systems: Livebook.Settings.file_systems(),
autosave_path_state: %{
file: autosave_dir(),
@ -29,6 +29,7 @@ defmodule LivebookWeb.SettingsLive do
socket={@socket}
current_page={Routes.settings_path(@socket, :page)}
current_user={@current_user}
saved_hubs={@saved_hubs}
/>
<div class="grow overflow-y-auto">
<SidebarHelpers.toggle socket={@socket} />

View file

@ -66,36 +66,45 @@ defmodule LivebookWeb.SidebarHelpers do
aria-label="sidebar"
data-el-sidebar
>
<div class="flex flex-col space-y-3">
<div class="group flex items-center mb-5">
<%= live_redirect to: Routes.home_path(@socket, :page), class: "flex items-center border-l-4 border-gray-900" do %>
<img src="/images/logo.png" class="group mx-2" height="40" width="40" alt="logo livebook" />
<span class="text-gray-300 text-2xl font-logo ml-[-1px] group-hover:text-white pt-1">
Livebook
<div class="flex flex-col">
<div class="space-y-3">
<div class="group flex items-center mb-5">
<%= live_redirect to: Routes.home_path(@socket, :page), class: "flex items-center border-l-4 border-gray-900" do %>
<img
src="/images/logo.png"
class="group mx-2"
height="40"
width="40"
alt="logo livebook"
/>
<span class="text-gray-300 text-2xl font-logo ml-[-1px] group-hover:text-white pt-1">
Livebook
</span>
<% end %>
<span class="text-gray-300 text-xs font-normal font-sans mx-2.5 pt-3 cursor-default">
v<%= Application.spec(:livebook, :vsn) %>
</span>
<% end %>
<span class="text-gray-300 text-xs font-normal font-sans mx-2.5 pt-3 cursor-default">
v<%= Application.spec(:livebook, :vsn) %>
</span>
</div>
<.sidebar_link
title="Home"
icon="home-6-line"
to={Routes.home_path(@socket, :page)}
current={@current_page}
/>
<.sidebar_link
title="Explore"
icon="compass-3-line"
to={Routes.explore_path(@socket, :page)}
current={@current_page}
/>
<.sidebar_link
title="Settings"
icon="settings-3-line"
to={Routes.settings_path(@socket, :page)}
current={@current_page}
/>
</div>
<.sidebar_link
title="Home"
icon="home-6-line"
to={Routes.home_path(@socket, :page)}
current={@current_page}
/>
<.sidebar_link
title="Explore"
icon="compass-3-line"
to={Routes.explore_path(@socket, :page)}
current={@current_page}
/>
<.sidebar_link
title="Settings"
icon="settings-3-line"
to={Routes.settings_path(@socket, :page)}
current={@current_page}
/>
<.hub_section socket={@socket} hubs={@saved_hubs} />
</div>
<div class="flex flex-col">
<%= if Livebook.Config.shutdown_enabled?() do %>
@ -150,24 +159,56 @@ defmodule LivebookWeb.SidebarHelpers do
"""
end
defp hub_section(assigns) do
~H"""
<%= if Application.get_env(:livebook, :feature_flags)[:hub] do %>
<div id="hubs" class="flex flex-col mt-12">
<div class="space-y-1">
<div class="grid grid-cols-1 md:grid-cols-2 relative leading-6 mb-2">
<div class="flex flex-col">
<small class="ml-5 font-medium text-white">HUBS</small>
</div>
<div class="flex flex-col">
<%= live_redirect to: Routes.hub_path(@socket, :page),
class: "flex absolute right-5 items-center justify-center
text-gray-400 hover:text-white hover:border-white" do %>
<.remix_icon icon="add-line" />
<% end %>
</div>
</div>
<%= for hub <- @hubs do %>
<%= live_redirect to: Routes.hub_path(@socket, :edit, hub.id), class: "h-7 flex items-center cursor-pointer text-gray-400 hover:text-white" do %>
<.remix_icon
class="text-lg leading-6 w-[56px] flex justify-center"
icon="checkbox-blank-circle-fill"
style={"color: #{hub.color}"}
/>
<span class="text-sm font-medium">
<%= hub.name %>
</span>
<% end %>
<% end %>
<div class="h-7 flex items-center cursor-pointer text-gray-400 hover:text-white mt-2">
<%= live_redirect to: Routes.hub_path(@socket, :page), class: "h-7 flex items-center cursor-pointer text-gray-400 hover:text-white" do %>
<.remix_icon class="text-lg leading-6 w-[56px] flex justify-center" icon="add-line" />
<span class="text-sm font-medium">
Add Hub
</span>
<% end %>
</div>
</div>
</div>
<% end %>
"""
end
defp sidebar_link_text_color(to, current) when to == current, do: "text-white"
defp sidebar_link_text_color(_to, _current), do: "text-gray-400"
defp sidebar_link_border_color(to, current) when to == current, do: "border-white"
defp sidebar_link_border_color(_to, _current), do: "border-transparent"
def sidebar_handlers(socket) do
if Livebook.Config.shutdown_enabled?() do
attach_hook(socket, :shutdown, :handle_event, fn
"shutdown", _params, socket ->
System.stop()
{:halt, put_flash(socket, :info, "Livebook is shutting down. You can close this page.")}
_event, _params, socket ->
{:cont, socket}
end)
else
socket
end
end
end

View file

@ -55,6 +55,11 @@ defmodule LivebookWeb.Router do
live "/explore", ExploreLive, :page
live "/explore/notebooks/:slug", ExploreLive, :notebook
if Application.compile_env(:livebook, :feature_flags)[:hub] do
live "/hub", HubLive, :page
live "/hub/:id", HubLive, :edit
end
live "/sessions/:id", SessionLive, :page
live "/sessions/:id/shortcuts", SessionLive, :shortcuts
live "/sessions/:id/settings/runtime", SessionLive, :runtime_settings

19
static/images/fly.svg Normal file
View file

@ -0,0 +1,19 @@
<svg role="img" class="" fill-rule="evenodd" viewBox="0 0 273 84" style="pointer-events: none; width: auto; height: 36px;" aria-labelledby="logo-title logo-description">
<title id="logo-title">Fly</title>
<desc id="logo-description">App performance optimization</desc>
<g buffered-rendering="static">
<path d="M57.413 10.134h9.454c8.409 0 15.236 6.827 15.236 15.236v33.243c0 8.409-6.827 15.236-15.236 15.236h-.745c-4.328-.677-6.205-1.975-7.655-3.072l-12.02-9.883a1.692 1.692 0 0 0-2.128 0l-3.905 3.211-10.998-9.043a1.688 1.688 0 0 0-2.127 0L12.01 68.503c-3.075 2.501-5.109 2.039-6.428 1.894C2.175 67.601 0 63.359 0 58.613V25.37c0-8.409 6.827-15.236 15.237-15.236h9.433l-.017.038-.318.927-.099.318-.428 1.899-.059.333-.188 1.902-.025.522-.004.183.018.872.043.511.106.8.135.72.16.663.208.718.54 1.52.178.456.94 1.986.332.61 1.087 1.866.416.673 1.517 2.234.219.296 1.974 2.569.638.791 2.254 2.635.463.507 1.858 1.999.736.762 1.216 1.208-.244.204-.152.137c-.413.385-.805.794-1.172 1.224a10.42 10.42 0 0 0-.504.644 8.319 8.319 0 0 0-.651 1.064 6.234 6.234 0 0 0-.261.591 5.47 5.47 0 0 0-.353 1.606l-.007.475a5.64 5.64 0 0 0 .403 1.953 5.44 5.44 0 0 0 1.086 1.703c.338.36.723.674 1.145.932.359.22.742.401 1.14.539a6.39 6.39 0 0 0 2.692.306h.005a6.072 6.072 0 0 0 2.22-.659c.298-.158.582-.341.848-.549a5.438 5.438 0 0 0 1.71-2.274c.28-.699.417-1.446.405-2.198l-.022-.393a5.535 5.535 0 0 0-.368-1.513 6.284 6.284 0 0 0-.285-.618 8.49 8.49 0 0 0-.67-1.061 11.022 11.022 0 0 0-.354-.453 14.594 14.594 0 0 0-1.308-1.37l-.329-.28.557-.55 2.394-2.5.828-.909 1.287-1.448.837-.979 1.194-1.454.808-1.016 1.187-1.587.599-.821.85-1.271.708-1.083 1.334-2.323.763-1.524.022-.047.584-1.414a.531.531 0 0 0 .02-.056l.629-1.962.066-.286.273-1.562.053-.423.016-.259.019-.978-.005-.182-.05-.876-.062-.68-.31-1.961c-.005-.026-.01-.052-.018-.078l-.398-1.45-.137-.403-.179-.446Zm4.494 41.455a3.662 3.662 0 0 0-3.61 3.61 3.663 3.663 0 0 0 3.61 3.609 3.665 3.665 0 0 0 3.611-3.609 3.663 3.663 0 0 0-3.611-3.61Z" fill="url(#_Radial1)" fill-opacity="1"></path>
<path d="M32.915 73.849H15.237a15.171 15.171 0 0 1-9.655-3.452c1.319.145 3.353.607 6.428-1.894l15.279-13.441a1.688 1.688 0 0 1 2.127 0l10.998 9.043 3.905-3.211a1.692 1.692 0 0 1 2.128 0l12.02 9.883c1.45 1.097 3.327 2.395 7.655 3.072h-7.996a3.399 3.399 0 0 1-1.963-.654l-.14-.108-10.578-8.784-10.439 8.784a3.344 3.344 0 0 1-2.091.762Zm28.992-22.26a3.662 3.662 0 0 0-3.61 3.61 3.663 3.663 0 0 0 3.61 3.609 3.665 3.665 0 0 0 3.611-3.609 3.663 3.663 0 0 0-3.611-3.61ZM38.57 40.652l-1.216-1.208-.736-.762-1.858-1.999-.463-.507-2.254-2.635-.638-.791-1.974-2.569-.219-.296-1.517-2.234-.416-.673-1.087-1.866-.332-.61-.94-1.986-.178-.456-.54-1.52-.208-.718-.16-.663-.135-.72-.106-.8-.043-.511-.018-.872.004-.183.025-.522.188-1.902.059-.333.428-1.899.099-.318.318-.927.102-.24.506-1.112.351-.662.489-.806.487-.718.347-.456.4-.482.44-.484.377-.379.918-.808.671-.549c.016-.014.033-.026.05-.038l.794-.537.631-.402 1.198-.631c.018-.011.039-.02.058-.029l1.699-.705.157-.059 1.51-.442.638-.143.862-.173.572-.087.877-.109.598-.053 1.187-.063.465-.005.881.018.229.013 1.276.106 1.688.238.195.041 1.668.415.49.146.544.188.663.251.524.222.77.363.485.249.872.512.325.2 1.189.868.341.296.829.755.041.041.703.754.242.273.825 1.096.168.262.655 1.106.197.379.369.825.386.963.137.403.398 1.45a.731.731 0 0 1 .018.078l.31 1.961.062.68.05.876.005.182-.019.978-.016.259-.053.423-.273 1.562-.066.286-.629 1.962a.531.531 0 0 1-.02.056l-.584 1.414-.022.047-.763 1.524-1.334 2.323-.708 1.083-.85 1.271-.599.821-1.187 1.587-.808 1.016-1.194 1.454-.837.979-1.287 1.448-.828.909-2.394 2.5-.557.55.329.28c.465.428.902.885 1.308 1.37.122.148.24.299.354.453a8.49 8.49 0 0 1 .67 1.061c.106.2.201.407.285.618.191.484.32.996.368 1.513l.022.393a5.666 5.666 0 0 1-.405 2.198 5.438 5.438 0 0 1-1.71 2.274c-.266.208-.55.391-.848.549a6.072 6.072 0 0 1-2.22.659h-.005A6.39 6.39 0 0 1 39 51.724a5.854 5.854 0 0 1-1.14-.539 5.523 5.523 0 0 1-1.145-.932 5.44 5.44 0 0 1-1.086-1.703 5.64 5.64 0 0 1-.403-1.953l.007-.475a5.47 5.47 0 0 1 .353-1.606c.077-.202.164-.399.261-.591.19-.371.408-.726.651-1.064.159-.221.328-.436.504-.644.367-.43.759-.839 1.172-1.224l.152-.137.244-.204Z" fill="transparent" data-darkreader-inline-fill="" style="--darkreader-inline-fill:transparent;"></path>
<path d="m45.445 64.303 10.578 8.784a3.396 3.396 0 0 0 2.139.762H32.879c.776 0 1.528-.269 2.127-.762l10.439-8.784Zm-4.341-20.731.096.028c.031.015.057.037.085.056l.08.071c.198.182.39.373.575.569.13.139.257.282.379.43.155.187.3.383.432.587.057.09.11.181.16.276.043.082.082.167.116.253.06.15.105.308.119.469l-.003.302a1.723 1.723 0 0 1-.817 1.343 2.338 2.338 0 0 1-.994.327l-.373.011-.315-.028a2.398 2.398 0 0 1-.433-.105 2.07 2.07 0 0 1-.41-.192l-.246-.18a1.688 1.688 0 0 1-.56-.96 2.418 2.418 0 0 1-.029-.19l-.009-.288c.005-.078.017-.155.034-.232.043-.168.105-.331.183-.486a4.47 4.47 0 0 1 .344-.559c.213-.288.444-.562.691-.821.159-.168.322-.331.492-.488l.121-.109c.084-.056.085-.056.181-.084h.101ZM40.485 3.42l.039-.003v33.669l-.084-.155a94.125 94.125 0 0 1-3.093-6.268 67.022 67.022 0 0 1-2.099-5.255 41.439 41.439 0 0 1-1.265-4.327c-.266-1.163-.47-2.343-.554-3.535a17.312 17.312 0 0 1-.029-1.528c.008-.444.026-.887.054-1.33.044-.697.115-1.392.217-2.082.081-.543.181-1.084.305-1.619.098-.425.212-.847.342-1.262.188-.6.413-1.186.675-1.758.096-.206.199-.411.307-.612.65-1.204 1.532-2.313 2.687-3.055a5.617 5.617 0 0 1 2.498-.88Zm4.366.085 2.265.646c1.049.387 2.059.892 2.987 1.522a11.984 11.984 0 0 1 3.212 3.204c.503.748.919 1.555 1.244 2.398.471 1.247.763 2.554.866 3.883.03.348.047.697.054 1.046.008.324.006.649-.02.973a10.97 10.97 0 0 1-.407 2.14 16.94 16.94 0 0 1-.587 1.684c-.28.685-.591 1.357-.932 2.013-.755 1.458-1.624 2.853-2.554 4.202a65.451 65.451 0 0 1-3.683 4.806 91.058 91.058 0 0 1-4.418 4.897 93.697 93.697 0 0 0 2.908-5.95c.5-1.124.971-2.26 1.414-3.407a53.41 53.41 0 0 0 1.317-3.831c.29-.969.546-1.948.757-2.938.181-.849.323-1.707.411-2.57.074-.72.101-1.444.083-2.166a30.807 30.807 0 0 0-.049-1.325c-.106-1.776-.376-3.546-.894-5.249a15.341 15.341 0 0 0-.714-1.892c-.663-1.444-1.588-2.794-2.84-3.779l-.42-.307Z" fill="#fff" data-darkreader-inline-fill="" style="--darkreader-inline-fill:#e8e6e3;"></path>
<path d="m188.849 65.24-11.341-24.279c-.947-2.023-1.511-2.762-2.458-3.62l-.923-.832c-.734-.713-1.217-1.372-1.217-2.157 0-1.123.888-2.067 2.508-2.067h9.846c1.546 0 2.508.804 2.508 2.004 0 .67-.308 1.172-.697 1.664-.462.586-1.063 1.157-1.063 2.197 0 .652.189 1.302.556 2.132l6.768 15.85 6.071-15.451c.373-1.028.629-1.933.629-2.658 0-1.127-.613-1.587-1.086-2.091-.411-.438-.74-.901-.74-1.643 0-1.212.986-2.004 2.313-2.004h6.064c1.7 0 2.508.879 2.508 2.004 0 .72-.414 1.386-1.23 2.105l-.858.705c-1.194.976-1.747 2.387-2.373 3.847l-9.195 22.152c-1.087 2.59-2.704 6.185-5.175 9.134-2.509 2.996-5.893 5.326-10.477 5.326-3.838 0-6.16-1.832-6.16-4.473 0-2.419 1.788-4.346 4.138-4.346 1.288 0 1.957.608 2.637 1.233.561.516 1.131 1.045 2.254 1.045 1.042 0 2.014-.441 2.893-1.152 1.343-1.087 2.47-2.798 3.3-4.625Zm66.644-.087c5.105 0 9.288-1.749 12.551-5.239 3.259-3.486 4.889-7.682 4.889-12.588 0-4.787-1.549-8.721-4.637-11.805-3.086-3.081-7.092-4.629-12.021-4.629-5.19 0-9.436 1.685-12.74 5.043-3.307 3.361-4.962 7.432-4.962 12.214 0 4.74 1.578 8.756 4.73 12.052 3.153 3.298 7.215 4.952 12.19 4.952Zm-43.168-.38c2.952 0 5.052-1.987 5.052-4.852 0-2.798-2.169-4.789-5.052-4.789-3.02 0-5.182 1.994-5.182 4.789 0 2.862 2.163 4.852 5.182 4.852Zm10.511-4.541.718-.759c.856-.831 1.13-1.67 1.13-3.982V41.82c0-1.999-.272-2.891-1.119-3.655l-.846-.758c-.827-.73-1.099-1.185-1.099-1.915 0-1.038.804-1.889 2.098-2.185l5.739-1.392c.549-.133 1.167-.263 1.648-.263.66 0 1.2.217 1.579.594s.603.921.603 1.6v21.645c0 2.183.265 3.198 1.176 3.964a.544.544 0 0 1 .042.041l.641.748c.806.784 1.141 1.304 1.141 2.019 0 1.275-.963 2.004-2.509 2.004h-9.715c-1.474 0-2.443-.725-2.443-2.004 0-.718.334-1.243 1.216-2.031Zm-64.946 0 .718-.759c.855-.831 1.13-1.67 1.13-3.982V26.948c0-1.936-.205-2.886-1.111-3.649l-.867-.84c-.749-.726-1.022-1.177-1.022-1.904 0-1.039.81-1.887 2.033-2.184l5.674-1.392c.549-.133 1.168-.263 1.648-.263.655 0 1.21.198 1.606.572.396.375.642.934.642 1.685v36.518c0 2.188.271 3.145 1.186 3.973l.732.774c.811.789 1.081 1.306 1.081 2.025 0 .529-.161.957-.449 1.282-.406.46-1.087.722-1.994.722h-9.716c-.907 0-1.587-.262-1.994-.722-.287-.325-.449-.753-.449-1.282 0-.72.267-1.241 1.152-2.031Zm-26.457-14.698v9.83c0 1.482.293 2.85 1.515 3.976l.789.765c.883.858 1.152 1.372 1.152 2.158 0 1.2-.963 2.004-2.508 2.004h-10.955c-1.545 0-2.508-.804-2.508-2.004 0-.933.274-1.375 1.157-2.162l.787-.763c.915-.83 1.512-1.96 1.512-3.974V29.099c0-1.6-.354-2.908-1.514-3.975l-.79-.766c-.812-.788-1.152-1.306-1.152-2.094 0-1.272.965-2.067 2.508-2.067h29.343c1.122 0 2.108.249 2.737.867.438.429.718 1.034.749 1.875l.457 6.774c.046.847-.204 1.553-.693 1.988-.336.299-.789.479-1.359.479-.718 0-1.269-.271-1.76-.739-.442-.421-.836-1.012-1.28-1.707-1.071-1.713-1.571-2.329-2.713-3.13-1.59-1.173-4.012-1.576-8.592-1.576-2.643 0-4.311.115-5.361.386-.68.175-1.072.403-1.282.737-.215.342-.239.775-.239 1.303v13.311h6.882c1.647 0 2.805-.297 4.147-2.132l.007-.01c.538-.685.927-1.189 1.297-1.524.432-.39.846-.574 1.396-.574 1.032 0 1.986.848 1.986 2.004v9.177c0 1.23-.957 2.068-1.986 2.068-.511 0-.928-.182-1.36-.564-.372-.328-.762-.817-1.269-1.473-1.468-1.9-2.505-2.203-4.218-2.203h-6.882Zm116.265-.233c0-3.292.717-5.658 2.204-7.081 1.468-1.405 3.047-2.116 4.743-2.116 2.334 0 4.436 1.305 6.332 3.874 1.939 2.625 2.897 6.174 2.897 10.639 0 3.296-.72 5.684-2.208 7.148-1.467 1.445-3.044 2.177-4.739 2.177-2.334 0-4.435-1.316-6.332-3.905-1.939-2.647-2.897-6.228-2.897-10.736Zm-19.201-17.805c2.958 0 5.051-1.664 5.051-4.536 0-2.804-2.091-4.472-5.051-4.472-3.029 0-5.117 1.67-5.117 4.472 0 2.802 2.089 4.536 5.117 4.536Z" fill="currentColor" data-darkreader-inline-fill="" style="--darkreader-inline-fill:currentColor;"></path>
</g>
<defs>
<radialGradient id="_Radial1" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(43.593 41.714) scale(59.4764)">
<stop offset="0" stop-color="#ba7bf0" data-darkreader-inline-stopcolor="" style="--darkreader-inline-stopcolor:#4a0e7d;"></stop>
<stop offset=".45" stop-color="#996bec" data-darkreader-inline-stopcolor="" style="--darkreader-inline-stopcolor:#3b1186;"></stop>
<stop offset="1" stop-color="#5046e4" data-darkreader-inline-stopcolor="" style="--darkreader-inline-stopcolor:#1f179c;"></stop>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1,71 @@
defmodule Livebook.Hubs.FlyClientTest do
use ExUnit.Case
alias Livebook.Hubs.{Fly, FlyClient}
setup do
bypass = Bypass.open()
Application.put_env(
:livebook,
:fly_graphql_endpoint,
"http://localhost:#{bypass.port}"
)
on_exit(fn ->
Application.delete_env(:livebook, :fly_graphql_endpoint)
end)
{:ok, bypass: bypass, url: "http://localhost:#{bypass.port}"}
end
describe "fetch_apps/1" do
test "fetches an empty list of apps", %{bypass: bypass} do
response = %{"data" => %{"apps" => %{"nodes" => []}}}
Bypass.expect_once(bypass, "POST", "/", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
assert {:ok, []} = FlyClient.fetch_apps("some valid token")
end
test "fetches a list of apps", %{bypass: bypass} do
app = %{
"id" => "foo-app",
"organization" => %{
"id" => "l3soyvjmvtmwtl6l2drnbfuvltipprge",
"name" => "Foo Bar",
"type" => "PERSONAL"
}
}
app_id = app["id"]
response = %{"data" => %{"apps" => %{"nodes" => [app]}}}
Bypass.expect_once(bypass, "POST", "/", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
assert {:ok, [%Fly{application_id: ^app_id}]} = FlyClient.fetch_apps("some valid token")
end
test "returns unauthorized when token is invalid", %{bypass: bypass} do
error = %{"extensions" => %{"code" => "UNAUTHORIZED"}}
response = %{"data" => nil, "errors" => [error]}
Bypass.expect_once(bypass, "POST", "/", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
assert {:error, "request failed with code: UNAUTHORIZED"} = FlyClient.fetch_apps("foo")
end
end
end

View file

@ -0,0 +1,31 @@
defmodule Livebook.Hubs.ProviderTest do
use ExUnit.Case
import Livebook.Fixtures
alias Livebook.Hubs.{Fly, Metadata, Provider}
describe "Fly" do
test "normalize/1" do
fly = fly_fixture()
assert Provider.normalize(fly) == %Metadata{
id: fly.id,
name: fly.hub_name,
color: fly.hub_color,
provider: fly
}
end
test "load/2" do
fly = fly_fixture()
fields = Map.from_struct(fly)
assert Provider.load(%Fly{}, fields) == fly
end
test "type/1" do
assert Provider.type(%Fly{}) == "fly"
end
end
end

View file

@ -0,0 +1,70 @@
defmodule Livebook.HubsTest do
use ExUnit.Case
import Livebook.Fixtures
alias Livebook.Hubs
setup do
on_exit(&Hubs.clean_hubs/0)
:ok
end
test "fetch_hubs/0 returns a list of persisted hubs" do
fly = create_fly("fly-baz")
assert Hubs.fetch_hubs() == [fly]
Hubs.delete_hub("fly-baz")
assert Hubs.fetch_hubs() == []
end
test "fetch_metadata/0 returns a list of persisted hubs normalized" do
fly = create_fly("fly-livebook")
assert Hubs.fetch_metadatas() == [
%Hubs.Metadata{
id: "fly-livebook",
color: fly.hub_color,
name: fly.hub_name,
provider: fly
}
]
Hubs.delete_hub("fly-livebook")
assert Hubs.fetch_metadatas() == []
end
test "fetch_hub!/1 returns one persisted fly" do
assert_raise Hubs.NotFoundError,
~s/could not find a hub matching "fly-foo"/,
fn ->
Hubs.fetch_hub!("fly-foo")
end
fly = create_fly("fly-foo")
assert Hubs.fetch_hub!("fly-foo") == fly
end
test "hub_exists?/1" do
refute Hubs.hub_exists?("fly-bar")
create_fly("fly-bar")
assert Hubs.hub_exists?("fly-bar")
end
test "save_hub/1 persists hub" do
fly = fly_fixture(id: "fly-foo")
Hubs.save_hub(fly)
assert Hubs.fetch_hub!("fly-foo") == fly
end
test "save_hub/1 updates hub" do
fly = create_fly("fly-foo2")
Hubs.save_hub(%{fly | hub_color: "#FFFFFF"})
refute Hubs.fetch_hub!("fly-foo2") == fly
assert Hubs.fetch_hub!("fly-foo2").hub_color == "#FFFFFF"
end
end

View file

@ -1,6 +1,7 @@
defmodule LivebookWeb.HomeLiveTest do
use LivebookWeb.ConnCase, async: true
import Livebook.Fixtures
import Phoenix.LiveViewTest
alias Livebook.{Sessions, Session}
@ -231,6 +232,32 @@ defmodule LivebookWeb.HomeLiveTest do
end
end
describe "hubs sidebar" do
test "doesn't show with disabled feature flag", %{conn: conn} do
Application.put_env(:livebook, :feature_flags, hub: false)
{:ok, _view, html} = live(conn, "/")
Application.put_env(:livebook, :feature_flags, hub: true)
refute html =~ "HUBS"
end
test "render section", %{conn: conn} do
{:ok, _view, html} = live(conn, "/")
assert html =~ "HUBS"
assert html =~ "Add Hub"
end
test "render persisted hubs", %{conn: conn} do
fly = create_fly("fly-foo-bar-id")
{:ok, _view, html} = live(conn, "/")
assert html =~ "HUBS"
assert html =~ fly.hub_name
Livebook.Hubs.delete_hub("fly-foo-bar-id")
end
end
test "link to introductory notebook correctly creates a new session", %{conn: conn} do
{:ok, view, _} = live(conn, "/")

View file

@ -0,0 +1,191 @@
defmodule LivebookWeb.HubLiveTest do
use LivebookWeb.ConnCase, async: true
import Livebook.Fixtures
import Phoenix.LiveViewTest
alias Livebook.Hubs
setup do
on_exit(&Hubs.clean_hubs/0)
:ok
end
test "render hub selection cards", %{conn: conn} do
{:ok, _view, html} = live(conn, "/hub")
assert html =~ "Fly"
assert html =~ "Livebook Enterprise"
end
describe "fly" do
test "persists fly", %{conn: conn} do
fly_app_bypass("123456789")
{:ok, view, _html} = live(conn, "/hub")
# renders the second step
assert view
|> element("#fly")
|> render_click() =~ "2. Configure your Hub"
# triggers the access_access_token field change
# and shows the fly's third step
assert view
|> element(~s/input[name="fly[access_token]"]/)
|> render_change(%{"fly" => %{"access_token" => "dummy access token"}}) =~
~s(<option value="123456789">Foo Bar - 123456789</option>)
# triggers the application_id field change
# and assigns `selected_app` to socket
view
|> element(~s/select[name="fly[application_id]"]/)
|> render_change(%{"fly" => %{"application_id" => "123456789"}})
# sends the save_hub event to backend
# and checks the new hub on sidebar
refute view
|> element("#fly-form")
|> render_submit(%{
"fly" => %{
"access_token" => "dummy access token",
"application_id" => "123456789",
"hub_name" => "My Foo Hub",
"hub_color" => "#FF00FF"
}
}) =~ "Application already exists"
# and checks the new hub on sidebar
assert view
|> element("#hubs")
|> render() =~ ~s/style="color: #FF00FF"/
assert view
|> element("#hubs")
|> render() =~ "/hub/fly-123456789"
assert view
|> element("#hubs")
|> render() =~ "My Foo Hub"
end
test "updates fly", %{conn: conn} do
fly_app_bypass("987654321")
fly = create_fly("fly-987654321", %{application_id: "987654321"})
{:ok, view, _html} = live(conn, "/hub/fly-987654321")
# renders the second step
assert render(view) =~ "2. Configure your Hub"
assert render(view) =~
~s(<option selected="selected" value="987654321">Foo Bar - 987654321</option>)
# sends the save_hub event to backend
# and checks the new hub on sidebar
view
|> element("#fly-form")
|> render_submit(%{
"fly" => %{
"access_token" => "dummy access token",
"application_id" => "987654321",
"hub_name" => "Personal Hub",
"hub_color" => "#FF00FF"
}
})
# and checks the new hub on sidebar
assert view
|> element("#hubs")
|> render() =~ ~s/style="color: #FF00FF"/
assert view
|> element("#hubs")
|> render() =~ "/hub/fly-987654321"
assert view
|> element("#hubs")
|> render() =~ "Personal Hub"
refute Hubs.fetch_hub!("fly-987654321") == fly
end
test "fails to create existing hub", %{conn: conn} do
fly = create_fly("fly-foo", %{application_id: "foo"})
fly_app_bypass("foo")
{:ok, view, _html} = live(conn, "/hub")
# renders the second step
assert view
|> element("#fly")
|> render_click() =~ "2. Configure your Hub"
# triggers the access_token field change
# and shows the fly's third step
assert view
|> element(~s/input[name="fly[access_token]"]/)
|> render_change(%{"fly" => %{"access_token" => "dummy access token"}}) =~
~s(<option value="foo">Foo Bar - foo</option>)
# triggers the application_id field change
# and assigns `selected_app` to socket
view
|> element(~s/select[name="fly[application_id]"]/)
|> render_change(%{"fly" => %{"application_id" => "foo"}})
# sends the save_hub event to backend
# and checks the new hub on sidebar
view
|> element("#fly-form")
|> render_submit(%{
"fly" => %{
"access_token" => "dummy access token",
"application_id" => "foo",
"hub_name" => "My Foo Hub",
"hub_color" => "#FF00FF"
}
})
assert render(view) =~ "Application already exists"
# and checks the hub didn't change on sidebar
assert view
|> element("#hubs")
|> render() =~ ~s/style="color: #{fly.hub_color}"/
assert view
|> element("#hubs")
|> render() =~ "/hub/fly-foo"
assert view
|> element("#hubs")
|> render() =~ fly.hub_name
assert Hubs.fetch_hub!("fly-foo") == fly
end
end
defp fly_app_bypass(app_id) do
bypass = Bypass.open()
Application.put_env(:livebook, :fly_graphql_endpoint, "http://localhost:#{bypass.port}")
app = %{
"id" => app_id,
"organization" => %{
"id" => "l3soyvjmvtmwtl6l2drnbfuvltipprge",
"name" => "Foo Bar",
"type" => "PERSONAL"
}
}
response = %{"data" => %{"apps" => %{"nodes" => [app]}}}
Bypass.expect(bypass, "POST", "/", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(200, Jason.encode!(response))
end)
end
end

27
test/support/fixtures.ex Normal file
View file

@ -0,0 +1,27 @@
defmodule Livebook.Fixtures do
@moduledoc false
def create_fly(id, attrs \\ %{}) do
attrs
|> fly_fixture()
|> Map.replace!(:id, id)
|> Livebook.Hubs.save_hub()
end
def fly_fixture(attrs \\ %{}) do
fly = %Livebook.Hubs.Fly{
id: "fly-foo-bar-baz",
hub_name: "My Personal Hub",
hub_color: "#FF00FF",
access_token: Livebook.Utils.random_cookie(),
organization_id: Livebook.Utils.random_id(),
organization_type: "PERSONAL",
organization_name: "Foo",
application_id: "foo-bar-baz"
}
for {key, value} <- attrs, reduce: fly do
acc -> Map.replace!(acc, key, value)
end
end
end