diff --git a/assets/css/components.css b/assets/css/components.css index acace6405..8e06f4066 100644 --- a/assets/css/components.css +++ b/assets/css/components.css @@ -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; + } } diff --git a/config/config.exs b/config/config.exs index ded1955cc..bea1d3227 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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 diff --git a/config/dev.exs b/config/dev.exs index ea5971f65..209185cee 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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 # diff --git a/config/test.exs b/config/test.exs index f0855b14a..68f6b605e 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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 diff --git a/lib/livebook/hubs.ex b/lib/livebook/hubs.ex new file mode 100644 index 000000000..ce579923c --- /dev/null +++ b/lib/livebook/hubs.ex @@ -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 diff --git a/lib/livebook/hubs/fly.ex b/lib/livebook/hubs/fly.ex new file mode 100644 index 000000000..49414b19d --- /dev/null +++ b/lib/livebook/hubs/fly.ex @@ -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 diff --git a/lib/livebook/hubs/fly_client.ex b/lib/livebook/hubs/fly_client.ex new file mode 100644 index 000000000..8806a8a3a --- /dev/null +++ b/lib/livebook/hubs/fly_client.ex @@ -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 diff --git a/lib/livebook/hubs/metadata.ex b/lib/livebook/hubs/metadata.ex new file mode 100644 index 000000000..eecb23c67 --- /dev/null +++ b/lib/livebook/hubs/metadata.ex @@ -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 diff --git a/lib/livebook/hubs/provider.ex b/lib/livebook/hubs/provider.ex new file mode 100644 index 000000000..79c1914bf --- /dev/null +++ b/lib/livebook/hubs/provider.ex @@ -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 diff --git a/lib/livebook_web/live/explore_live.ex b/lib/livebook_web/live/explore_live.ex index 78e63d2c2..0077bf937 100644 --- a/lib/livebook_web/live/explore_live.ex +++ b/lib/livebook_web/live/explore_live.ex @@ -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} />
diff --git a/lib/livebook_web/live/home_live.ex b/lib/livebook_web/live/home_live.ex index 34f87572a..0c601b9d1 100644 --- a/lib/livebook_web/live/home_live.ex +++ b/lib/livebook_web/live/home_live.ex @@ -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} />
diff --git a/lib/livebook_web/live/hooks/sidebar_hook.ex b/lib/livebook_web/live/hooks/sidebar_hook.ex new file mode 100644 index 000000000..d7097118b --- /dev/null +++ b/lib/livebook_web/live/hooks/sidebar_hook.ex @@ -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 diff --git a/lib/livebook_web/live/hub_live.ex b/lib/livebook_web/live/hub_live.ex new file mode 100644 index 000000000..a0551b0c2 --- /dev/null +++ b/lib/livebook_web/live/hub_live.ex @@ -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""" +
+ + +
+
+
+ +

+ Manage your Livebooks in the cloud with Hubs. +

+
+ +
+

+ 1. Select your Hub service +

+ +
+ <.card_item id="fly" selected={@selected_provider} title="Fly"> + <:logo> + <%= Phoenix.HTML.raw(File.read!("static/images/fly.svg")) %> + + <:headline> + Deploy notebooks to your Fly account. + + + + <.card_item id="enterprise" selected={@selected_provider} title="Livebook Enterprise"> + <:logo> + Fly logo + + <:headline> + Control access, manage secrets, and deploy notebooks within your team and company. + + +
+
+ + <%= if @selected_provider do %> +
+

+ 2. Configure your Hub +

+ + <%= if @selected_provider == "fly" do %> + <.live_component + module={LivebookWeb.HubLive.FlyComponent} + id="fly-form" + operation={@operation} + hub={@hub} + /> + <% end %> + + <%= if @selected_provider == "enterprise" do %> +
+ Livebook Enterprise is currently in closed beta. If you want to learn more, click here. +
+ <% end %> +
+ <% end %> +
+
+ + <.current_user_modal current_user={@current_user} /> +
+ """ + end + + defp card_item(assigns) do + ~H""" +
card_item_bg_color(@id, @selected)} + phx-click={JS.push("select_provider", value: %{value: @id})} + > + +
+

+ <%= @title %> +

+ +

+ <%= render_slot(@headline) %> +

+
+
+ """ + 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 diff --git a/lib/livebook_web/live/hub_live/fly_component.ex b/lib/livebook_web/live/hub_live/fly_component.ex new file mode 100644 index 000000000..50f3e56b6 --- /dev/null +++ b/lib/livebook_web/live/hub_live/fly_component.ex @@ -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""" +
+ <.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" + > +
+

+ Access Token +

+ <%= 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" + ) %> +
+ + <%= if length(@apps) > 0 do %> +
+

+ Application +

+ <%= select(f, :application_id, @select_options, + class: "input", + required: true, + phx_target: @myself, + phx_change: "select_app", + disabled: @operation == :edit + ) %> +
+ +
+
+

+ Name +

+ <%= text_input(f, :hub_name, value: @data["hub_name"], class: "input", required: true) %> +
+ +
+

+ Color +

+ +
+
+
+
+
+
+ <%= text_input(f, :hub_color, + value: @data["hub_color"], + class: "input", + spellcheck: "false", + required: true, + maxlength: 7 + ) %> + +
+
+
+
+ + <%= submit("Save", class: "button-base button-blue", phx_disable_with: "Saving...") %> + <% end %> + +
+ """ + 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 diff --git a/lib/livebook_web/live/settings_live.ex b/lib/livebook_web/live/settings_live.ex index 1389d8905..22c2048b4 100644 --- a/lib/livebook_web/live/settings_live.ex +++ b/lib/livebook_web/live/settings_live.ex @@ -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} />
diff --git a/lib/livebook_web/live/sidebar_helpers.ex b/lib/livebook_web/live/sidebar_helpers.ex index cdf7ad336..cc069e270 100644 --- a/lib/livebook_web/live/sidebar_helpers.ex +++ b/lib/livebook_web/live/sidebar_helpers.ex @@ -66,36 +66,45 @@ defmodule LivebookWeb.SidebarHelpers do aria-label="sidebar" data-el-sidebar > -
-
- <%= live_redirect to: Routes.home_path(@socket, :page), class: "flex items-center border-l-4 border-gray-900" do %> - logo livebook -