diff --git a/lib/livebook_web/live/hub_live.ex b/lib/livebook_web/live/hub_live.ex
new file mode 100644
index 000000000..7281aedf7
--- /dev/null
+++ b/lib/livebook_web/live/hub_live.ex
@@ -0,0 +1,325 @@
+defmodule LivebookWeb.HubLive do
+ use LivebookWeb, :live_view
+
+ import LivebookWeb.UserHelpers
+
+ alias Livebook.Hub
+ alias Livebook.Hub.Fly
+ alias Livebook.Hub.Settings
+ alias Livebook.Users.User
+ alias LivebookWeb.{PageHelpers, SidebarHelpers}
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok,
+ assign(socket,
+ selected_hub_service: nil,
+ machines: [],
+ machine_options: [],
+ data: %{},
+ page_title: "Livebook - Hub"
+ )}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
+
+
+
+
+ Here you can create your Hubs.
+ Keep in mind that this configuration gets persisted and
+ will be restored on application launch.
+
+
+ Follow the next steps to create you Hub configuration.
+
+
+
+
+
+ 1. Select your Hub service
+
+
+
+ <.card_item
+ id="fly"
+ selected={@selected_hub_service}
+ title="Fly"
+ headline="Connect to your application"
+ >
+ <:logo>
+ <.fly_logo />
+
+
+
+ <.card_item
+ disabled
+ selected={@selected_hub_service}
+ id="enterprise"
+ title="Enterprise"
+ headline="Coming soon..."
+ >
+ <:logo>
+

+
+
+
+
+
+ <%= if @selected_hub_service do %>
+
+
+ 2. Connect to your Hub with the following form
+
+
+ <%= if @selected_hub_service == "fly" do %>
+ <.fly_form socket={@socket} data={@data} machines={@machine_options} />
+ <% end %>
+
+ <% end %>
+
+
+
+ <.current_user_modal current_user={@current_user} />
+
+ """
+ end
+
+ defp card_item(assigns) do
+ assigns = put_class(assigns)
+
+ ~H"""
+
+
+ <%= render_slot(@logo) %>
+
+
+
+ <%= @title %>
+
+
+
+ <%= @headline %>
+
+
+
+ """
+ end
+
+ defp fly_logo(assigns) do
+ ~H"""
+
+ """
+ end
+
+ defp fly_form(assigns) do
+ ~H"""
+ <.form
+ class="flex flex-col space-y-4"
+ let={f}
+ for={:fly}
+ phx-submit="save"
+ phx-change="update_data"
+ phx-debounce="blur"
+ >
+
+
+ Access Token
+
+ <%= password_input(f, :token,
+ phx_change: "fetch_machines",
+ phx_debounce: "blur",
+ value: @data["token"],
+ class: "input w-full",
+ autofocus: true,
+ spellcheck: "false",
+ autocomplete: "off"
+ ) %>
+
+
+ <%= if length(@machines) > 0 do %>
+
+
+ Application
+
+ <%= select(f, :application, @machines, class: "input") %>
+
+
+
+
+
+ Name
+
+ <%= text_input(f, :name, value: @data["name"], class: "input") %>
+
+
+
+
+ Color
+
+
+
+ <.hex_color form={f} name="hex_color" value={@data["hex_color"]} />
+
+
+
+
+ <%= submit("Save", class: "button-base button-blue") %>
+ <% end %>
+
+ """
+ end
+
+ defp hex_color(assigns) do
+ ~H"""
+
+
+ <%= text_input(@form, @name,
+ value: @value,
+ class: "input",
+ spellcheck: "false",
+ maxlength: 7
+ ) %>
+
+
+ """
+ end
+
+ defp put_class(%{id: id, selected: service} = assigns) when service === id do
+ assign_new(assigns, :class, fn -> "flex card-item flex-col selected" end)
+ end
+
+ defp put_class(%{disabled: true} = assigns) do
+ assign_new(assigns, :class, fn -> "flex card-item flex-col disabled" end)
+ end
+
+ defp put_class(assigns) do
+ assign_new(assigns, :class, fn -> "flex card-item flex-col" end)
+ end
+
+ @impl true
+ def handle_event("select_hub_service", %{"value" => service}, socket) do
+ {:noreply, assign(socket, selected_hub_service: service)}
+ end
+
+ def handle_event("fetch_machines", %{"fly" => %{"token" => token}}, socket) do
+ case Hub.fetch_machines(%Fly{token: token}) do
+ {:ok, machines} ->
+ data = %{"token" => token, "hex_color" => User.random_hex_color()}
+ opts = select_machine_options(machines)
+
+ {:noreply, assign(socket, data: data, machines: machines, machine_options: opts)}
+
+ {:error, _} ->
+ {:noreply,
+ socket
+ |> assign(mahcines: [], machine_options: [], data: %{})
+ |> put_flash(:error, "Invalid Access Token")}
+ end
+ end
+
+ def handle_event("save", %{"fly" => params}, socket) do
+ machines = socket.assigns.machines
+
+ case Enum.find(machines, &(&1.id == params["application"])) do
+ nil ->
+ {:noreply,
+ socket
+ |> assign(data: params)
+ |> put_flash(:error, "Internal Server Error")}
+
+ selected_machine ->
+ opts = select_machine_options(machines, params["application"])
+
+ Settings.save_machine(%{
+ selected_machine
+ | name: params["name"],
+ color: params["hex_color"],
+ token: params["token"]
+ })
+
+ {:noreply, assign(socket, data: params, machine_options: opts)}
+ end
+ end
+
+ def handle_event("update_data", %{"fly" => data}, socket) do
+ opts = select_machine_options(socket.assigns.machines, data["application"])
+
+ {:noreply, assign(socket, data: data, machine_options: opts)}
+ end
+
+ def handle_event("randomize_color", _, socket) do
+ data = Map.put(socket.assigns.data, "hex_color", User.random_hex_color())
+ {:noreply, assign(socket, data: data)}
+ end
+
+ defp select_machine_options(machines, machine_id \\ nil) do
+ for machine <- machines do
+ if machine.id == machine_id do
+ [key: machine.name, value: machine.id, selected: true]
+ else
+ [key: machine.name, value: machine.id]
+ end
+ end
+ end
+end
diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex
index eac9b9b47..165a8af4a 100644
--- a/lib/livebook_web/router.ex
+++ b/lib/livebook_web/router.ex
@@ -55,6 +55,10 @@ defmodule LivebookWeb.Router do
live "/explore", ExploreLive, :page
live "/explore/notebooks/:slug", ExploreLive, :notebook
+ if Application.get_env(:livebook, :feature_flags)[:hub] do
+ live "/hub", HubLive, :page
+ end
+
live "/sessions/:id", SessionLive, :page
live "/sessions/:id/shortcuts", SessionLive, :shortcuts
live "/sessions/:id/settings/runtime", SessionLive, :runtime_settings