diff --git a/.formatter.exs b/.formatter.exs index ecde7b6..73d29a7 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,6 @@ # Used by "mix format" [ - inputs: ["{mix,.formatter,dev,dev.*}.exs", "{config,lib,test}/**/*.{ex,exs}"], - import_deps: [:ecto, :ecto_sql, :plug, :phoenix] + import_deps: [:ecto, :ecto_sql, :plug, :phoenix], + inputs: ["{mix,.formatter,dev,dev.*}.exs", "{config,lib,test}/**/*.{heex,ex,exs}"], + plugins: [Phoenix.LiveView.HTMLFormatter] ] diff --git a/.gitignore b/.gitignore index 28dc7b0..21d1524 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,9 @@ error_tracker-*.tar # Temporary files, for example, from tests. /tmp/ +/assets/node_modules + +/priv/static/app.js +/priv/static/app.css + dev.local.exs diff --git a/README.md b/README.md index 3024954..1430ec5 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,42 @@ defmodule MyApp.Endpoint do use ErrorTracker.Integrations.Plug end ``` + +## Development + +### Development server + +We have a `dev.exs` script that starts a development server. + +To run it together with an `IEx` console you can do: + +``` +iex -S mix dev +``` + +### Assets + +In ortder to participate in the development of this library, you may need to +know how to compile the assets needed to use the Web UI. + +To do so, you need to first make a clean build: + +``` +mix do assets.install, assets.build +``` + +That task will build the JS and CSS of the project. + +The JS is not expected to change too much because we rely in LiveView, but if +you make any change just execute that command again and you are good to go. + +In the case of CSS, as it is automatically generated by Tailwind, you need to +start the watcher when your intention is to modify the classes used. + +To do so you can execute this task in a separate terminal: + +``` +mix assets.watch +``` + + diff --git a/assets/bun.lockb b/assets/bun.lockb new file mode 100755 index 0000000..b0a82ae Binary files /dev/null and b/assets/bun.lockb differ diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..71a77f0 --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,4 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..5faf26a --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,20 @@ +// Establish Phoenix Socket and LiveView configuration. +import { Socket, LongPoll } from "phoenix"; +import { LiveSocket } from "phoenix_live_view"; +import topbar from "topbar"; + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); +let socketPath = document.querySelector("meta[name='socket-path']").getAttribute("content"); +let socketTransport = document.querySelector("meta[name='socket-transport']").getAttribute("content"); +let normalizedTransport = (socketTransport == "longpoll") ? LongPoll : WebSocket; + +let liveSocket = new LiveSocket(socketPath, Socket, { transport: normalizedTransport, params: { _csrf_token: csrfToken }}); + +// Show progress bar on live navigation and form submits +topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); +window.addEventListener("phx:page-loading-start", (_info) => topbar.show(300)); +window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide()); + +// connect if there are any LiveViews on the page +liveSocket.connect(); +window.liveSocket = liveSocket; diff --git a/assets/package.json b/assets/package.json new file mode 100644 index 0000000..31b8464 --- /dev/null +++ b/assets/package.json @@ -0,0 +1,10 @@ +{ + "workspaces": [ + "../deps/*" + ], + "dependencies": { + "phoenix": "workspace:*", + "phoenix_live_view": "workspace:*", + "topbar": "^3.0.0" + } +} diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js new file mode 100644 index 0000000..1447c42 --- /dev/null +++ b/assets/tailwind.config.js @@ -0,0 +1,22 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +let plugin = require('tailwindcss/plugin') + +module.exports = { + content: [ + './js/**/*.js', + '../lib/error_tracker/web.ex', + '../lib/error_tracker/web/**/*.*ex' + ], + theme: { + extend: {}, + }, + plugins: [ + require('@tailwindcss/forms'), + plugin(({addVariant}) => addVariant('phx-no-feedback', ['&.phx-no-feedback', '.phx-no-feedback &'])), + plugin(({addVariant}) => addVariant('phx-click-loading', ['&.phx-click-loading', '.phx-click-loading &'])), + plugin(({addVariant}) => addVariant('phx-submit-loading', ['&.phx-submit-loading', '.phx-submit-loading &'])), + plugin(({addVariant}) => addVariant('phx-change-loading', ['&.phx-change-loading', '.phx-change-loading &'])) + ] +} diff --git a/config/config.exs b/config/config.exs index 0845f95..3c99d05 100644 --- a/config/config.exs +++ b/config/config.exs @@ -7,8 +7,16 @@ if config_env() == :dev do args: ~w( --config=tailwind.config.js --input=css/app.css - --output=../priv/static/assets/app.css + --output=../priv/static/app.css ), cd: Path.expand("../assets", __DIR__) ] + + config :bun, + version: "1.1.18", + default: [ + args: ~w(build app.js --outdir=../../priv/static), + cd: Path.expand("../assets/js", __DIR__), + env: %{} + ] end diff --git a/dev.exs b/dev.exs index b02d8e6..55ea57c 100644 --- a/dev.exs +++ b/dev.exs @@ -94,6 +94,7 @@ end defmodule ErrorTrackerDevWeb.Router do use Phoenix.Router + use ErrorTracker.Web, :router pipeline :browser do plug :fetch_session @@ -106,6 +107,8 @@ defmodule ErrorTrackerDevWeb.Router do get "/noroute", ErrorTrackerDevWeb.PageController, :noroute get "/exception", ErrorTrackerDevWeb.PageController, :exception get "/exit", ErrorTrackerDevWeb.PageController, :exit + + error_tracker_dashboard("/errors") end end diff --git a/lib/error_tracker/web.ex b/lib/error_tracker/web.ex new file mode 100644 index 0000000..f866fb5 --- /dev/null +++ b/lib/error_tracker/web.ex @@ -0,0 +1,83 @@ +defmodule ErrorTracker.Web do + @moduledoc """ + ErrorTracker includes a Web UI to view and inspect errors occurred on your + application and already stored on the database. + + In order to use it, you need to add the following to your Phoenix's ` + router.ex` file: + + ```elixir + defmodule YourAppWeb.Router do + use Phoenix.Router + use ErrorTracker.Web, :router + + ... + + error_tracker_dashboard "/errors" + end + ``` + + This will add the routes needed for the ErrorTracker LiveView UI to work. + + ## LiveView socket options + + By default the library expects you to have your LiveView socket at `/live` and + using `websocket` transport. + + If that's not the case, you can configure it adding the following + configuration to your app's config files: + + ```elixir + config :error_tracker, + socket: [ + path: "/my-custom-socket-path" + transport: :longpoll # (accepted values are :longpoll or :websocket) + ] + ``` + """ + + def html do + quote do + import Phoenix.Controller, only: [get_csrf_token: 0] + + unquote(html_helpers()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, layout: {ErrorTracker.Web.Layouts, :live} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def router do + quote do + import ErrorTracker.Web.Router + end + end + + defp html_helpers do + quote do + use Phoenix.Component + + import Phoenix.HTML + import Phoenix.LiveView.Helpers + + alias Phoenix.LiveView.JS + end + end + + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/lib/error_tracker/web/components/layouts.ex b/lib/error_tracker/web/components/layouts.ex new file mode 100644 index 0000000..33ecade --- /dev/null +++ b/lib/error_tracker/web/components/layouts.ex @@ -0,0 +1,19 @@ +defmodule ErrorTracker.Web.Layouts do + use ErrorTracker.Web, :html + + @css_path :code.priv_dir(:error_tracker) |> Path.join("static/app.css") + @js_path :code.priv_dir(:error_tracker) |> Path.join("static/app.js") + + @default_docket_config %{path: "/live", transport: :websocket} + + embed_templates "layouts/*" + + def get_content(:css), do: File.read!(@css_path) + def get_content(:js), do: File.read!(@js_path) + + def get_socket_config(key) do + default = Map.get(@default_docket_config, key) + config = Application.get_env(:error_tracker, :live_view_socket, []) + Keyword.get(config, key, default) + end +end diff --git a/lib/error_tracker/web/components/layouts/live.html.heex b/lib/error_tracker/web/components/layouts/live.html.heex new file mode 100644 index 0000000..c753bc6 --- /dev/null +++ b/lib/error_tracker/web/components/layouts/live.html.heex @@ -0,0 +1,3 @@ +
+ <%= @inner_content %> +
diff --git a/lib/error_tracker/web/components/layouts/root.html.heex b/lib/error_tracker/web/components/layouts/root.html.heex new file mode 100644 index 0000000..a802f2e --- /dev/null +++ b/lib/error_tracker/web/components/layouts/root.html.heex @@ -0,0 +1,24 @@ + + + + + + + + + + + <%= assigns[:page_title] || "ErrorTracker" %> + + + + + + + <%= @inner_content %> + + diff --git a/lib/error_tracker/web/live/dashboard.ex b/lib/error_tracker/web/live/dashboard.ex new file mode 100644 index 0000000..caf12e6 --- /dev/null +++ b/lib/error_tracker/web/live/dashboard.ex @@ -0,0 +1,20 @@ +defmodule ErrorTracker.Web.Live.Dashboard do + @moduledoc false + + use ErrorTracker.Web, :live_view + + @impl Phoenix.LiveView + def mount(_params, _session, socket) do + {:ok, assign(socket, :counter, 0)} + end + + @impl Phoenix.LiveView + def handle_params(_params, _uri, socket) do + {:noreply, socket} + end + + @impl Phoenix.LiveView + def handle_event("increment", _params, socket) do + {:noreply, assign(socket, :counter, socket.assigns.counter + 1)} + end +end diff --git a/lib/error_tracker/web/live/dashboard.html.heex b/lib/error_tracker/web/live/dashboard.html.heex new file mode 100644 index 0000000..07f5708 --- /dev/null +++ b/lib/error_tracker/web/live/dashboard.html.heex @@ -0,0 +1,8 @@ +

Hello world!

+

Number of presses: <%= @counter %>

+ diff --git a/lib/error_tracker/web/router.ex b/lib/error_tracker/web/router.ex new file mode 100644 index 0000000..52136df --- /dev/null +++ b/lib/error_tracker/web/router.ex @@ -0,0 +1,35 @@ +defmodule ErrorTracker.Web.Router do + @moduledoc false + + @doc """ + Creates the routes needed to use the `ErrorTracker` web interface. + + It requires a path in which you are going to serve the web interface. + """ + defmacro error_tracker_dashboard(path, opts \\ []) do + {session_name, session_opts} = parse_options(opts) + + quote do + scope unquote(path), alias: false, as: false do + import Phoenix.LiveView.Router, only: [live: 4, live_session: 3] + + live_session unquote(session_name), unquote(session_opts) do + live "/", ErrorTracker.Web.Live.Dashboard, :index, as: unquote(session_name) + end + end + end + end + + @doc false + def parse_options(opts) do + on_mount = Keyword.get(opts, :on_mount, []) + session_name = Keyword.get(opts, :as, :error_tracker_dashboard) + + session_opts = [ + on_mount: on_mount, + root_layout: {ErrorTracker.Web.Layouts, :root} + ] + + {session_name, session_opts} + end +end diff --git a/mix.exs b/mix.exs index 00ee6a7..38a3fe7 100644 --- a/mix.exs +++ b/mix.exs @@ -50,6 +50,7 @@ defmodule ErrorTracker.MixProject do {:plug, "~> 1.10"}, {:postgrex, ">= 0.0.0"}, # Dev dependencies + {:bun, "~> 1.3", only: :dev}, {:credo, "~> 1.7", only: [:dev, :test]}, {:ex_doc, "~> 0.33", only: :dev}, {:phoenix_live_reload, ">= 0.0.0", only: :dev}, @@ -62,7 +63,9 @@ defmodule ErrorTracker.MixProject do [ setup: ["deps.get", "cmd --cd assets npm install"], dev: "run --no-halt dev.exs", - "assets.build": ["esbuild default --minify"] + "assets.install": ["bun.install", "cmd _build/bun install --cwd assets/"], + "assets.watch": ["tailwind default --watch"], + "assets.build": ["bun default --minify", "tailwind default --minify"] ] end end diff --git a/mix.lock b/mix.lock index b24680c..0f48b67 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,5 @@ %{ + "bun": {:hex, :bun, "1.3.0", "6833722da5b073777e043aec42091b0cf8bbacb84262ec6d348a914dda4c6a98", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "dde1b8116ba57269a9f398b4b28492b16fb29a78800c9533b7c9fb036793d62a"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"},