diff --git a/README.md b/README.md index 75d9818df61..4a6075cddfc 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,9 @@ The following environment variables configure Livebook: Enabled by default unless `LIVEBOOK_PASSWORD` is set. Set it to "false" to disable it. + * LIVEBOOK_UPDATE_INSTRUCTIONS_URL - sets the URL to direct the user to for + updating Livebook when a new version becomes available. + If running Livebook as a Docker image or an Elixir release, [the environment diff --git a/lib/livebook.ex b/lib/livebook.ex index 236449d99dd..bc95987a662 100644 --- a/lib/livebook.ex +++ b/lib/livebook.ex @@ -155,6 +155,11 @@ defmodule Livebook do :app_service_url, Livebook.Config.app_service_url!("LIVEBOOK_APP_SERVICE_URL") end + + if update_instructions_url = + Livebook.Config.update_instructions_url!("LIVEBOOK_UPDATE_INSTRUCTIONS_URL") do + config :livebook, :update_instructions_url, update_instructions_url + end end @doc """ diff --git a/lib/livebook/application.ex b/lib/livebook/application.ex index 63dd6e87513..ee722dc6f97 100644 --- a/lib/livebook/application.ex +++ b/lib/livebook/application.ex @@ -18,16 +18,18 @@ defmodule Livebook.Application do LivebookWeb.Telemetry, # Start the PubSub system {Phoenix.PubSub, name: Livebook.PubSub}, + # Start a supervisor for Livebook tasks + {Task.Supervisor, name: Livebook.TaskSupervisor}, # Start the storage module Livebook.Storage.current(), - # Periodid measurement of system resources + # Start the periodic version check + Livebook.UpdateCheck, + # Periodic measurement of system resources Livebook.SystemResources, # Start the tracker server on this node {Livebook.Tracker, pubsub_server: Livebook.PubSub}, # Start the supervisor dynamically managing sessions {DynamicSupervisor, name: Livebook.SessionSupervisor, strategy: :one_for_one}, - # Start a supervisor for Livebook tasks - {Task.Supervisor, name: Livebook.TaskSupervisor}, # Start the server responsible for associating files with sessions Livebook.Session.FileGuard, # Start the Node Pool for managing node names diff --git a/lib/livebook/config.ex b/lib/livebook/config.ex index 49af9635455..0eaa2ce8891 100644 --- a/lib/livebook/config.ex +++ b/lib/livebook/config.ex @@ -122,6 +122,14 @@ defmodule Livebook.Config do Application.fetch_env!(:livebook, :app_service_url) end + @doc """ + Returns the update check URL. + """ + @spec update_instructions_url() :: String.t() | nil + def update_instructions_url() do + Application.get_env(:livebook, :update_instructions_url) + end + ## Parsing @doc """ @@ -254,6 +262,13 @@ defmodule Livebook.Config do System.get_env(env) end + @doc """ + Parses update instructions url from env. + """ + def update_instructions_url!(env) do + System.get_env(env) + end + @doc """ Parses and validates default runtime from env. """ diff --git a/lib/livebook/settings.ex b/lib/livebook/settings.ex index 11d8afa899a..cf270cf9249 100644 --- a/lib/livebook/settings.ex +++ b/lib/livebook/settings.ex @@ -13,7 +13,7 @@ defmodule Livebook.Settings do @doc """ Returns the current autosave path. """ - @spec autosave_path() :: String.t() | nil + @spec autosave_path() :: String.t() def autosave_path() do case storage().fetch_key(:settings, "global", :autosave_path) do {:ok, value} -> value @@ -98,4 +98,23 @@ defmodule Livebook.Settings do {:error, message} -> raise ArgumentError, "invalid S3 filesystem: #{message}" end end + + @doc """ + Returns whether the update check is enabled. + """ + @spec update_check_enabled?() :: boolean() + def update_check_enabled?() do + case storage().fetch_key(:settings, "global", :update_check_enabled) do + {:ok, value} -> value + :error -> true + end + end + + @doc """ + Sets whether the update check is enabled. + """ + @spec set_update_check_enabled(boolean()) :: :ok + def set_update_check_enabled(enabled) do + storage().insert(:settings, "global", update_check_enabled: enabled) + end end diff --git a/lib/livebook/update_check.ex b/lib/livebook/update_check.ex new file mode 100644 index 00000000000..01e94e2dbea --- /dev/null +++ b/lib/livebook/update_check.ex @@ -0,0 +1,157 @@ +defmodule Livebook.UpdateCheck do + @moduledoc false + + # Periodically checks for available Livebook update. + + use GenServer + + require Logger + + @name __MODULE__ + @timeout :infinity + + @hour_in_ms 60 * 60 * 1000 + @day_in_ms 24 * @hour_in_ms + + @doc false + def start_link(_opts) do + GenServer.start_link(__MODULE__, {}, name: @name) + end + + @doc """ + Returns the latest Livebook version if it's more recent than the + current one. + """ + @spec new_version() :: String.t() | nil + def new_version() do + GenServer.call(@name, :get_new_version, @timeout) + end + + @doc """ + Returns whether the update check is enabled. + """ + @spec enabled?() :: boolean() + def enabled?() do + GenServer.call(@name, :get_enabled, @timeout) + end + + @doc """ + Sets whether the update check is enabled. + """ + @spec set_enabled(boolean()) :: :ok + def set_enabled(enabled) do + GenServer.cast(@name, {:set_enabled, enabled}) + end + + @impl true + def init({}) do + state = %{ + enabled: Livebook.Settings.update_check_enabled?(), + new_version: nil, + timer_ref: nil, + request_ref: nil + } + + {:ok, schedule_check(state, 0)} + end + + @impl true + def handle_cast({:set_enabled, enabled}, state) do + Livebook.Settings.set_update_check_enabled(enabled) + state = %{state | enabled: enabled} + state = state |> cancel_check() |> schedule_check(0) + {:noreply, state} + end + + @impl true + def handle_call(:get_enabled, _from, state) do + {:reply, state.enabled, state} + end + + @impl true + def handle_call(:get_new_version, _from, state) do + new_version = if state.enabled, do: state.new_version + {:reply, new_version, state} + end + + @impl true + def handle_info(:check, state) do + task = Task.Supervisor.async_nolink(Livebook.TaskSupervisor, &fetch_latest_version/0) + {:noreply, %{state | request_ref: task.ref}} + end + + def handle_info({ref, response}, %{request_ref: ref} = state) do + Process.demonitor(ref, [:flush]) + + state = + case response do + {:ok, version} -> + new_version = if newer?(version), do: version + state = %{state | new_version: new_version} + schedule_check(state, @day_in_ms) + + {:error, error} -> + Logger.error("version check failed, #{error}") + schedule_check(state, @hour_in_ms) + end + + {:noreply, %{state | request_ref: nil}} + end + + def handle_info({:DOWN, ref, :process, _pid, reason}, %{request_ref: ref} = state) do + Logger.error("version check failed, reason: #{inspect(reason)}") + {:noreply, %{state | request_ref: nil} |> schedule_check(@hour_in_ms)} + end + + def handle_info(_msg, state), do: {:noreply, state} + + defp schedule_check(%{enabled: false} = state, _time), do: state + + defp schedule_check(state, time) do + timer_ref = Process.send_after(self(), :check, time) + %{state | timer_ref: timer_ref} + end + + defp cancel_check(%{timer_ref: nil} = state), do: state + + defp cancel_check(state) do + if Process.cancel_timer(state.timer_ref) == false do + receive do + :check -> :ok + end + end + + %{state | timer_ref: nil} + end + + defp fetch_latest_version() do + url = "https://api.github.com/repos/livebook-dev/livebook/releases/latest" + headers = [{"accept", "application/vnd.github.v3+json"}] + + case Livebook.Utils.HTTP.request(:get, url, headers: headers) do + {:ok, status, _headers, body} -> + with 200 <- status, + {:ok, data} <- Jason.decode(body), + %{"tag_name" => "v" <> version} <- data do + {:ok, version} + else + _ -> {:error, "unexpected response"} + end + + {:error, reason} -> + {:error, "failed to make a request, reason: #{inspect(reason)}"} + end + end + + defp newer?(version) do + current_version = Application.spec(:livebook, :vsn) |> List.to_string() + stable?(version) and Version.compare(current_version, version) == :lt + end + + defp stable?(version) do + case Version.parse(version) do + {:ok, %{pre: []}} -> true + _ -> false + end + end +end diff --git a/lib/livebook_web/live/home_live.ex b/lib/livebook_web/live/home_live.ex index 232a7d4b6fe..b8de0ba907d 100644 --- a/lib/livebook_web/live/home_live.ex +++ b/lib/livebook_web/live/home_live.ex @@ -26,6 +26,8 @@ defmodule LivebookWeb.HomeLive do sessions: sessions, notebook_infos: notebook_infos, page_title: "Livebook", + new_version: Livebook.UpdateCheck.new_version(), + update_instructions_url: Livebook.Config.update_instructions_url(), app_service_url: Livebook.Config.app_service_url(), memory: Livebook.SystemResources.memory() )} @@ -39,9 +41,10 @@ defmodule LivebookWeb.HomeLive do -
-
- <.memory_notification memory={@memory} app_service_url={@app_service_url} /> +
+ <.update_notification version={@new_version} instructions_url={@update_instructions_url} /> + <.memory_notification memory={@memory} app_service_url={@app_service_url} /> +
@@ -157,18 +160,41 @@ defmodule LivebookWeb.HomeLive do end end + defp update_notification(%{version: nil} = assigns), do: ~H"" + + defp update_notification(assigns) do + ~H""" +
+ + Livebook v<%= @version %> available! + <%= if @instructions_url do %> + Check out the news on + + livebook.dev + + and follow the + + update instructions + + <% else %> + Check out the news and installation steps on + livebook.dev + <% end %> + 🚀 + +
+ """ + end + defp memory_notification(assigns) do ~H""" <%= if @app_service_url && @memory.free < 30_000_000 do %> -
- - <.remix_icon icon="alarm-warning-line" class="text-xl mr-2" /> - - Less than 30 MB of memory left, consider adding more resources to - the instance - or closing running sessions. - - +
+ <.remix_icon icon="alarm-warning-line" class="align-text-bottom mr-0.5" /> + Less than 30 MB of memory left, consider + adding more resources to the instance + or closing + running sessions
<% end %> """ diff --git a/lib/livebook_web/live/settings_live.ex b/lib/livebook_web/live/settings_live.ex index 276e5788337..7bca9fd80b7 100644 --- a/lib/livebook_web/live/settings_live.ex +++ b/lib/livebook_web/live/settings_live.ex @@ -7,17 +7,16 @@ defmodule LivebookWeb.SettingsLive do @impl true def mount(_params, _session, socket) do - file_systems = Livebook.Settings.file_systems() - {:ok, socket |> SidebarHelpers.shared_home_handlers() |> assign( - file_systems: file_systems, + file_systems: Livebook.Settings.file_systems(), autosave_path_state: %{ file: autosave_dir(), dialog_opened?: false }, + update_check_enabled: Livebook.UpdateCheck.enabled?(), page_title: "Livebook - Settings" )} end @@ -45,9 +44,9 @@ defmodule LivebookWeb.SettingsLive do
-

+

About -

+
<%= if app_name = Livebook.Config.app_service_name() do %> @@ -76,6 +75,18 @@ defmodule LivebookWeb.SettingsLive do <% end %>
+ +
+

+ Preferences +

+
+ <.switch_checkbox + name="update_check_enabled" + label="Show available Livebook updates" + checked={@update_check_enabled} /> +
+
@@ -253,6 +264,12 @@ defmodule LivebookWeb.SettingsLive do {:noreply, assign(socket, file_systems: file_systems)} end + def handle_event("save", %{"update_check_enabled" => enabled}, socket) do + enabled = enabled == "true" + Livebook.UpdateCheck.set_enabled(enabled) + {:noreply, assign(socket, :update_check_enabled, enabled)} + end + @impl true def handle_info({:file_systems_updated, file_systems}, socket) do {:noreply, assign(socket, file_systems: file_systems)}