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} />
+ Manage your Livebooks in the cloud with Hubs. +
++ <%= @title %> +
+ ++ <%= render_slot(@headline) %> +
+