diff --git a/assets/js/app.js b/assets/js/app.js index ebb0068..64d71eb 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -18,21 +18,65 @@ // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. import "phoenix_html" // Establish Phoenix Socket and LiveView configuration. -import {Socket, LongPoll} from "phoenix" -import {LiveSocket} from "phoenix_live_view" -import topbar from "../vendor/topbar" +import { Socket, LongPoll } from "phoenix" +import { LiveSocket } from "phoenix_live_view" +import topbar from "topbar" +import * as echarts from "echarts" const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveTran = document.querySelector("meta[name='live-transport']").getAttribute("content") const livePath = document.querySelector("meta[name='live-path']").getAttribute("content") +let hooks = {} + +hooks.ObserverEChart = { + mounted() { + selector = "#" + this.el.id + + this.chart = echarts.init(this.el.querySelector(selector + "-chart")) + option = JSON.parse(this.el.querySelector(selector + "-data").textContent) + + this.chart.setOption(option) + }, + updated() { + selector = "#" + this.el.id + // This flag will indicate to Echart to not merge the data + let notMerge = !this.el.dataset.merge ?? true; + + newOption = JSON.parse(this.el.querySelector(selector + "-data").textContent) + + // Compare the new option series with the previous one + if (this.previousSeries && JSON.stringify(this.previousSeries) === JSON.stringify(newOption.series)) { + // If the data is the same, skip the update + console.log('No changes in the data, skipping setOption'); + return; // Exit without updating the chart + } + + // Save the new option as the previous one for future comparisons + this.previousSeries = newOption.series; + + // Set the callback in the tooltip formatter (or any other part of the option) + var callback = (args) => { + this.pushEventTo(this.el, "request-process", { id: args.data.id, series_name: args.seriesName }); + return args.data.id; + } + + newOption.tooltip = { + formatter: callback + }; + + this.chart.setOption(newOption, notMerge) + } +} + const liveSocket = new LiveSocket(livePath, Socket, { transport: liveTran === "longpoll" ? LongPoll : WebSocket, - params: { _csrf_token: csrfToken } + params: { _csrf_token: csrfToken }, + hooks }) // Show progress bar on live navigation and form submits -topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +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()) diff --git a/assets/package-lock.json b/assets/package-lock.json index ad63067..1ad1666 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -6,6 +6,7 @@ "": { "license": "MIT", "dependencies": { + "echarts": "^5.5.1", "phoenix": "file:../deps/phoenix", "phoenix_html": "file:../deps/phoenix_html", "phoenix_live_view": "file:../deps/phoenix_live_view", @@ -44,6 +45,15 @@ "phoenix": "1.7.18" } }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, "node_modules/phoenix": { "resolved": "../deps/phoenix", "link": true @@ -60,9 +70,31 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/topbar/-/topbar-1.0.1.tgz", "integrity": "sha512-HZqQSMBiG29vcjOrqKCM9iGY/h69G5gQH7ae83ZCPz5uPmbQKwK0sMEqzVDBiu64tWHJ+kk9NApECrF+FAAvRA==" + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "dependencies": { + "tslib": "2.3.0" + } } }, "dependencies": { + "echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "requires": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, "phoenix": { "version": "file:../deps/phoenix" }, @@ -95,6 +127,19 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/topbar/-/topbar-1.0.1.tgz", "integrity": "sha512-HZqQSMBiG29vcjOrqKCM9iGY/h69G5gQH7ae83ZCPz5uPmbQKwK0sMEqzVDBiu64tWHJ+kk9NApECrF+FAAvRA==" + }, + "tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + }, + "zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "requires": { + "tslib": "2.3.0" + } } } } diff --git a/assets/package.json b/assets/package.json index 736dc84..4376c1a 100644 --- a/assets/package.json +++ b/assets/package.json @@ -6,6 +6,7 @@ "phoenix": "file:../deps/phoenix", "phoenix_html": "file:../deps/phoenix_html", "phoenix_live_view": "file:../deps/phoenix_live_view", - "topbar": "^1.0.1" + "topbar": "^1.0.1", + "echarts": "^5.5.1" } } diff --git a/guides/overview.md b/guides/overview.md index ff01fa0..d9c7249 100644 --- a/guides/overview.md +++ b/guides/overview.md @@ -18,5 +18,5 @@ and also function callers, as many other possibilities. ## Installation -See the [installation guide](installation.md) for details on installing and configuring Oban Web +See the [installation guide](installation.md) for details on installing and configuring Observer Web for your application. diff --git a/lib/observer_web/common.ex b/lib/observer_web/common.ex index 6bbcb26..40b8966 100644 --- a/lib/observer_web/common.ex +++ b/lib/observer_web/common.ex @@ -14,7 +14,7 @@ defmodule ObserverWeb.Common do ## Examples - iex> alias Deployex.Common + iex> alias ObserverWeb.Common ...> assert is_binary(Common.uuid4()) ...> assert is_binary(Common.uuid4()) ...> assert is_binary(Common.uuid4()) diff --git a/lib/observer_web/observer.ex b/lib/observer_web/observer.ex new file mode 100644 index 0000000..26d6022 --- /dev/null +++ b/lib/observer_web/observer.ex @@ -0,0 +1,282 @@ +defmodule ObserverWeb.Observer do + @moduledoc """ + This module will provide observability functions + + ## References: + * https://github.com/shinyscorpion/wobserver + """ + + require Logger + + alias ObserverWeb.Observer.Helper + alias ObserverWeb.Rpc + + @link_line_color "#CCC" + @monitor_line_color "#D1A1E5" + @monitored_by_line_color "#4DB8FF" + + @process_symbol "emptycircle" + @process_item_color "#93C5FD" + + @app_process_symbol "emptydiamond" + @app_process_item_color "#A1887F" + + @supervisor_symbol "emptyroundRect" + @supervisor_item_color "#F87171" + + @port_symbol "emptytriangle" + @port_item_color "#FBBF24" + + @reference_symbol "emptyrect" + @reference_item_color "#28A745" + + @type t :: %__MODULE__{ + id: pid() | port() | reference() | nil, + children: list(), + name: String.t(), + symbol: String.t(), + lineStyle: map(), + itemStyle: map() + } + + @derive Jason.Encoder + + defstruct id: nil, + children: [], + name: "", + symbol: @process_symbol, + lineStyle: %{color: @link_line_color}, + itemStyle: %{color: @process_item_color} + + @doc """ + Lists all running applications. + """ + @spec list(node :: atom()) :: list({atom, String.t(), String.t()}) + def list(node \\ Node.self()) do + Rpc.call(node, :application_controller, :which_applications, [], :infinity) + |> Enum.filter(&alive?(node, &1)) + |> Enum.map(&structure_application/1) + end + + @doc """ + Retreives information about the application and its respective linked processes, ports and references. + """ + @spec info(node :: atom(), app :: atom) :: map + def info(node \\ Node.self(), app \\ :kernel) do + app_pid = Rpc.call(node, :application_controller, :get_master, [app], :infinity) + + children = + node + |> Rpc.call(:application_master, :get_child, [app_pid], :infinity) + |> structure_id(app_pid) + + new(%{ + id: app_pid, + children: children, + symbol: @app_process_symbol, + itemStyle: %{color: @app_process_item_color} + }) + end + + ### ========================================================================== + ### Private functions + ### ========================================================================== + + defp alive?(node, {app, _, _}) do + node + |> Rpc.call(:application_controller, :get_master, [app], :infinity) + |> is_pid + catch + # coveralls-ignore-start + _, _ -> + false + # coveralls-ignore-stop + end + + defp structure_application({name, description, version}) do + %{ + name: name, + description: to_string(description), + version: to_string(version) + } + end + + defp structure_id({pid, name}, parent) do + {_, dictionary} = Rpc.pinfo(pid, :dictionary) + + case Keyword.get(dictionary, :"$ancestors") do + [ancestor_parent] -> + child = structure_id({name, pid, :supervisor, []}, ancestor_parent) + + [ + new(%{ + id: ancestor_parent, + children: [child], + symbol: @app_process_symbol, + itemStyle: %{color: @app_process_item_color} + }) + ] + + _ -> + # coveralls-ignore-start + child = structure_id({name, pid, :supervisor, []}, parent) + [child] + # coveralls-ignore-stop + end + end + + defp structure_id({_, :undefined, _, _}, _parent), do: nil + + defp structure_id({_, pid, :supervisor, _}, parent) do + {:links, links} = Rpc.pinfo(pid, :links) + + links = links -- [parent] + + children = + pid + |> :supervisor.which_children() + |> Kernel.++(Enum.filter(links, fn link -> is_port(link) end)) + |> Helper.parallel_map(&structure_id(&1, pid)) + |> Enum.filter(&(&1 != nil)) + + new(%{ + id: pid, + children: children, + symbol: @supervisor_symbol, + itemStyle: %{color: @supervisor_item_color} + }) + end + + defp structure_id({_, pid, :worker, _}, parent) do + {:links, links} = Rpc.pinfo(pid, :links) + {:monitored_by, monitored_by_pids} = Rpc.pinfo(pid, :monitored_by) + {:monitors, monitors} = Rpc.pinfo(pid, :monitors) + + links = links -- [parent] + + children = Enum.map(links, &structure_links(&1)) + monitored_by_pids = Enum.map(monitored_by_pids, &monitored_by(&1)) + monitors = Enum.map(monitors, &monitor(&1)) + + new(%{ + id: pid, + children: children ++ monitored_by_pids ++ monitors + }) + end + + # coveralls-ignore-start + defp structure_id(id, _parent) when is_port(id) do + new(%{id: id, symbol: @port_symbol}) + end + + defp structure_id(id, _parent) when is_reference(id) do + new(%{id: id, symbol: @reference_symbol}) + end + + defp structure_id(id, _parent) do + new(%{id: id}) + end + + # coveralls-ignore-stop + + # Check https://www.erlang.org/docs/26/man/erlang#process_info-2 + # coveralls-ignore-start + defp monitored_by(reference) when is_reference(reference) do + new(%{ + id: reference, + symbol: @reference_symbol, + itemStyle: %{color: @reference_item_color}, + lineStyle: %{color: @monitored_by_line_color} + }) + end + + # coveralls-ignore-stop + + defp monitored_by(port) when is_port(port) do + new(%{ + id: port, + symbol: @port_symbol, + itemStyle: %{color: @port_item_color}, + lineStyle: %{color: @monitored_by_line_color} + }) + end + + defp monitored_by(pid) when is_pid(pid) do + new(%{ + id: pid, + lineStyle: %{color: @monitored_by_line_color} + }) + end + + # Check https://www.erlang.org/docs/26/man/erlang#process_info-2 + # coveralls-ignore-start + defp monitor({:port, port}) do + new(%{ + id: port, + lineStyle: %{color: @monitor_line_color} + }) + end + + # coveralls-ignore-stop + + defp monitor({:process, pid}) do + new(%{ + id: pid, + lineStyle: %{color: @monitor_line_color} + }) + end + + defp structure_links(port) when is_port(port) do + new(%{ + id: port, + symbol: @port_symbol, + itemStyle: %{color: @port_item_color} + }) + end + + defp structure_links(pid) when is_pid(pid) do + new(%{id: pid}) + end + + # coveralls-ignore-start + defp structure_links(reference) when is_reference(reference) do + new(%{id: reference}) + end + + # coveralls-ignore-stop + + @spec new(map()) :: struct() + def new(%{id: id} = attrs) when is_port(id) or is_pid(id) or is_reference(id) do + name = name(id) + struct(__MODULE__, Map.put(attrs, :name, name)) + end + + # coveralls-ignore-start + def new(%{id: id} = attrs) do + name = "#{inspect(id)}" + Logger.warning("Entity ID not mapped: #{name}") + + struct( + __MODULE__, + attrs + |> Map.put(:name, name) + |> Map.put(:id, nil) + ) + end + + # coveralls-ignore-stop + + defp name(pid) when is_pid(pid) do + case Rpc.pinfo(pid, :registered_name) do + {_, registered_name} -> to_string(registered_name) |> String.trim_leading("Elixir.") + _ -> pid |> inspect |> String.trim_leading("#PID") + end + end + + defp name(port) when is_port(port), do: port |> inspect |> String.trim_leading("#Port") + # coveralls-ignore-start + defp name(reference) when is_reference(reference), + do: reference |> inspect |> String.trim_leading("#Reference") + + # coveralls-ignore-stop +end diff --git a/lib/observer_web/observer/helper.ex b/lib/observer_web/observer/helper.ex new file mode 100644 index 0000000..c053f84 --- /dev/null +++ b/lib/observer_web/observer/helper.ex @@ -0,0 +1,88 @@ +defmodule ObserverWeb.Observer.Helper do + @moduledoc """ + Helper functions and JSON encoders. + + ## References: + * https://github.com/shinyscorpion/wobserver + """ + + alias Jason.Encoder + + defimpl Encoder, for: PID do + @doc """ + JSON encodes a `PID`. + + Uses `inspect/1` to turn the `pid` into a String and passes the `options` to `Encoder.BitString.encode/1`. + """ + @spec encode(pid :: pid, options :: Jason.Encode.opts()) :: any() + def encode(pid, options) do + pid + |> inspect + |> Encoder.BitString.encode(options) + end + end + + defimpl Encoder, for: Port do + @doc """ + JSON encodes a `Port`. + + Uses `inspect/1` to turn the `port` into a String and passes the `options` to `Encoder.BitString.encode/1`. + """ + @spec encode(port :: port, options :: Jason.Encode.opts()) :: any() + def encode(port, options) do + port + |> inspect + |> Encoder.BitString.encode(options) + end + end + + defimpl Encoder, for: Reference do + @doc """ + JSON encodes a `Reference`. + + Uses `inspect/1` to turn the `reference` into a String and passes the `options` to `Encoder.BitString.encode/1`. + """ + @spec encode(reference :: reference, options :: Jason.Encode.opts()) :: any() + def encode(reference, options) do + reference + |> inspect + |> Encoder.BitString.encode(options) + end + end + + @doc """ + Formats function information as readable string. + + Only name will be return if only `name` is given. + + Example: + ```bash + iex> format_function {Logger, :log, 2} + "Logger.log/2" + ``` + ```bash + iex> format_function :format_function + "format_function" + ``` + ```bash + iex> format_function nil + nil + ``` + """ + @spec format_function(nil | {atom, atom, integer} | atom) :: String.t() | nil + def format_function(nil), do: nil + def format_function({module, name, arity}), do: "#{module}.#{name}/#{arity}" + def format_function(name), do: "#{name}" + + @doc """ + Parallel map implemented with `Task`. + + Maps the `function` over the `enum` using `Task.async/1` and `Task.await/1`. + """ + @spec parallel_map(enum :: list, function :: fun) :: list + def parallel_map(enum, function) do + enum + |> Enum.map(&Task.async(fn -> function.(&1) end)) + |> Enum.map(&Task.await/1) + end +end diff --git a/lib/observer_web/observer/port.ex b/lib/observer_web/observer/port.ex new file mode 100644 index 0000000..1938a72 --- /dev/null +++ b/lib/observer_web/observer/port.ex @@ -0,0 +1,39 @@ +defmodule ObserverWeb.Observer.Port do + @moduledoc """ + Retrieve Port information + """ + + alias ObserverWeb.Rpc + + @doc """ + Return port information + + ## Examples + + iex> alias ObserverWeb.Observer.Port + ...> [h | _] = :erlang.ports() + ...> assert %{connected: _, id: _, name: _, os_pid: _} = Port.info(h) + ...> assert :undefined = Port.info(nil) + ...> assert :undefined = Port.info("") + """ + @spec info(atom(), port()) :: + :undefined | %{connected: any(), id: any(), name: any(), os_pid: any()} + def info(node \\ Node.self(), port) + + def info(node, port) when is_port(port) do + case Rpc.call(node, :erlang, :port_info, [port], :infinity) do + data when is_list(data) -> + %{ + name: Keyword.get(data, :name, 0), + id: Keyword.get(data, :id, 0), + connected: Keyword.get(data, :connected, 0), + os_pid: Keyword.get(data, :os_pid, 0) + } + + _ -> + :undefined + end + end + + def info(_node, _port), do: :undefined +end diff --git a/lib/observer_web/observer/process.ex b/lib/observer_web/observer/process.ex new file mode 100644 index 0000000..a2bf889 --- /dev/null +++ b/lib/observer_web/observer/process.ex @@ -0,0 +1,133 @@ +defmodule ObserverWeb.Observer.Process do + @moduledoc """ + Retrieve process links and information + + ## References: + * https://github.com/shinyscorpion/wobserver + """ + + alias ObserverWeb.Observer.Helper + alias ObserverWeb.Rpc + + @process_full [ + :registered_name, + :priority, + :trap_exit, + :initial_call, + :current_function, + :message_queue_len, + :error_handler, + :group_leader, + :links, + :memory, + :total_heap_size, + :heap_size, + :stack_size, + :min_heap_size, + :garbage_collection, + :status, + :dictionary, + :monitored_by, + :monitors + ] + + @doc """ + Creates a complete overview of process stats based on the given `pid`. + """ + @spec info(pid :: pid()) :: :undefined | map + def info(pid) do + process_info(pid, @process_full, &structure_full/2) + end + + ### ========================================================================== + ### Private functions + ### ========================================================================== + defp process_info(pid, information, structurer) do + case Rpc.pinfo(pid, information) do + :undefined -> :undefined + data -> structurer.(data, pid) + end + end + + defp process_status_module(pid) do + {:status, ^pid, {:module, class}, _} = :sys.get_status(pid, 100) + class + catch + # coveralls-ignore-start + _, _ -> + :unknown + # coveralls-ignore-stop + end + + defp state(pid) do + :sys.get_state(pid, 100) + catch + _, _ -> :unknown + end + + defp initial_call(data) do + dictionary_init = + data + |> Keyword.get(:dictionary, []) + |> Keyword.get(:"$initial_call", nil) + + case dictionary_init do + nil -> + Keyword.get(data, :initial_call, nil) + + call -> + call + end + end + + # Structurers + + defp structure_full(data, pid) do + gc = Keyword.get(data, :garbage_collection, []) + dictionary = Keyword.get(data, :dictionary) + + %{ + pid: pid, + registered_name: Keyword.get(data, :registered_name, nil), + priority: Keyword.get(data, :priority, :normal), + trap_exit: Keyword.get(data, :trap_exit, false), + message_queue_len: Keyword.get(data, :message_queue_len, 0), + error_handler: Keyword.get(data, :error_handler, :none), + relations: %{ + group_leader: Keyword.get(data, :group_leader, nil), + ancestors: Keyword.get(dictionary, :"$ancestors", []), + links: Keyword.get(data, :links, nil), + monitored_by: Keyword.get(data, :monitored_by, nil), + monitors: Keyword.get(data, :monitors, nil) + }, + memory: %{ + total: Keyword.get(data, :memory, 0), + stack_and_heap: Keyword.get(data, :total_heap_size, 0), + heap_size: Keyword.get(data, :heap_size, 0), + stack_size: Keyword.get(data, :stack_size, 0), + gc_min_heap_size: Keyword.get(gc, :min_heap_size, 0), + gc_full_sweep_after: Keyword.get(gc, :fullsweep_after, 0) + }, + meta: structure_meta(data, pid), + state: to_string(:io_lib.format("~tp", [state(pid)])) + } + end + + defp structure_meta(data, pid) do + init = initial_call(data) + + class = + case init do + {:supervisor, _, _} -> :supervisor + {:application_master, _, _} -> :application + _ -> process_status_module(pid) + end + + %{ + init: Helper.format_function(init), + current: Helper.format_function(Keyword.get(data, :current_function)), + status: Keyword.get(data, :status), + class: class + } + end +end diff --git a/lib/observer_web/rpc.ex b/lib/observer_web/rpc.ex index 3ca0034..5ff91f7 100644 --- a/lib/observer_web/rpc.ex +++ b/lib/observer_web/rpc.ex @@ -24,6 +24,18 @@ defmodule ObserverWeb.Rpc do def call(node, module, function, args, timeout), do: default().call(node, module, function, args, timeout) + @doc """ + Call :rpc.pinfo to request process information (local or remote pid) + + Location transparent version of the BIF :erlang.process_info/2 + + https://www.erlang.org/doc/apps/kernel/rpc.html#pinfo/1 + """ + @impl true + @spec pinfo(pid :: pid, information :: list | atom()) :: any() | :undefined + def pinfo(pid, information), + do: default().pinfo(pid, information) + ### ========================================================================== ### Private functions ### ========================================================================== diff --git a/lib/observer_web/rpc/adapter.ex b/lib/observer_web/rpc/adapter.ex index 7c5aada..1f24a47 100644 --- a/lib/observer_web/rpc/adapter.ex +++ b/lib/observer_web/rpc/adapter.ex @@ -10,4 +10,6 @@ defmodule ObserverWeb.Rpc.Adapter do args :: list, timeout :: 0..4_294_967_295 | :infinity ) :: any() | {:badrpc, any()} + + @callback pinfo(pid :: pid, information :: list | atom()) :: any() | :undefined end diff --git a/lib/observer_web/rpc/local.ex b/lib/observer_web/rpc/local.ex index 27c2341..ecba854 100644 --- a/lib/observer_web/rpc/local.ex +++ b/lib/observer_web/rpc/local.ex @@ -12,4 +12,9 @@ defmodule ObserverWeb.Rpc.Local do def call(node, module, function, args, timeout) do :rpc.call(node, module, function, args, timeout) end + + @impl true + def pinfo(pid, information) do + :rpc.pinfo(pid, information) + end end diff --git a/lib/observer_web/tracer/server.ex b/lib/observer_web/tracer/server.ex index c2e1a68..f7b6eec 100644 --- a/lib/observer_web/tracer/server.ex +++ b/lib/observer_web/tracer/server.ex @@ -74,7 +74,7 @@ defmodule ObserverWeb.Server do Logger.info("New Trace Session: #{session_id} functions: #{inspect(functions_by_node)}") tracer_pid = self() - # The local node (deployex) is always present in the trace list of nodes. + # The local node is always present in the trace list of nodes. # The following list will indicate to the trace handler whether the node # should be included or filtered out. monitored_nodes = Map.keys(functions_by_node) diff --git a/lib/web/components/attention.ex b/lib/web/components/attention.ex new file mode 100644 index 0000000..e73f888 --- /dev/null +++ b/lib/web/components/attention.ex @@ -0,0 +1,44 @@ +defmodule Observer.Web.Components.Attention do + @moduledoc false + use Phoenix.Component + + attr :id, :string, required: true + attr :message, :string, required: true + attr :title, :string, required: true + attr :class, :string, default: "" + slot :inner_form, doc: "the slot for adding form to input data" + slot :inner_button, doc: "the slot for adding form to input data" + + def content(assigns) do + ~H""" +
+ + {render_slot(@inner_button)} +
+ """ + end +end diff --git a/lib/web/components/core.ex b/lib/web/components/core.ex index 487895e..f298adf 100644 --- a/lib/web/components/core.ex +++ b/lib/web/components/core.ex @@ -24,14 +24,12 @@ defmodule Observer.Web.Components.Core do attr :row_item, :any, default: &Function.identity/1, - doc: "the function for mapping each row before calling the :col and :action slots" + doc: "the function for mapping each row before calling the :col slots" slot :col, required: true do attr :label, :string end - slot :action, doc: "the slot for showing user actions in the last table column" - def table_tracing(assigns) do assigns = with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do @@ -90,6 +88,82 @@ defmodule Observer.Web.Components.Core do """ end + @doc ~S""" + Renders a table with process styling. + + ## Examples + + <.table id="users" rows={@users}> + <:col :let={user} label="id"><%= user.id %> + <:col :let={user} label="username"><%= user.username %> + + """ + attr :id, :string, required: true + attr :title, :string, required: true + attr :rows, :list, required: true + attr :row_id, :any, default: nil, doc: "the function for generating the row id" + attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" + attr :transition, :boolean, default: false + + attr :row_item, :any, + default: &Function.identity/1, + doc: "the function for mapping each row before calling the :col slots" + + slot :col, required: true do + attr :label, :string + end + + def table_process(assigns) do + assigns = + with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do + assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) + end + + ~H""" +
+
+ +
+ {@title} +
+ + + + + +
+
+ + + {render_slot(col, @row_item.(row))} + +
+
+
+
+ """ + end + @doc """ Renders an input with label and error messages. diff --git a/lib/web/components/layouts.ex b/lib/web/components/layouts.ex index af73039..c24a973 100644 --- a/lib/web/components/layouts.ex +++ b/lib/web/components/layouts.ex @@ -73,7 +73,15 @@ defmodule Observer.Web.Layouts do """ end diff --git a/lib/web/components/layouts/live.html.heex b/lib/web/components/layouts/live.html.heex index c3c57a4..604fc48 100644 --- a/lib/web/components/layouts/live.html.heex +++ b/lib/web/components/layouts/live.html.heex @@ -9,5 +9,6 @@ <.nav socket={@socket} page={@page.name} /> - {@inner_content} <.footer /> + {@inner_content} + <.footer /> diff --git a/lib/web/components/multi_select.ex b/lib/web/components/multi_select.ex new file mode 100644 index 0000000..5069da5 --- /dev/null +++ b/lib/web/components/multi_select.ex @@ -0,0 +1,195 @@ +defmodule Observer.Web.Components.MultiSelect do + @moduledoc """ + Multi select box + + References: + * https://www.creative-tim.com/twcomponents/component/multi-select + + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + + attr :id, :string, required: true + attr :selected_text, :string, required: true + attr :selected, :list, required: true + attr :unselected, :list, required: true + attr :show_options, :boolean, required: true + + def content(assigns) do + ~H""" +
+
+
+
+
+
+
+ {@selected_text} +
+ + <%= for item <- @selected do %> + <%= for key <- item.keys do %> +
+
+ {"#{item.name}:#{key}"} +
+ +
+ <% end %> + <% end %> +
+ +
+
+
+ +
+
+ +
+
+ <%= for item <- @unselected do %> +
+
+
{item.name}:
+
+ +
+ <%= for key <- item.keys do %> + + <% end %> +
+
+ <% end %> +
+
+
+
+
+
+ """ + end + + def border_item_color("services"), do: "border-teal-300" + def border_item_color("apps"), do: "border-blue-400" + # coveralls-ignore-start + def border_item_color(_), do: "border-gray-300" + # coveralls-ignore-stop + + def bg_item_color("services"), do: "bg-teal-50" + def bg_item_color("apps"), do: "bg-blue-50" + # coveralls-ignore-start + def bg_item_color(_), do: "bg-gray-50" + # coveralls-ignore-stop + + def text_item_color("services"), do: "text-teal-700" + def text_item_color("apps"), do: "text-blue-700" + # coveralls-ignore-start + def text_item_color(_), do: "text-teal-700" + # coveralls-ignore-stop +end diff --git a/lib/web/components/multi_select_list.ex b/lib/web/components/multi_select_list.ex index 1b33b9f..1c2d81e 100644 --- a/lib/web/components/multi_select_list.ex +++ b/lib/web/components/multi_select_list.ex @@ -36,7 +36,7 @@ defmodule Observer.Web.Components.MultiSelectList do bg_item_color(item.name) ]}>
{"#{item.name}:#{key}"} @@ -124,10 +124,10 @@ defmodule Observer.Web.Components.MultiSelectList do
<%= if item[:info] do %> -
{item.name} +
{item.name} {item.info}:
<% else %> -
{item.name}:
+
{item.name}:
<% end %>
@@ -145,7 +145,7 @@ defmodule Observer.Web.Components.MultiSelectList do phx-value-key={key} phx-value-item={item.name} > -
+
{key}
diff --git a/lib/web/live/index.ex b/lib/web/live/index.ex index f04a215..89f2563 100644 --- a/lib/web/live/index.ex +++ b/lib/web/live/index.ex @@ -9,8 +9,8 @@ defmodule Observer.Web.IndexLive do use Observer.Web, :live_view - alias Observer.Web.ObserverPage - alias Observer.Web.TracingPage + alias Observer.Web.Observer.Page, as: ObserverPage + alias Observer.Web.Tracing.Page, as: TracingPage @impl Phoenix.LiveView def mount(params, session, socket) do diff --git a/lib/web/pages/observer/legend.ex b/lib/web/pages/observer/legend.ex new file mode 100644 index 0000000..f34ec27 --- /dev/null +++ b/lib/web/pages/observer/legend.ex @@ -0,0 +1,141 @@ +defmodule Observer.Web.Observer.Legend do + @moduledoc false + use Phoenix.Component + + def content(assigns) do + ~H""" +
+

Legend

+
+ Process (App) +
+ + + +
+ + Supervisor +
+ + + +
+ + Process (Worker) +
+ + + +
+ + Port +
+ + + +
+ + Reference +
+ + + +
+ + Link +
+ + + +
+ + Monitor +
+ + + +
+ + Monitored by +
+ + + +
+
+
+ """ + end +end diff --git a/lib/web/pages/observer/page.ex b/lib/web/pages/observer/page.ex new file mode 100644 index 0000000..ad0de0a --- /dev/null +++ b/lib/web/pages/observer/page.ex @@ -0,0 +1,574 @@ +defmodule Observer.Web.Observer.Page do + @moduledoc """ + This is the live component responsible for handling the Observer page + """ + + @behaviour Observer.Web.Page + + use Observer.Web, :live_component + + require Logger + + alias Observer.Web.Components.Attention + alias Observer.Web.Components.MultiSelect + alias Observer.Web.Observer.Legend + alias Observer.Web.Observer.Port + alias Observer.Web.Observer.Process + alias Observer.Web.Page + alias ObserverWeb.Observer, as: ObserverSystem + + @tooltip_debouncing 50 + + @impl Phoenix.LiveComponent + def render(assigns) do + unselected_services_keys = + assigns.node_info.services_keys -- assigns.node_info.selected_services_keys + + unselected_apps_keys = + assigns.node_info.apps_keys -- assigns.node_info.selected_apps_keys + + # credo:disable-for-lines:9 + adjust_series_position = fn series -> + case Enum.count(series) do + n when n > 0 -> + step = 100.0 / n + + {series, _top, _bottom} = + Enum.reduce(series, {[], 0.0, 100.0}, fn serie, {acc, top, bottom} -> + bottom = bottom - step + + new_serie = %{ + serie + | top: :erlang.float_to_binary(top, [{:decimals, 0}]) <> "%", + bottom: :erlang.float_to_binary(bottom, [{:decimals, 0}]) <> "%" + } + + {acc ++ [new_serie], top + step, bottom} + end) + + series + + _ -> + series + end + end + + initial_tree_depth = assigns.form.params["initial_tree_depth"] + + chart_tree_data = + assigns.observer_data + |> Enum.reduce([], fn {key, %{"data" => info}}, acc -> + acc ++ [series(key, info, initial_tree_depth)] + end) + |> adjust_series_position.() + |> flare_chart_data() + + attention_msg = ~H""" + The Observer Web visualizes process relationships, supervisor trees, and more. + Hover over an element to view detailed information about the process or port. + You can also configure the initial tree depth, or set the depth to -1 to expand all trees. + """ + + assigns = + assigns + |> assign(chart_tree_data: chart_tree_data) + |> assign(unselected_services_keys: unselected_services_keys) + |> assign(unselected_apps_keys: unselected_apps_keys) + |> assign(attention_msg: attention_msg) + + ~H""" +
+ + <:inner_form> + <.form + for={@form} + id="observer-update-form" + class="flex ml-2 mr-2 text-xs text-center whitespace-nowrap gap-5" + phx-change="form-update" + > + + + + <:inner_button> + + + + +
+ +
+
+ <%= if @observer_data != %{} do %> + + <% end %> +
+
+
+ +
+
+ <%= if @current_selected_id.type == "pid" do %> + + <% else %> + + <% end %> +
+
+ """ + end + + @impl Page + def handle_mount(socket) do + socket + |> assign(:node_info, update_node_info()) + |> assign(:node_data, %{}) + |> assign(:observer_data, %{}) + |> assign(:current_selected_id, reset_current_selected_id()) + |> assign(form: to_form(default_form_options())) + |> assign(:show_observer_options, false) + end + + @impl Phoenix.LiveComponent + def handle_event(message, value, socket) do + # Redirect message to the parent process + send(self(), {message, value}) + {:noreply, socket} + end + + @impl Page + def handle_params(params, _uri, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Live Observer") + end + + @impl Page + def handle_parent_event("toggle-options", _value, socket) do + show_observer_options = !socket.assigns.show_observer_options + + {:noreply, socket |> assign(:show_observer_options, show_observer_options)} + end + + def handle_parent_event("form-update", %{"initial_tree_depth" => depth}, socket) do + {:noreply, assign(socket, form: to_form(%{"initial_tree_depth" => depth}))} + end + + def handle_parent_event( + "observer-apps-update", + _data, + %{assigns: %{observer_data: observer_data}} = socket + ) do + new_observer_data = + Enum.reduce(observer_data, %{}, fn {key, data}, acc -> + [service, app] = String.split(key, "::") + + new_info = + ObserverSystem.info(String.to_existing_atom(service), String.to_existing_atom(app)) + + Map.put(acc, key, %{data | "data" => new_info}) + end) + + {:noreply, + socket + |> assign(:observer_data, new_observer_data)} + end + + def handle_parent_event( + "multi-select-remove-item", + %{"item" => "services", "key" => service_key}, + %{assigns: %{node_info: node_info}} = socket + ) do + node_info = + update_node_info( + node_info.selected_services_keys -- [service_key], + node_info.selected_apps_keys + ) + + socket = + Enum.reduce(node_info.selected_apps_keys, socket, fn app_key, acc -> + data_key = data_key(service_key, app_key) + + update_observer_data(acc, data_key, nil) + end) + + {:noreply, + socket + |> assign(:node_info, node_info) + |> assign(:current_selected_id, reset_current_selected_id())} + end + + def handle_parent_event( + "multi-select-remove-item", + %{"item" => "apps", "key" => app_key}, + %{assigns: %{node_info: node_info}} = socket + ) do + node_info = + update_node_info( + node_info.selected_services_keys, + node_info.selected_apps_keys -- [app_key] + ) + + socket = + Enum.reduce(node_info.selected_services_keys, socket, fn service_key, acc -> + data_key = data_key(service_key, app_key) + + update_observer_data(acc, data_key, nil) + end) + + {:noreply, + socket + |> assign(:node_info, node_info) + |> assign(:current_selected_id, reset_current_selected_id())} + end + + def handle_parent_event( + "multi-select-add-item", + %{"item" => "services", "key" => service_key}, + %{assigns: %{node_info: node_info}} = socket + ) do + node_info = + update_node_info( + node_info.selected_services_keys ++ [service_key], + node_info.selected_apps_keys + ) + + socket = + Enum.reduce(node_info.selected_apps_keys, socket, fn app_key, acc -> + node_service = Enum.find(node_info.node, &(&1.service == service_key)) + + data_key = data_key(service_key, app_key) + + if app_key in node_service.apps_keys do + info = + ObserverSystem.info( + String.to_existing_atom(service_key), + String.to_existing_atom(app_key) + ) + + update_observer_data(acc, data_key, %{"transition" => false, "data" => info}) + else + # coveralls-ignore-start + update_observer_data(acc, data_key, nil) + # coveralls-ignore-stop + end + end) + + {:noreply, + socket + |> assign(:node_info, node_info) + |> assign(:current_selected_id, reset_current_selected_id())} + end + + def handle_parent_event( + "multi-select-add-item", + %{"item" => "apps", "key" => app_key}, + %{assigns: %{node_info: node_info}} = socket + ) do + node_info = + update_node_info( + node_info.selected_services_keys, + node_info.selected_apps_keys ++ [app_key] + ) + + socket = + Enum.reduce(node_info.selected_services_keys, socket, fn service_key, acc -> + node_service = Enum.find(node_info.node, &(&1.service == service_key)) + + data_key = data_key(service_key, app_key) + + if app_key in node_service.apps_keys do + info = + ObserverSystem.info( + String.to_existing_atom(service_key), + String.to_existing_atom(app_key) + ) + + update_observer_data(acc, data_key, %{"transition" => false, "data" => info}) + else + # coveralls-ignore-start + update_observer_data(acc, data_key, nil) + # coveralls-ignore-stop + end + end) + + {:noreply, + socket + |> assign(:node_info, node_info) + |> assign(:current_selected_id, reset_current_selected_id())} + end + + @impl Page + def handle_info( + {"request-process", %{"id" => request_id, "series_name" => series_name}}, + %{assigns: %{current_selected_id: %{id_string: id_string, debouncing: debouncing}}} = + socket + ) + when id_string != request_id or debouncing < 0 do + pid? = String.contains?(request_id, "#PID<") + port? = String.contains?(request_id, "#Port<") + + current_selected_id = + cond do + pid? -> + pid = + request_id + |> String.trim_leading("#PID") + |> String.to_charlist() + |> :erlang.list_to_pid() + + Logger.info("Retrieving process info for pid: #{request_id}") + + %{ + info: ObserverSystem.Process.info(pid), + id_string: request_id, + type: "pid", + debouncing: @tooltip_debouncing + } + + port? -> + [service, _app] = String.split(series_name, "::") + + port = + request_id + |> String.to_charlist() + |> :erlang.list_to_port() + + node = String.to_existing_atom(service) + + Logger.info("Retrieving port info for port: #{request_id}") + + %{ + info: ObserverSystem.Port.info(node, port), + id_string: request_id, + type: "port", + debouncing: @tooltip_debouncing + } + + true -> + reset_current_selected_id(request_id) + end + + {:noreply, assign(socket, :current_selected_id, current_selected_id)} + end + + # The debouncing added here will reduce the number of Process.info requests since + # tooltips are high demand signals. + def handle_info( + {"request-process", _data}, + %{assigns: %{current_selected_id: current_selected_id}} = socket + ) do + {:noreply, + assign(socket, :current_selected_id, %{ + current_selected_id + | debouncing: current_selected_id.debouncing - 1 + })} + end + + def handle_info({:nodeup, _node}, %{assigns: %{node_info: node_info}} = socket) do + node_info = + update_node_info( + node_info.selected_services_keys, + node_info.selected_apps_keys + ) + + {:noreply, assign(socket, :node_info, node_info)} + end + + def handle_info({:nodedown, node}, %{assigns: %{node_info: node_info}} = socket) do + service_key = node |> to_string + + node_info = + update_node_info( + node_info.selected_services_keys -- [service_key], + node_info.selected_apps_keys + ) + + socket = + Enum.reduce(node_info.selected_apps_keys, socket, fn app_key, acc -> + data_key = data_key(service_key, app_key) + + update_observer_data(acc, data_key, nil) + end) + + {:noreply, + socket + |> assign(:node_info, node_info) + |> assign(:current_selected_id, reset_current_selected_id())} + end + + defp data_key(service, apps), do: "#{service}::#{apps}" + + defp update_observer_data( + %{assigns: %{observer_data: observer_data}} = socket, + data_key, + nil + ) do + assign(socket, :observer_data, Map.delete(observer_data, data_key)) + end + + defp update_observer_data( + %{assigns: %{observer_data: observer_data}} = socket, + data_key, + attributes + ) do + updated_data = + observer_data + |> Map.get(data_key, %{}) + |> Map.merge(attributes) + + assign(socket, :observer_data, Map.put(observer_data, data_key, updated_data)) + end + + defp default_form_options, do: %{"initial_tree_depth" => "3"} + + defp node_info_new do + %{ + services_keys: [], + apps_keys: [], + selected_services_keys: [], + selected_apps_keys: [], + node: [] + } + end + + defp update_node_info, do: update_node_info([], []) + + defp update_node_info(selected_services_keys, selected_apps_keys) do + initial_map = + %{ + node_info_new() + | selected_services_keys: selected_services_keys, + selected_apps_keys: selected_apps_keys + } + + Enum.reduce(Node.list() ++ [Node.self()], initial_map, fn instance_node, + %{ + services_keys: services_keys, + apps_keys: apps_keys, + node: node + } = acc -> + service = instance_node |> to_string + [name, _hostname] = String.split(service, "@") + services_keys = (services_keys ++ [service]) |> Enum.sort() + + instance_app_keys = ObserverSystem.list(instance_node) |> Enum.map(&(&1.name |> to_string)) + apps_keys = (apps_keys ++ instance_app_keys) |> Enum.sort() |> Enum.uniq() + + node = + if service in selected_services_keys do + [ + %{ + name: name, + apps_keys: instance_app_keys, + service: service + } + | node + ] + else + node + end + + %{acc | services_keys: services_keys, apps_keys: apps_keys, node: node} + end) + end + + defp reset_current_selected_id(id_string \\ nil), + do: %{info: nil, id_string: id_string, type: nil, debouncing: @tooltip_debouncing} + + defp flare_chart_data(series) do + %{ + tooltip: %{ + trigger: "item", + triggerOn: "mousemove" + }, + notMerge: true, + legend: [ + %{ + top: "5%", + left: "0%", + orient: "vertical", + borderColor: "#c23531" + } + ], + series: series + } + end + + defp series(name, data, initial_tree_depth) do + %{ + type: "tree", + name: name, + data: [data], + top: "0%", + left: "30%", + bottom: "74%", + right: "20%", + symbolSize: 10, + itemStyle: %{color: "#93C5FD"}, + edgeShape: "curve", + edgeForkPosition: "63%", + initialTreeDepth: initial_tree_depth, + lineStyle: %{ + width: 2 + }, + axisPointer: [ + %{ + show: "auto" + } + ], + label: %{ + backgroundColor: "#fff", + position: "top", + verticalAlign: "middle", + align: "center" + }, + leaves: %{ + label: %{ + position: "right", + verticalAlign: "middle", + align: "left" + } + }, + emphasis: %{ + focus: "descendant" + }, + roam: "zoom", + symbol: "emptyCircle", + expandAndCollapse: true, + animationDuration: 550, + animationDurationUpdate: 750 + } + end +end diff --git a/lib/web/pages/observer/port.ex b/lib/web/pages/observer/port.ex new file mode 100644 index 0000000..a628669 --- /dev/null +++ b/lib/web/pages/observer/port.ex @@ -0,0 +1,59 @@ +defmodule Observer.Web.Observer.Port do + @moduledoc false + + use Observer.Web, :html + use Phoenix.Component + + alias Observer.Web.Components.Attention + + attr :info, :map, required: true + attr :id, :map, required: true + + def content(assigns) do + info = assigns.info + + port_overview = + if is_map(info) do + [ + %{name: "Id", value: "#{info.id}"}, + %{name: "Name", value: "#{info.name}"}, + %{name: "Os Pid", value: "#{info.os_pid}"}, + %{name: "Connected", value: "#{inspect(info.connected)}"} + ] + else + nil + end + + assigns = + assigns + |> assign(port_overview: port_overview) + + ~H""" +
+ <%= cond do %> + <% @info == nil -> %> + <% @info == :undefined -> %> + + <% true -> %> +
+
+ + <:col :let={item}> + {item.name} + + <:col :let={item}> + {item.value} + + +
+
+ <% end %> +
+ """ + end +end diff --git a/lib/web/pages/observer/process.ex b/lib/web/pages/observer/process.ex new file mode 100644 index 0000000..0afe3da --- /dev/null +++ b/lib/web/pages/observer/process.ex @@ -0,0 +1,112 @@ +defmodule Observer.Web.Observer.Process do + @moduledoc false + + use Observer.Web, :html + use Phoenix.Component + + alias Observer.Web.Components.Attention + + attr :info, :map, required: true + attr :id, :map, required: true + + def content(assigns) do + info = assigns.info + + {process_overview, process_memory} = + if is_map(info) do + process_overview = + [ + %{name: "Id", value: "#{inspect(info.pid)}"}, + %{name: "Registered name", value: "#{info.registered_name}"}, + %{name: "Status", value: "#{info.meta.status}"}, + %{name: "Class", value: "#{info.meta.class}"}, + %{name: "Message Queue Length", value: "#{info.message_queue_len}"}, + %{name: "Group Leader", value: "#{inspect(info.relations.group_leader)}"}, + %{name: "Trap exit", value: "#{info.trap_exit}"} + ] + + process_memory = + [ + %{name: "Total", value: "#{info.memory.total}"}, + %{name: "Heap Size", value: "#{info.memory.heap_size}"}, + %{name: "Stack Size", value: "#{info.memory.stack_size}"}, + %{name: "GC Min Heap Size", value: "#{info.memory.gc_min_heap_size}"}, + %{name: "GC FullSweep After", value: "#{info.memory.gc_full_sweep_after}"} + ] + + {process_overview, process_memory} + else + {nil, nil} + end + + assigns = + assigns + |> assign(process_overview: process_overview) + |> assign(process_memory: process_memory) + + ~H""" +
+ <%= cond do %> + <% @info == nil -> %> + <% @info == :undefined -> %> + + <% true -> %> +
+
+ + <:col :let={item}> + {item.name} + + <:col :let={item}> + {item.value} + + + + + <:col :let={item}> + {item.name} + + <:col :let={item}> + {item.value} + + + <.relations title="State" value={"#{inspect(@info.state)}"} /> +
+ +
+ <.relations title="Links" value={"#{inspect(@info.relations.links)}"} /> + + <.relations title="Ancestors" value={"#{inspect(@info.relations.ancestors)}" } /> + <.relations title="Monitors" value={"#{inspect(@info.relations.monitors)}"} /> + <.relations title="Monitored by" value={"#{inspect(@info.relations.monitored_by)}"} /> +
+
+ <% end %> +
+ """ + end + + defp relations(assigns) do + ~H""" +
+
+ {@title} +
+
+ + {@value} + +
+
+ """ + end +end diff --git a/lib/web/pages/observer_page.ex b/lib/web/pages/observer_page.ex deleted file mode 100644 index 9a8ef44..0000000 --- a/lib/web/pages/observer_page.ex +++ /dev/null @@ -1,81 +0,0 @@ -defmodule Observer.Web.ObserverPage do - @moduledoc """ - This is the live component responsible for handling the Observer page - """ - - @behaviour Observer.Web.Page - - use Observer.Web, :live_component - - alias Observer.Web.Page - - @impl Phoenix.LiveComponent - def render(assigns) do - ~H""" -
-
- -
-
- """ - end - - @impl Page - def handle_mount(socket) do - socket - end - - @impl Page - def handle_params(params, _uri, socket) do - {:noreply, apply_action(socket, socket.assigns.live_action, params)} - end - - defp apply_action(socket, :index, _params) do - socket - |> assign(:page_title, "Live Observer") - end - - @impl Page - def handle_parent_event(_message, _value, socket) do - {:noreply, socket} - end - - @impl Page - def handle_info(_message, socket) do - {:noreply, socket} - end -end diff --git a/lib/web/pages/tracing_page.ex b/lib/web/pages/tracing/page.ex similarity index 82% rename from lib/web/pages/tracing_page.ex rename to lib/web/pages/tracing/page.ex index 4ab75fa..4b02148 100644 --- a/lib/web/pages/tracing_page.ex +++ b/lib/web/pages/tracing/page.ex @@ -1,4 +1,4 @@ -defmodule Observer.Web.TracingPage do +defmodule Observer.Web.Tracing.Page do @moduledoc """ This is the live component responsible for handling the Tracing debug """ @@ -7,6 +7,7 @@ defmodule Observer.Web.TracingPage do use Observer.Web, :live_component + alias Observer.Web.Components.Attention alias Observer.Web.Components.Core alias Observer.Web.Components.MultiSelectList alias Observer.Web.Page @@ -43,6 +44,19 @@ defmodule Observer.Web.TracingPage do """ + attention_msg = ~H""" + Incorrect use of the :dbg + tracer in production can lead to performance degradation, latency and crashes. + Observer Web tracing enforces limits on the maximum number of messages and applies a timeout (in seconds) + to ensure the debugger doesn't remain active unintentionally. Check out the + + Erlang Debugger + for more detailed information. + """ + assigns = assigns |> assign(unselected_services_keys: unselected_services_keys) @@ -54,94 +68,68 @@ defmodule Observer.Web.TracingPage do |> assign(trace_idle?: trace_idle?) |> assign(trace_owner?: trace_owner?) |> assign(show_tracing_options: show_tracing_options) + |> assign(attention_msg: attention_msg) ~H""" -
-
- - - - - -
+
+ + <:inner_form> + <.form + for={@form} + id="tracing-update-form" + class="flex ml-2 mr-2 text-xs text-center whitespace-nowrap gap-5" + phx-change="form-update" + > + + + + + + <:inner_button> + + + + + +
stub(:call, fn node, module, function, args, timeout -> + :rpc.call(node, module, function, args, timeout) + end) + + invalid_port = + "#Port<0.1000>" + |> String.to_charlist() + |> :erlang.list_to_port() + + [h | _] = :erlang.ports() + assert %{connected: _, id: _, name: _, os_pid: _} = ObserverPort.info(h) + assert %{connected: _, id: _, name: _, os_pid: _} = ObserverPort.info(Node.self(), h) + assert :undefined = ObserverPort.info(Node.self(), invalid_port) + assert :undefined = ObserverPort.info(Node.self(), nil) + end +end diff --git a/test/tracing_web/observer/process_test.exs b/test/tracing_web/observer/process_test.exs new file mode 100644 index 0000000..fdb873e --- /dev/null +++ b/test/tracing_web/observer/process_test.exs @@ -0,0 +1,28 @@ +defmodule ObserverWeb.ProcessTest do + use ExUnit.Case, async: false + + import Mox + + alias ObserverWeb.Observer.Process, as: ObserverPort + + setup :verify_on_exit! + + test "info/1" do + ObserverWeb.RpcMock + |> stub(:pinfo, fn pid, information -> :rpc.pinfo(pid, information) end) + + kernel_pid = :application_controller.get_master(:kernel) + + assert %{error_handler: :error_handler, memory: _, relations: %{links: [head, tail]}} = + ObserverPort.info(kernel_pid) + + assert %{error_handler: :error_handler, memory: _, relations: _} = ObserverPort.info(head) + assert %{error_handler: :error_handler, memory: _, relations: _} = ObserverPort.info(tail) + invalid_pid = "<0.11111.0>" |> String.to_charlist() |> :erlang.list_to_pid() + assert :undefined = ObserverPort.info(invalid_pid) + process = Process.whereis(Elixir.ObserverWeb.Application) + + assert %{error_handler: :error_handler, memory: _, relations: _} = + ObserverPort.info(process) + end +end diff --git a/test/tracing_web/observer_test.exs b/test/tracing_web/observer_test.exs new file mode 100644 index 0000000..b0b4146 --- /dev/null +++ b/test/tracing_web/observer_test.exs @@ -0,0 +1,38 @@ +defmodule ObserverWeb.ObserverTest do + use ExUnit.Case, async: true + + import Mox + + alias ObserverWeb.Observer + + setup :verify_on_exit! + + test "list/0" do + ObserverWeb.RpcMock + |> stub(:call, fn node, module, function, args, timeout -> + :rpc.call(node, module, function, args, timeout) + end) + + assert Enum.find(Observer.list(), &(&1.name == :kernel)) + end + + test "info/0" do + ObserverWeb.RpcMock + |> stub(:call, fn node, module, function, args, timeout -> + :rpc.call(node, module, function, args, timeout) + end) + |> stub(:pinfo, fn pid, information -> :rpc.pinfo(pid, information) end) + + assert %Observer{id: _, name: _, children: _, symbol: _, lineStyle: _, itemStyle: _} = + Observer.info() + + assert %Observer{id: _, name: _, children: _, symbol: _, lineStyle: _, itemStyle: _} = + Observer.info(Node.self(), :observer_web) + + assert %Observer{id: _, name: _, children: _, symbol: _, lineStyle: _, itemStyle: _} = + Observer.info(Node.self(), :phoenix_pubsub) + + assert %Observer{id: _, name: _, children: _, symbol: _, lineStyle: _, itemStyle: _} = + Observer.info(Node.self(), :logger) + end +end diff --git a/test/tracing_web/web/live/observer_test.exs b/test/tracing_web/web/live/observer_test.exs new file mode 100644 index 0000000..1cba28b --- /dev/null +++ b/test/tracing_web/web/live/observer_test.exs @@ -0,0 +1,448 @@ +defmodule Observer.Web.ObserverLiveTest do + use Observer.Web.ConnCase, async: false + + import Phoenix.LiveViewTest + import Mox + + setup [ + :set_mox_global, + :verify_on_exit! + ] + + test "GET /observer", %{conn: conn} do + ObserverWeb.RpcMock + |> stub(:call, fn node, module, function, args, timeout -> + :rpc.call(node, module, function, args, timeout) + end) + |> stub(:pinfo, fn pid, information -> :rpc.pinfo(pid, information) end) + + {:ok, _index_live, html} = live(conn, "/observer/observer") + + assert html =~ "Live Observer" + end + + test "Adjust Initial Tree Depth", %{conn: conn} do + ObserverWeb.RpcMock + |> stub(:call, fn node, module, function, args, timeout -> + :rpc.call(node, module, function, args, timeout) + end) + |> stub(:pinfo, fn pid, information -> :rpc.pinfo(pid, information) end) + + {:ok, index_live, _html} = live(conn, "/observer/observer") + + html = + index_live + |> element("#observer-multi-select-toggle-options") + |> render_click() + + refute html =~ "4242" + + html = + index_live + |> element("#observer-update-form") + |> render_change(%{initial_tree_depth: "4242"}) + + assert html =~ "4242" + end + + test "Add/Remove Local Service + Kernel App", %{conn: conn} do + node = Node.self() |> to_string + service = String.replace(node, "@", "-") + + ObserverWeb.RpcMock + |> stub(:call, fn node, module, function, args, timeout -> + :rpc.call(node, module, function, args, timeout) + end) + |> stub(:pinfo, fn pid, information -> :rpc.pinfo(pid, information) end) + + {:ok, index_live, _html} = live(conn, "/observer/observer") + + index_live + |> element("#observer-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#observer-multi-select-services-#{service}-add-item") + |> render_click() + + html = + index_live + |> element("#observer-multi-select-apps-kernel-add-item") + |> render_click() + + assert html =~ "services:#{node}" + assert html =~ "apps:kernel" + + html = + index_live + |> element("#observer-multi-select-apps-kernel-remove-item") + |> render_click() + + assert html =~ "services:#{node}" + refute html =~ "apps:kernel" + + html = + index_live + |> element("#observer-multi-select-services-#{service}-remove-item") + |> render_click() + + refute html =~ "services:#{node}" + refute html =~ "apps:kernel" + end + + test "Add/Remove Kernel App + Local Service", %{conn: conn} do + node = Node.self() |> to_string + service = String.replace(node, "@", "-") + + ObserverWeb.RpcMock + |> stub(:call, fn node, module, function, args, timeout -> + :rpc.call(node, module, function, args, timeout) + end) + |> stub(:pinfo, fn pid, information -> :rpc.pinfo(pid, information) end) + + {:ok, index_live, _html} = live(conn, "/observer/observer") + + index_live + |> element("#observer-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#observer-multi-select-apps-kernel-add-item") + |> render_click() + + html = + index_live + |> element("#observer-multi-select-services-#{service}-add-item") + |> render_click() + + assert html =~ "services:#{node}" + assert html =~ "apps:kernel" + + html = + index_live + |> element("#observer-multi-select-services-#{service}-remove-item") + |> render_click() + + refute html =~ "services:#{node}" + assert html =~ "apps:kernel" + + html = + index_live + |> element("#observer-multi-select-apps-kernel-remove-item") + |> render_click() + + refute html =~ "services:#{node}" + refute html =~ "apps:kernel" + end + + test "Select Service+Apps and select a process to request information", %{conn: conn} do + node = Node.self() |> to_string + service = String.replace(node, "@", "-") + test_pid_process = self() + + ObserverWeb.RpcMock + |> stub(:call, fn node, module, function, args, timeout -> + :rpc.call(node, module, function, args, timeout) + end) + |> stub(:pinfo, fn pid, information -> + send(test_pid_process, {:observer_page_pid, self()}) + :rpc.pinfo(pid, information) + end) + + {:ok, index_live, _html} = live(conn, "/observer/observer") + + index_live + |> element("#observer-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#observer-multi-select-apps-kernel-add-item") + |> render_click() + + index_live + |> element("#observer-multi-select-services-#{service}-add-item") + |> render_click() + + pid = Enum.random(:erlang.processes()) + + # Send the request 2 times to validate the path where the request + # was already executed. + id = "#{inspect(pid)}" + series_name = "#{Node.self()}::kernel" + + assert_receive {:observer_page_pid, observer_page_pid}, 1_000 + + send(observer_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + send(observer_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + + assert html = render(index_live) + + # Check the Process information is being shown + assert html =~ "Group Leader" + assert html =~ "Heap Size" + refute html =~ "Os Pid" + refute html =~ "Connected" + end + + test "Select Service+Apps and select a process that is dead or doesn't exist", %{conn: conn} do + node = Node.self() |> to_string + service = String.replace(node, "@", "-") + test_pid_process = self() + + ObserverWeb.RpcMock + |> stub(:call, fn node, module, function, args, timeout -> + :rpc.call(node, module, function, args, timeout) + end) + |> stub(:pinfo, fn pid, information -> + send(test_pid_process, {:observer_page_pid, self()}) + :rpc.pinfo(pid, information) + end) + + {:ok, index_live, _html} = live(conn, "/observer/observer") + + index_live + |> element("#observer-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#observer-multi-select-apps-kernel-add-item") + |> render_click() + + index_live + |> element("#observer-multi-select-services-#{service}-add-item") + |> render_click() + + series_name = "#{Node.self()}::kernel" + + id = "#PID<0.0.11111>" + + assert_receive {:observer_page_pid, observer_page_pid}, 1_000 + + send(observer_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + send(observer_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + + assert html = render(index_live) + + # Check the Process information is not being shown + assert html =~ "Process #PID<0.0.11111> is either dead or protected" + end + + test "Select Service+Apps and select a port to request information", %{conn: conn} do + node = Node.self() |> to_string + service = String.replace(node, "@", "-") + test_pid_process = self() + + ObserverWeb.RpcMock + |> stub(:call, fn node, module, function, args, timeout -> + :rpc.call(node, module, function, args, timeout) + end) + |> stub(:pinfo, fn pid, information -> + send(test_pid_process, {:observer_page_pid, self()}) + :rpc.pinfo(pid, information) + end) + + {:ok, index_live, _html} = live(conn, "/observer/observer") + + index_live + |> element("#observer-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#observer-multi-select-apps-kernel-add-item") + |> render_click() + + index_live + |> element("#observer-multi-select-services-#{service}-add-item") + |> render_click() + + port = Enum.random(:erlang.ports()) + + # Send the request 2 times to validate the path where the request + # was already executed. + id = "#{inspect(port)}" + series_name = "#{Node.self()}::kernel" + + assert_receive {:observer_page_pid, observer_page_pid}, 1_000 + + send(observer_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + send(observer_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + + assert html = render(index_live) + + # Check the Process information is being shown + refute html =~ "Group Leader" + refute html =~ "Heap Size" + assert html =~ "Os Pid" + assert html =~ "Connected" + end + + test "Select Service+Apps and select a port that is dead or doesn't exist", %{conn: conn} do + node = Node.self() |> to_string + service = String.replace(node, "@", "-") + test_pid_process = self() + + ObserverWeb.RpcMock + |> stub(:call, fn node, module, function, args, timeout -> + :rpc.call(node, module, function, args, timeout) + end) + |> stub(:pinfo, fn pid, information -> + send(test_pid_process, {:observer_page_pid, self()}) + :rpc.pinfo(pid, information) + end) + + {:ok, index_live, _html} = live(conn, "/observer/observer") + + index_live + |> element("#observer-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#observer-multi-select-apps-kernel-add-item") + |> render_click() + + index_live + |> element("#observer-multi-select-services-#{service}-add-item") + |> render_click() + + series_name = "#{Node.self()}::kernel" + + id = "#Port<0.100>" + + assert_receive {:observer_page_pid, observer_page_pid}, 1_000 + + send(observer_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + send(observer_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + + assert html = render(index_live) + + # Check the Port information is not being shown + assert html =~ "Port #Port<0.100> is either dead or protected" + end + + test "Select Service+Apps and select a reference to request information", %{conn: conn} do + node = Node.self() |> to_string + service = String.replace(node, "@", "-") + test_pid_process = self() + + ObserverWeb.RpcMock + |> stub(:call, fn node, module, function, args, timeout -> + :rpc.call(node, module, function, args, timeout) + end) + |> stub(:pinfo, fn pid, information -> + send(test_pid_process, {:observer_page_pid, self()}) + :rpc.pinfo(pid, information) + end) + + {:ok, index_live, _html} = live(conn, "/observer/observer") + + index_live + |> element("#observer-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#observer-multi-select-apps-kernel-add-item") + |> render_click() + + index_live + |> element("#observer-multi-select-services-#{service}-add-item") + |> render_click() + + reference = make_ref() + + # Send the request 2 times to validate the path where the request + # was already executed. + id = "#{inspect(reference)}" + series_name = "#{Node.self()}::kernel" + + assert_receive {:observer_page_pid, observer_page_pid}, 1_000 + + send(observer_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + send(observer_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + + assert html = render(index_live) + + # Check the Process information is being shown + refute html =~ "Group Leader" + refute html =~ "Heap Size" + refute html =~ "Os Pid" + refute html =~ "Connected" + end + + @tag :capture_log + test "Update buttom with Observer Web App + Local Service", %{conn: conn} do + node = Node.self() |> to_string + service = String.replace(node, "@", "-") + + ObserverWeb.RpcMock + |> stub(:call, fn node, module, function, args, timeout -> + :rpc.call(node, module, function, args, timeout) + end) + |> stub(:pinfo, fn pid, information -> :rpc.pinfo(pid, information) end) + + {:ok, index_live, _html} = live(conn, "/observer/observer") + + index_live + |> element("#observer-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#observer-multi-select-apps-observer_web-add-item") + |> render_click() + + html = + index_live + |> element("#observer-multi-select-services-#{service}-add-item") + |> render_click() + + assert html =~ "services:#{node}" + assert html =~ "apps:observer_web" + + assert index_live + |> element("#observer-multi-select-update", "UPDATE") + |> render_click() =~ "apps:observer_web" + end + + test "Testing NodeUp/NodeDown", %{conn: conn} do + fake_node = :myapp@nohost + node = Node.self() |> to_string + service = String.replace(node, "@", "-") + test_pid_process = self() + + ObserverWeb.RpcMock + |> stub(:call, fn node, module, function, args, timeout -> + :rpc.call(node, module, function, args, timeout) + end) + |> stub(:pinfo, fn pid, information -> + send(test_pid_process, {:observer_page_pid, self()}) + :rpc.pinfo(pid, information) + end) + + {:ok, index_live, _html} = live(conn, "/observer/observer") + + index_live + |> element("#observer-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#observer-multi-select-apps-kernel-add-item") + |> render_click() + + html = + index_live + |> element("#observer-multi-select-services-#{service}-add-item") + |> render_click() + + assert html =~ "services:#{node}" + assert html =~ "apps:kernel" + + assert_receive {:observer_page_pid, observer_page_pid}, 1_000 + + # Check node up/down doesn't change the selected items + send(observer_page_pid, {:nodeup, fake_node}) + send(observer_page_pid, {:nodedown, fake_node}) + + assert html = render(index_live) + assert html =~ "services:#{node}" + assert html =~ "apps:kernel" + end +end diff --git a/test/tracing_web/web/live/tracing_test.exs b/test/tracing_web/web/live/tracing_test.exs index 1c538c6..94a044b 100644 --- a/test/tracing_web/web/live/tracing_test.exs +++ b/test/tracing_web/web/live/tracing_test.exs @@ -22,7 +22,7 @@ defmodule Observer.Web.TracingLiveTest do :rpc.call(node, module, function, args, timeout) end) - {:ok, _index_live, html} = live(conn, "/observer/tracing") + {:ok, _index_live, html} = live(conn, "/observer") assert html =~ "Live Tracing" end