diff --git a/.formatter.exs b/.formatter.exs index 6ede60f6..277d7292 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -8,7 +8,7 @@ request: 2 ], line_length: 120, - import_deps: [:gen_lsp], + import_deps: [:gen_lsp, :plug, :temple], plugins: [Styler], inputs: [ ".formatter.exs", diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 00000000..b94b4b8c --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,14 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +@import url('https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Rubik:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); + +pre { + @apply bg-zinc-500 text-white rounded px-2 text-sm; +} + +h1, h2, h3, h4, h5 { + @apply font-fancy font-semibold; +} diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js new file mode 100644 index 00000000..40b5eec3 --- /dev/null +++ b/assets/tailwind.config.js @@ -0,0 +1,17 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +const defaultTheme = require("tailwindcss/defaultTheme"); + +module.exports = { + content: ["./js/**/*.js", "./lib/**/*.ex"], + theme: { + extend: { + fontFamily: { + sans: ['"Inter"', ...defaultTheme.fontFamily.sans], + fancy: ['"Rubik"', ...defaultTheme.fontFamily.sans], + }, + }, + }, + plugins: [], +}; diff --git a/config/config.exs b/config/config.exs index cd210e21..cb8aad5e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -2,6 +2,21 @@ import Config config :next_ls, :indexing_timeout, 100 +config :temple, + engine: EEx.SmartEngine, + attributes: {Temple, :attributes} + +config :tailwind, + version: "3.3.2", + default: [ + args: ~w( + --config=assets/tailwind.config.js + --input=assets/css/app.css + --output=priv/css/site.css + ) + ] + +# config :logger, :default_handler, config: [type: :standard_error] config :logger, :default_handler, config: [ file: ~c".elixir-tools/next-ls.log", @@ -14,4 +29,12 @@ config :logger, :default_handler, config :logger, :default_formatter, format: "\n$time $metadata[$level] $message\n", metadata: [:id] +config :next_ls, :logger, [ + {:handler, :ui_logger, NextLS.UI.Logger, + %{ + config: %{}, + formatter: Logger.Formatter.new() + }} +] + import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index becde769..614a307b 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -1 +1,6 @@ import Config + +config :next_ls, :assets, tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} + +config :web_dev_utils, :reload_url, "'wss://' + location.host + '/ws'" +config :web_dev_utils, :reload_log, true diff --git a/lib/next_ls.ex b/lib/next_ls.ex index 7ba9440d..25b379df 100644 --- a/lib/next_ls.ex +++ b/lib/next_ls.ex @@ -64,13 +64,22 @@ defmodule NextLS do task_supervisor = Keyword.fetch!(args, :task_supervisor) runtime_task_supervisor = Keyword.fetch!(args, :runtime_task_supervisor) dynamic_supervisor = Keyword.fetch!(args, :dynamic_supervisor) - registry = Keyword.fetch!(args, :registry) - extensions = Keyword.get(args, :extensions, elixir: NextLS.ElixirExtension, credo: NextLS.CredoExtension) cache = Keyword.fetch!(args, :cache) + {:ok, logger} = DynamicSupervisor.start_child(dynamic_supervisor, {NextLS.Logger, lsp: lsp}) + {:ok, ui} = + DynamicSupervisor.start_child( + dynamic_supervisor, + {Bandit, + [ + plug: {NextLS.UI.Router, registry: registry}, + port: "NEXTLS_UI_PORT" |> System.get_env("0") |> String.to_integer() + ]} + ) + {:ok, assign(lsp, auto_update: Keyword.get(args, :auto_update, false), @@ -85,7 +94,8 @@ defmodule NextLS do registry: registry, extensions: extensions, ready: false, - client_capabilities: nil + client_capabilities: nil, + ui: ui )} end @@ -127,6 +137,7 @@ defmodule NextLS do nil end, document_formatting_provider: true, + execute_command_provider: %GenLSP.Structures.ExecuteCommandOptions{commands: ["open-ui"]}, hover_provider: true, workspace_symbol_provider: true, document_symbol_provider: true, @@ -572,6 +583,23 @@ defmodule NextLS do {:reply, [], lsp} end + def handle_request( + %GenLSP.Requests.WorkspaceExecuteCommand{params: %GenLSP.Structures.ExecuteCommandParams{command: command}}, + lsp + ) do + {:ok, {_, port}} = ThousandIsland.listener_info(lsp.assigns.ui) + + case command do + "open-ui" -> + System.cmd("open", ["http://localhost:#{port}"]) + + _ -> + NextLS.Logger.warning(lsp.logger, "[Next LS] Unknown workspace command: #{command}") + end + + {:reply, nil, lsp} + end + def handle_request(%Shutdown{}, lsp) do {:reply, nil, assign(lsp, exit_code: 0)} end diff --git a/lib/next_ls/application.ex b/lib/next_ls/application.ex index 0e935ce0..58aab83c 100644 --- a/lib/next_ls/application.ex +++ b/lib/next_ls/application.ex @@ -24,11 +24,22 @@ defmodule NextLS.Application do Node.start(:"next-ls-#{System.system_time()}", :shortnames) - children = [NextLS.LSPSupervisor] + children = [ + {Registry, name: NextLS.UI.Registry, keys: :duplicate}, + WebDevUtils.FileSystem, + WebDevUtils.CodeReloader, + NextLS.LSPSupervisor + ] # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: NextLS.Supervisor] - Supervisor.start_link(children, opts) + Supervisor.start_link(children ++ asset_children(), opts) + end + + def asset_children do + for conf <- Application.get_env(:next_ls, :assets, []) do + {WebDevUtils.Assets, conf} + end end end diff --git a/lib/next_ls/db/activity.ex b/lib/next_ls/db/activity.ex index 26d34099..988f2826 100644 --- a/lib/next_ls/db/activity.ex +++ b/lib/next_ls/db/activity.ex @@ -13,7 +13,13 @@ defmodule NextLS.DB.Activity do :gen_statem.start_link({:local, Keyword.get(args, :name)}, __MODULE__, Keyword.drop(args, [:name]), []) end - def update(statem, count), do: :gen_statem.cast(statem, count) + def update(statem, count, time \\ DateTime.utc_now() |> DateTime.to_unix(:millisecond)) do + Registry.dispatch(NextLS.UI.Registry, :activity_socket, fn entries -> + for {pid, _} <- entries, do: send(pid, {:activity, count, time}) + end) + + :gen_statem.cast(statem, count) + end @impl :gen_statem def callback_mode, do: :state_functions @@ -44,6 +50,10 @@ defmodule NextLS.DB.Activity do {:next_state, :waiting, %{data | token: nil}} end + # def active(event, msg, data) do + # handle_event(event, msg, data) + # end + def waiting(:cast, 0, _data) do :keep_state_and_data end @@ -53,4 +63,12 @@ defmodule NextLS.DB.Activity do NextLS.Progress.start(data.lsp, token, "Indexing!") {:next_state, :active, %{data | count: mailbox_count, token: token}} end + + # def waiting(event, msg, data) do + # handle_event(event, msg, data) + # end + + # defp handle_event({:call, from}, :get, data) do + # {:keep_state, data, [{:reply, from, data.count}]} + # end end diff --git a/lib/next_ls/runtime/supervisor.ex b/lib/next_ls/runtime/supervisor.ex index 32751e03..5a74452c 100644 --- a/lib/next_ls/runtime/supervisor.ex +++ b/lib/next_ls/runtime/supervisor.ex @@ -26,7 +26,9 @@ defmodule NextLS.Runtime.Supervisor do children = [ {NextLS.Runtime.Sidecar, name: sidecar_name, db: db_name}, {NextLS.DB.Activity, - logger: logger, name: db_activity, lsp: lsp, timeout: Application.get_env(:next_ls, :indexing_timeout)}, + logger: logger, + registry: registry, + name: db_activity, lsp: lsp, timeout: Application.get_env(:next_ls, :indexing_timeout)}, {NextLS.DB, logger: logger, file: "#{hidden_folder}/nextls.db", diff --git a/lib/next_ls_ui/components.ex b/lib/next_ls_ui/components.ex new file mode 100644 index 00000000..64448658 --- /dev/null +++ b/lib/next_ls_ui/components.ex @@ -0,0 +1,111 @@ +defmodule NextLS.UI.Component do + @moduledoc false + use Temple.Component + + defmacro __using__(_) do + quote do + import Temple + import unquote(__MODULE__) + end + end +end + +defmodule NextLS.UI.Components do + @moduledoc false + use NextLS.UI.Component + + @env Mix.env() + + def root(assigns) do + assigns = Map.put(assigns, :env, @env) + + temple do + "" + + html lang: "en" do + head do + meta charset: "utf-8" + meta http_equiv: "X-UA-Compatible", content: "IE=edge" + meta name: "viewport", content: "width=device-width, initial-scale=1.0" + + title do + "Next LS Inspector" + end + + script src: "https://unpkg.com/htmx.org@1.9.6" + script src: "https://unpkg.com/htmx.org/dist/ext/ws.js" + script src: "https://unpkg.com/htmx.org/dist/ext/morphdom-swap.js" + + script src: "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js" + + script src: "https://cdn.jsdelivr.net/npm/luxon@^2" + script src: "https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@^1" + + + link rel: "stylesheet", href: "/css/site.css" + end + + body class: "bg-zinc-200 dark:bg-zinc-900 font-sans", hx_ext: "morphdom-swap" do + main class: "container mx-auto" do + header class: "mb-8 py-2" do + div class: "flex items-center space-x-2" do + a href: "/", class: "hover:underline" do + img src: "/nextls-logo-no-background.png", class: "h-8 w-8" + end + + h2 class: "text-xl dark:text-white" do + a href: "/" do + "Next LS Inspector" + end + end + end + end + + slot @inner_block + + footer class: "flex justify-between dark:text-white mt-8 py-4" do + div do + a class: "underline", + href: "https://github.com/elixir-tools/next-ls", + do: "Source Code" + end + + div class: "italic" do + span do: "Built with" + + a class: "underline", + href: "https://github.com/mhanberg/temple", + do: "Temple," + + a class: "underline", + href: "https://tailwindcss.com", + do: "TailwindCSS," + + a class: "underline", + href: "https://htmx.org", + do: "HTMX," + + " and" + + span class: "text-red-500", do: "♥" + end + end + end + + if @env == :dev do + c &WebDevUtils.Components.live_reload/1 + end + end + end + end + end + + def card(assigns) do + temple do + div class: "#{assigns[:class]} bg-zinc-50 dark:bg-zinc-700 dark:text-white rounded shadow-xl p-2", + rest!: Map.take(assigns, [:id]) do + slot @inner_block + end + end + end +end diff --git a/lib/next_ls_ui/logger.ex b/lib/next_ls_ui/logger.ex new file mode 100644 index 00000000..c0ffc3dd --- /dev/null +++ b/lib/next_ls_ui/logger.ex @@ -0,0 +1,12 @@ +defmodule NextLS.UI.Logger do + @moduledoc false + def log(event, _config) do + if Process.alive?(Process.whereis(NextLS.UI.Registry)) do + Registry.dispatch(NextLS.UI.Registry, :log_socket, fn entries -> + for {pid, _} <- entries do + send(pid, {:log, event}) + end + end) + end + end +end diff --git a/lib/next_ls_ui/pages/home_page.ex b/lib/next_ls_ui/pages/home_page.ex new file mode 100644 index 00000000..3ed07cc9 --- /dev/null +++ b/lib/next_ls_ui/pages/home_page.ex @@ -0,0 +1,268 @@ +defmodule NextLS.UI.HomePage do + @moduledoc false + + use NextLS.UI.Component + + import NextLS.UI.Components + + def run(_conn, assigns) do + assigns = Map.put(assigns, :node, assigns.query["node"] || Atom.to_string(Node.self())) + + temple do + c &root/1 do + div class: "grid grid-cols-1 lg:grid-cols-2 gap-4" do + c &card/1 do + h2 class: "text-xl dark:text-white mb-2" do + "System Information" + end + + ul do + li do + span class: "flex items-center gap-2" do + "Version:" + pre do: NextLS.version() + end + end + + li do + span class: "flex items-center gap-2" do + "Elixir: " + pre do: System.version() + end + end + + li do + span class: "flex items-center gap-2" do + "OTP:" + pre class: "whitespace-pre-wrap", do: :erlang.system_info(:system_version) + end + end + + li do + span class: "flex items-center gap-2" do + "OS:" + pre class: "whitespace-pre-wrap", do: inspect(:os.type()) + end + end + + li do + span class: "flex items-center gap-2" do + "Arch:" + + pre class: "whitespace-pre-wrap", + do: :system_architecture |> :erlang.system_info() |> List.to_string() + end + end + + li do + span class: "flex items-center gap-2" do + "Schedulers:" + + pre class: "whitespace-pre-wrap", + do: System.schedulers_online() + end + end + end + end + + c &card/1, class: "col-span-1" do + div class: "flex flex-col justify-between mb-2" do + h2 class: "text-lg dark:text-white mb-2" do + "Node Information" + end + + select class: "bg-zinc-500 text-zinc-50 dark:bg-zinc-100 dark:text-zinc-950 rounded p-1", + name: "node", + hx_get: "/node", + hx_target: "#node" do + for n <- [Node.self() | Node.list()] do + option value: n, selected: @node == n do + n + end + end + end + end + + div id: "node" do + c &node_information/1, node: @node + end + end + + c &card/1, class: "col-span-2" do + h2 class: "text-lg dark:text-white mb-2" do + "DB Query Lag" + end + + div class: "w-full" do + canvas id: "lag" + end + + div hx_ext: "ws", ws_connect: "/ws/activity" do + div id: "activity" do + script do: """ + var chart = new Chart( + document.getElementById('lag'), + { + type: 'bar', + options: { + scales: { + x: { + type: "time" + } + } + }, + data: { + labels: [], + datasets: [ + { + label: 'DB lag over time', + data: [] + } + ] + } + } + ); + """ + end + end + end + + c &card/1, class: "col-span-1 lg:col-span-2" do + h2 class: "text-xl dark:text-white mb-2" do + "Logs" + end + + div class: "group min-h-[16rem]", + id: "log-container", + hx_ext: "ws", + ws_connect: "/ws/logs", + data_log_show_error: true, + data_log_show_warning: true, + data_log_show_info: true, + data_log_show_debug: true, + hx_on: + "htmx:wsAfterMessage: event.currentTarget.children.logs.scrollTo(0, event.currentTarget.children.logs.scrollHeight)" do + div class: "flex gap-4 mb-4" do + c &log_toggle_button/1, type: :debug do + "debug" + end + + c &log_toggle_button/1, type: :info do + "info" + end + + c &log_toggle_button/1, type: :warning do + "warning" + end + + c &log_toggle_button/1, type: :error do + "error" + end + end + + div id: "logs", class: "group max-h-72 overflow-y-scroll font-mono" do + div class: "hidden only:block italic text-sm" do + "Nothing yet..." + end + end + end + end + end + end + end + end + + def log_toggle_button(assigns) do + classes = + case assigns.type do + :error -> + "border-red-500 group-data-[log-show-error]:bg-red-500 text-red-500 group-data-[log-show-error]:text-red-950" + + :warning -> + "border-yellow-500 group-data-[log-show-warning]:bg-yellow-500 text-yellow-500 group-data-[log-show-warning]:text-yellow-950" + + :info -> + "border-white group-data-[log-show-info]:bg-white text-white group-data-[log-show-info]:text-black" + + :debug -> + "border-cyan-500 group-data-[log-show-debug]:bg-cyan-500 text-cyan-500 group-data-[log-show-debug]:text-cyan-950" + end + + assigns = Map.put(assigns, :classes, classes) + + temple do + button class: "w-16 text-sm rounded border bg-transparent #{@classes}", + "hx_on:click": "htmx.find('#log-container').toggleAttribute('data-log-show-#{@type}')", + type: "button" do + slot @inner_block + end + end + end + + def node_information(assigns) do + temple do + ul do + li do + span class: "flex items-center gap-2" do + "Elixir: " + pre do: :erpc.call(String.to_atom(@node), System, :version, []) + end + end + + li do + span class: "flex items-center gap-2" do + "OTP:" + + pre class: "whitespace-pre-wrap", + do: :erpc.call(String.to_atom(@node), :erlang, :system_info, [:system_version]) + end + end + + li do + span class: "flex items-center gap-2" do + "Directory:" + + pre class: "whitespace-pre-wrap", + do: :erpc.call(String.to_atom(@node), File, :cwd!, []) + end + end + + li do + span class: "flex items-center gap-2" do + "Elixir exe:" + + pre class: "whitespace-pre-wrap", + do: :erpc.call(String.to_atom(@node), System, :find_executable, ["elixir"]) + end + end + + li do + span class: "flex items-center gap-2" do + "Erlang exe:" + + pre class: "whitespace-pre-wrap", + do: :erpc.call(String.to_atom(@node), System, :find_executable, ["erl"]) + end + end + + li do + span class: "flex items-center gap-2" do + "epmd exe:" + + pre class: "whitespace-pre-wrap", + do: :erpc.call(String.to_atom(@node), System, :find_executable, ["epmd"]) + end + end + + li do + span class: "flex items-center gap-2" do + "Schedulers:" + + pre class: "whitespace-pre-wrap", + do: :erpc.call(String.to_atom(@node), System, :schedulers_online, []) + end + end + end + end + end +end diff --git a/lib/next_ls_ui/router.ex b/lib/next_ls_ui/router.ex new file mode 100644 index 00000000..9c09d66f --- /dev/null +++ b/lib/next_ls_ui/router.ex @@ -0,0 +1,69 @@ +defmodule NextLS.UI.Router do + use Plug.Router, copy_opts_to_assign: :opts + use Plug.Debugger + use NextLS.UI.Component + + require Logger + + @not_found ~s''' +
Not Found + ''' + + def init(opts), do: opts + + if Mix.env() == :dev do + plug :recompile + + defp recompile(conn, _) do + WebDevUtils.CodeReloader.reload() + + conn + end + end + + plug Plug.Static, at: "/", from: "priv", cache_control_for_etags: "no-cache" + plug :fetch_query_params + + plug :match + plug :dispatch + + get "/" do + response = NextLS.UI.HomePage.run(conn, %{query: conn.query_params}) + + conn + |> put_resp_header("Content-Type", "text/html") + |> resp(200, response) + end + + get "/node" do + response = NextLS.UI.HomePage.node_information(%{node: conn.query_params["node"]}) + + conn + |> put_resp_header("Content-Type", "text/html") + |> resp(200, response) + end + + get "/ws" do + conn + |> WebSockAdapter.upgrade(NextLS.UI.Websocket.Reload, [], timeout: 60_000) + |> halt() + end + + get "/ws/logs" do + conn + |> WebSockAdapter.upgrade(NextLS.UI.Websocket.Logs, [], timeout: 60_000) + |> halt() + end + + get "/ws/activity" do + conn + |> WebSockAdapter.upgrade(NextLS.UI.Websocket.Activity, [registry: conn.assigns.opts[:registry]], timeout: 60_000) + |> halt() + end + + match _ do + Logger.error("File not found: #{conn.request_path}") + + send_resp(conn, 404, @not_found) + end +end diff --git a/lib/next_ls_ui/websocket.ex b/lib/next_ls_ui/websocket.ex new file mode 100644 index 00000000..3a46ed51 --- /dev/null +++ b/lib/next_ls_ui/websocket.ex @@ -0,0 +1,173 @@ +defmodule NextLS.UI.Websocket do + @moduledoc false + defmodule Reload do + @moduledoc false + + def init(_args) do + :ok = WebDevUtils.LiveReload.init() + + {:ok, %{}} + end + + def handle_in({"subscribe", [opcode: :text]}, state) do + {:push, {:text, "subscribed"}, state} + end + + def handle_info({:reload, _asset_type}, state) do + {:push, {:text, "reload"}, state} + end + + def handle_info({:file_event, _watcher_pid, {_path, _event}} = file_event, state) do + WebDevUtils.LiveReload.reload!(file_event, + patterns: [ + ~r"lib/next_ls_ui/.*.ex", + ~r"assets/.*.(css|js)" + ] + ) + + {:ok, state} + end + end + + defmodule Logs do + @moduledoc false + import Temple + + require Logger + + def init(_args) do + Registry.register(NextLS.UI.Registry, :log_socket, true) + + {:ok, %{}} + end + + def handle_info({:log, event}, state) do + resp = + if event.level == :notice do + "" + else + {_formatter, config} = Logger.default_formatter(colors: [enabled: false]) + + temple do + div id: "logs", hx_swap_oob: "beforeend" do + div data_log_type: event.level, + class: """ + hidden + #{log_show(event.level)} + data-[log-type=error]:text-red-500 + data-[log-type=warning]:text-yellow-500 + data-[log-type=info]:text-white + data-[log-type=debug]:text-cyan-500 + """ do + Logger.Formatter.format(event, config) + end + end + end + end + + {:push, {:text, resp}, state} + end + + def handle_info(message, state) do + Logger.notice("Unhandled message: #{inspect(message)}") + + {:ok, state} + end + + defp log_show(type) do + case type do + :error -> "group-data-[log-show-error]:block" + :warning -> "group-data-[log-show-warning]:block" + :info -> "group-data-[log-show-info]:block" + :debug -> "group-data-[log-show-debug]:block" + end + end + end + + defmodule Activity do + @moduledoc false + import Temple + + require Logger + + def init(_args) do + Registry.register(NextLS.UI.Registry, :activity_socket, true) + :timer.send_interval(5000, :update) + {:ok, %{data: [], last: %{count: 0}}} + end + + def handle_info({:activity, count, time}, state) do + {:ok, put_in(state.data, [%{count: count, time: time} | state.data])} + end + + def handle_info(:update, state) do + now = DateTime.utc_now() |> DateTime.to_unix(:millisecond) |> to_string() + + assigns = + if state.data == [] do + %{ + counts: to_string(state.last.count), + times: now + } + else + counts = Enum.map_join(state.data, ", ", &"#{&1.count}") + times = Enum.map_join(state.data, ", ", &"#{&1.time}") + + %{ + counts: counts, + times: times + } + end + + resp = + temple do + div id: "activity" do + script do + """ + (function() { + const then = #{now} - 30000; + chart.data.labels.push(#{@times}); + chart.data.datasets[0].data.push(#{@counts}); + const data = chart.data.datasets[0].data; + const labels = chart.data.labels.filter((label) => label > then); + chart.data.labels = labels; + chart.data.datasets[0].data = data.slice(-labels.length); + chart.update() + })(); + """ + end + end + end + + {:push, {:text, resp}, %{state | data: [], last: List.first(state.data) || state.last}} + end + + def handle_info(message, state) do + Logger.notice("Unhandled message: #{inspect(message)}") + + {:ok, state} + end + + def javascript_escape(data) when is_binary(data), do: javascript_escape(data, "") + + defp javascript_escape(<<0x2028::utf8, t::binary>>, acc), do: javascript_escape(t, <