Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow users to render content in the <head> #457

Merged
merged 2 commits into from
Nov 11, 2024

Conversation

SteffenDE
Copy link
Contributor

Relates to: #455

@josevalim instead of storing the components in application env or in a process, I thought about it a little bit more and decided to use assigns together with the already possible on_mount option to define hooks running on all LiveViews.

Recap: it is currently not possible to create custom dashboard pages that need their own phx-hooks. In order to make this work, the hooks must be available when constructing the LiveSocket, therefore we need a way to run custom scripts before the live dashboard app.js runs. It is also important that they run on all pages as they are only executed on the dead render and not later live navigations.

I want to revisit this when we get to colocated hooks, as they will possibly allow this to work with less configuration.

I didn't create an issue in LiveView yet, as even if we had support for executing <script> tags dynamically added, we still would not be able to use those to add hooks, as LiveView does not support adding hooks after the LiveSocket was already created. See phoenixframework/phoenix_live_view#2744 and phoenixframework/phoenix_live_view#2563.

.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {
display: none;
} /*!
#nprogress{pointer-events:none}#nprogress .bar{background:#29d;position:fixed;z-index:1031;top:0;left:0;width:100%;height:2px}#nprogress .peg{display:block;position:absolute;right:0px;width:100px;height:100%;box-shadow:0 0 10px #29d,0 0 5px #29d;opacity:1;-webkit-transform:rotate(3deg) translate(0px, -4px);-ms-transform:rotate(3deg) translate(0px, -4px);transform:rotate(3deg) translate(0px, -4px)}#nprogress .spinner{display:block;position:fixed;z-index:1031;top:15px;right:15px}#nprogress .spinner-icon{width:18px;height:18px;box-sizing:border-box;border:solid 2px transparent;border-top-color:#29d;border-left-color:#29d;border-radius:50%;-webkit-animation:nprogress-spinner 400ms linear infinite;animation:nprogress-spinner 400ms linear infinite}.nprogress-custom-parent{overflow:hidden;position:relative}.nprogress-custom-parent #nprogress .spinner,.nprogress-custom-parent #nprogress .bar{position:absolute}@-webkit-keyframes nprogress-spinner{0%{-webkit-transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg)}}@keyframes nprogress-spinner{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}.uplot,.uplot *,.uplot *::before,.uplot *::after{box-sizing:border-box}.uplot{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";line-height:1.5;width:min-content}.u-title{text-align:center;font-size:18px;font-weight:bold}.u-wrap{position:relative;user-select:none}.u-over,.u-under{position:absolute}.u-under{overflow:hidden}.uplot canvas{display:block;position:relative;width:100%;height:100%}.u-axis{position:absolute}.u-legend{font-size:14px;margin:auto;text-align:center}.u-inline{display:block}.u-inline *{display:inline-block}.u-inline tr{margin-right:16px}.u-legend th{font-weight:600}.u-legend th>*{vertical-align:middle;display:inline-block}.u-legend .u-marker{width:1em;height:1em;margin-right:4px;background-clip:padding-box !important}.u-inline.u-live th::after{content:":";vertical-align:middle}.u-inline:not(.u-live) .u-value{display:none}.u-series>*{padding:4px}.u-series th{cursor:pointer}.u-legend .u-off>*{opacity:.3}.u-select{background:rgba(0, 0, 0, 0.07);position:absolute;pointer-events:none}.u-cursor-x,.u-cursor-y{position:absolute;left:0;top:0;pointer-events:none;will-change:transform;z-index:100}.u-hz .u-cursor-x,.u-vt .u-cursor-y{height:100%;border-right:1px dashed #607d8b}.u-hz .u-cursor-y,.u-vt .u-cursor-x{width:100%;border-bottom:1px dashed #607d8b}.u-cursor-pt{position:absolute;top:0;left:0;border-radius:50%;border:0 solid;pointer-events:none;will-change:transform;z-index:100;background-clip:padding-box !important}.u-axis.u-off,.u-select.u-off,.u-cursor-x.u-off,.u-cursor-y.u-off,.u-cursor-pt.u-off{display:none}/*!
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what happened here, I just did a mix assets.build.

@SteffenDE
Copy link
Contributor Author

SteffenDE commented Nov 9, 2024

Ah and here is an example of a custom page using the new API:

Application.put_env(:sample, Example.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 5001],
  server: true,
  live_view: [signing_salt: "aaaaaaaa"],
  secret_key_base: String.duplicate("a", 64)
)

Application.put_env(
  :phoenix_live_dashboard,
  :before_closing_head_tag,
  {Example.TerminalPage, :before_closing_head_tag}
)

Mix.install([
  {:plug_cowboy, "~> 2.5"},
  {:jason, "~> 1.0"},
  {:phoenix, "~> 1.7"},
  {:phoenix_live_view, "~> 1.0.0-rc.7", override: true},
  {:phoenix_live_dashboard, github: "phoenixframework/phoenix_live_dashboard", branch: "sd-before_closing_head_tag", override: true},
  # {:phoenix_live_dashboard, path: "~/oss/phoenix_live_dashboard", override: true},
  {:extty, "~> 0.4.1"}
])

defmodule Example.Hooks do
  import Phoenix.LiveView
  import Phoenix.Component

  def on_mount(:default, _params, _session, socket) do
    {:cont, Phoenix.LiveDashboard.PageBuilder.register_after_opening_head_tag(socket, &after_opening_head_tag/1) |> dbg()}
  end

  defp after_opening_head_tag(assigns) do
    ~H"""
    <script nonce={@csp_nonces[:script]} src="https://unpkg.com/@xterm/[email protected]/lib/xterm.js">
    </script>
    <script nonce={@csp_nonces[:script]} src="https://unpkg.com/@xterm/[email protected]/lib/addon-fit.js">
    </script>
    <script nonce={@csp_nonces[:script]}>
      window.LiveDashboard.registerCustomHooks({
        Terminal: {
          init() {
            if (this.terminal) this.terminal.dispose();
            this.terminal = new Terminal({
              theme: this.getTheme(),
            });
            const fitAddon = new FitAddon.FitAddon();
            this.fitAddon = fitAddon;
            this.terminal.loadAddon(fitAddon);
            this.terminal.open(this.el);
            fitAddon.fit();
            // send initial size (we need to wait for the first patch)
            this.pushEvent("resize", { cols: this.terminal.cols, rows: this.terminal.rows });
            this.terminal.onResize((size) => this.pushEvent("resize", { cols: size.cols, rows: size.rows }));
            this.terminal.onData((data) => this.pushEvent("terminal", data));
          },
          mounted() {
            this.init();
            this.handleEvent("new-terminal", () => this.init());
            this.handleEvent("terminal", ({ data }) => {
              if (this.terminal) this.terminal.write(data);
            });
            this.resizeHandler = this.handleResize.bind(this);
            window.addEventListener("resize", this.resizeHandler);
          },
          getTheme() {
            return {
              background: "#00000000",
              foreground: "#6C696E",
              selectionBackground: "#6C696E70",
              cursor: "#6C696E",
              cursorAccent: "#6C696E",
              black: "#FFFFFF",
              blue: "#775DFF",
              brightBlack: "#A7A5A8",
              brightBlue: "#775DFF",
              brightCyan: "#149BDA",
              brightGreen: "#17AD98",
              brightMagenta: "#AA17E6",
              brightRed: "#D8137F",
              brightWhite: "#322D34",
              brightYellow: "#DC8A0E",
              cyan: "#149BDA",
              green: "#17AD98",
              magenta: "#AA17E6",
              red: "#D8137F",
              white: "#6C696E",
              yellow: "#DC8A0E"
            };
          },
          handleResize() {
            if (this.fitAddon) this.fitAddon.fit();
          },
          destroyed() {
            window.removeEventListener("resize", this.resizeHandler);
          }
        }
      });
    </script>
    <link rel="stylesheet" href="https://unpkg.com/@xterm/[email protected]/css/xterm.css" nonce={@csp_nonces[:style]} />
    """
  end
end

defmodule Example.TerminalPage do
  @moduledoc false
  use Phoenix.LiveDashboard.PageBuilder, refresher?: false

  @impl true
  def mount(params, _session, socket) do
    tty =
      if connected?(socket) and not is_map_key(params, "node") do
        {:ok, tty} = ExTTY.start_link(handler: self())

        tty
      end

    socket
    |> assign(:tty, tty)
    |> assign(:nodes, [:self | Node.list()])
    |> assign(:node, :self)
    |> then(&{:ok, &1})
  end

  @impl true
  def handle_params(%{"node" => node}, _uri, socket) do
    node = String.to_existing_atom(node)

    socket
    |> assign(:node, node)
    |> push_event("new-terminal", %{})
    |> connect_tty()
    |> then(&{:noreply, &1})
  end

  def handle_params(_, _, socket), do: {:noreply, socket}

  @impl true
  def menu_link(_, _) do
    {:ok, "IEx"}
  end

  def handle_event("terminal", data, socket) do
    ExTTY.send_text(socket.assigns.tty, data)

    {:noreply, socket}
  end

  def handle_event("resize", %{"rows" => rows, "cols" => cols}, socket) do
    ExTTY.window_change(socket.assigns.tty, cols, rows)

    {:noreply, socket}
  end

  defp connect_tty(socket) do
    if socket.assigns.tty do
      Process.unlink(socket.assigns.tty)
      Process.exit(socket.assigns.tty, :normal)
    end

    opts =
      case socket.assigns.node do
        :self -> [handler: self()]
        node -> [handler: self(), remsh: node]
      end

    {:ok, tty} = ExTTY.start_link(opts)

    assign(socket, tty: tty)
  end

  @impl true
  def handle_info({:tty_data, data}, socket) do
    {:noreply, push_event(socket, "terminal", %{"data" => data})}
  end

  def handle_info(_msg, socket), do: {:noreply, socket}

  @impl true
  def render(assigns) do
    ~H"""
    <div :if={connected?(@socket)} id="terminal-page" phx-update="ignore" style="height: calc(100vh - 250px); padding: 4px; border: 1px solid #746f97; background: #00000005;">
      <div
        id="terminal"
        phx-hook="Terminal"
        style="height: 100%;"
      >
      </div>
    </div>
    """
  end
end

defmodule Example.ErrorView do
  def render(template, _), do: Phoenix.Controller.status_message_from_template(template)
end

defmodule Example.Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router
  import Phoenix.LiveDashboard.Router

  def put_csp(conn, _opts) do
    [img_nonce, script_nonce] =
      for _i <- 1..2, do: 16 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)

    conn
    |> assign(:img_csp_nonce, img_nonce)
    |> assign(:script_csp_nonce, script_nonce)
    |> put_resp_header(
      "content-security-policy",
      "default-src; script-src 'nonce-#{script_nonce}'; style-src 'self' https://unpkg.com 'unsafe-inline'; " <>
        "img-src 'nonce-#{img_nonce}' data: ; font-src data: ; connect-src 'self'; frame-src 'self' ;"
    )
  end

  pipeline :browser do
    plug(:accepts, ["html"])
    plug :put_csp
  end

  scope "/", Example do
    pipe_through(:browser)

    live_dashboard "/",
      additional_pages: [
        terminal: Example.TerminalPage
      ],
      on_mount: Example.Hooks,
      csp_nonce_assign_key: %{
        img: :img_csp_nonce,
        style: :style_csp_nonce,
        script: :script_csp_nonce
      }
  end
end

defmodule Example.Endpoint do
  use Phoenix.Endpoint, otp_app: :sample
  socket("/live", Phoenix.LiveView.Socket)

  plug Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix"
  plug Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view"

  plug(Example.Router)
end

{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)

Copy link
Member

@josevalim josevalim left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This LGTM as long as you think it won't overlap with what we may add to LiveView. :)

Also, it seems you are removing a bunch of files, just keep in mind that we always bundle precompiled assets in case someone wants to use the repo from git :)

awesome job, ship it!!!

@SteffenDE
Copy link
Contributor Author

This LGTM as long as you think it won't overlap with what we may add to LiveView. :)

It won't. Colocated hooks will (maybe) make this easier, so this feature won't be needed for bringing hooks to custom pages any more, but it also wouldn't have any conflicts.

Also, it seems you are removing a bunch of files, just keep in mind that we always bundle precompiled assets in case someone wants to use the repo from git :)

The removals are from the changes to the compiled app.css. Previously it wasn't minified into one line, now it is, but I don't know why.

awesome job, ship it!!!

Thank you! 🚀

@SteffenDE SteffenDE marked this pull request as ready for review November 11, 2024 09:21
@SteffenDE SteffenDE merged commit 90ca015 into main Nov 11, 2024
4 checks passed
@SteffenDE SteffenDE deleted the sd-before_closing_head_tag branch November 11, 2024 10:10
@egze
Copy link

egze commented Nov 13, 2024

Thank you! Can you please push a new version out?

@SteffenDE
Copy link
Contributor Author

I don’t have the permissions to, but @josevalim can :)

@josevalim
Copy link
Member

@egze please give it a try in your custom page and once it works, let us know, and I can ship it. Just in case there is something pending/missing/incorrect while you work on it.

@egze
Copy link

egze commented Nov 14, 2024

@josevalim works like a charm

@josevalim
Copy link
Member

Released.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants