lib/cluster_connect_smartcell.ex

defmodule ClusterConnectSmartcell do
  @moduledoc """
  #{File.read!("README.md")}
  """

  use Kino.JS, assets_path: "lib/assets/cluster_connect_smartcell"
  use Kino.JS.Live
  use Kino.SmartCell, name: "OTP Connect"

  @impl true
  def init(attrs, ctx) do
    root_fields = %{
      "target_node" => attrs["target_node"] || "",
      "erlang_cookie" => attrs["erlang_cookie"] || "",
      "module" => attrs["module"] || "",
      "function" => attrs["function"] || "",
      "arguments" => attrs["arguments"] || ""
    }

    {:ok, assign(ctx, root_fields: root_fields)}
  end

  @impl true
  def handle_connect(ctx) do
    # Called first time the cliet connects, gives initial state
    payload = %{
      root_fields: ctx.assigns.root_fields
    }

    {:ok, payload, ctx}
  end

  @impl true
  def to_attrs(ctx) do
    ctx.assigns.root_fields
  end

  @impl true
  def to_source(attrs) do
    %{
      "target_node" => target_node,
      "erlang_cookie" => erlang_cookie,
      "module" => module,
      "function" => function,
      "arguments" => arguments
    } = attrs

    target_node = String.to_atom(target_node)
    erlang_cookie = String.to_atom(erlang_cookie)

    quote do
      with {:cookie, true} <-
             {:cookie, :erlang.set_cookie(unquote(erlang_cookie))},
           {:connect, true} <-
             {:connect,
              unquote(target_node)
              |> :net_kernel.connect_node()},
           {:monitor, :ok} <- {:monitor, :net_kernel.monitor_nodes(true)},
           nodes <- Node.list(:connected),
           {:command, {:ok, result}} <-
             {:command,
              ClusterConnectSmartcell.do_execute_remote_command(
                unquote(target_node),
                unquote(module),
                unquote(function),
                unquote(arguments)
              )} do
        %{"result" => result, "nodes" => nodes}
      else
        {step, res} when step in [:cookie, :connect, :command, :monitor] ->
          %{"outcome" => false} |> Map.put(Atom.to_string(step), res)

        error ->
          %{"outcome" => false, "error" => error}
      end
    end
    |> Kino.SmartCell.quoted_to_string()
  end

  # Event Handlers
  @impl true
  def handle_event("update_field", %{"field" => field, "value" => value}, ctx) do
    parsed_value = parse_value(field, value)
    ctx = update(ctx, :root_fields, &Map.put(&1, field, parsed_value))
    broadcast_event(ctx, "update_root", %{"fields" => %{field => parsed_value}})

    {:noreply, ctx}
  end

  def do_execute_remote_command(target_node, module, function, arguments)
      when is_binary(module) and byte_size(module) > 0 and
             is_binary(function) and byte_size(function) > 0 and
             is_binary(arguments) and byte_size(arguments) > 0 do
    module = module |> normalize_module_name() |> String.to_atom()
    function = String.to_atom(function)
    # pass a list for now
    {arguments, _bindings} = arguments |> Code.eval_string() |> parse_evaluated_arguments!()
    :erpc.call(target_node, module, function, arguments)
  rescue
    error -> error
  end

  def do_execute_remote_command(_target_node, _module, _function, _arguments),
    do: {:ok, :no_command}

  # Privates
  defp parse_value(_field, value), do: value

  defp normalize_module_name(module) do
    if String.starts_with?(module, "Elixir.") do
      module
    else
      "Elixir.#{module}"
    end
  end

  defp parse_evaluated_arguments!({arguments, bindings}) when is_list(arguments),
    do: {arguments, bindings}

  defp parse_evaluated_arguments!({arguments, _bindings}),
    do: raise("Arguments should be a list, got #{inspect(arguments)}")
end