diff --git a/assets/js/app.js b/assets/js/app.js index 04a01bd..4ed771b 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -7,12 +7,14 @@ import LiveMetricsEChart from "./hooks/live_metrics_echart"; import ObserverEChart from "./hooks/observer_echart"; import ScrollBottom from "./hooks/scroll_bottom"; import Themer from "./hooks/themer"; +import AutoDismissFlash from "./hooks/auto_dismiss_flash"; const hooks = { LiveMetricsEChart, ObserverEChart, ScrollBottom, Themer, + AutoDismissFlash, }; // Topbar --- diff --git a/assets/js/hooks/auto_dismiss_flash.js b/assets/js/hooks/auto_dismiss_flash.js new file mode 100644 index 0000000..40113cb --- /dev/null +++ b/assets/js/hooks/auto_dismiss_flash.js @@ -0,0 +1,11 @@ +const AutoDismissFlash = { + mounted() { + setTimeout(() => { + this.el.style.transition = "opacity 0.5s"; + this.el.style.opacity = "0"; + setTimeout(() => this.pushEventTo(this.el, "clear-flash"), 500); + }, 3500); + }, +}; + +export default AutoDismissFlash diff --git a/assets/js/hooks/observer_echart.js b/assets/js/hooks/observer_echart.js index d6ed231..e0d258a 100644 --- a/assets/js/hooks/observer_echart.js +++ b/assets/js/hooks/observer_echart.js @@ -19,7 +19,7 @@ const ObserverEChart = { // 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'); + // console.log('[ObserverEChart] No changes in the data, skipping setOption'); return; // Exit without updating the chart } diff --git a/lib/observer_web/apps/process.ex b/lib/observer_web/apps/process.ex index 16b05ac..26a67d3 100644 --- a/lib/observer_web/apps/process.ex +++ b/lib/observer_web/apps/process.ex @@ -99,17 +99,24 @@ defmodule ObserverWeb.Apps.Process do gc = Keyword.get(data, :garbage_collection, []) dictionary = Keyword.get(data, :dictionary) - {state, phx_lv_socket} = - case state(pid, timeout) do - {:ok, %{socket: %Phoenix.LiveView.Socket{}} = state} -> - new_state = %{state | socket: "Phoenix.LiveView.Socket", components: "hidden"} - {to_string(:io_lib.format("~tp", [new_state])), state.socket} - - {:ok, state} -> - {to_string(:io_lib.format("~tp", [state])), nil} + meta = structure_meta(data, pid) - {:error, reason} -> - {reason, nil} + {state, phx_lv_socket} = + if meta.class in [:unknown, :application] do + {"Could not retrieve the state for pid: #{inspect(pid)}. Reason: state is not available - see Overview class for more information", + nil} + else + case state(pid, timeout) do + {:ok, %{socket: %Phoenix.LiveView.Socket{}} = state} -> + new_state = %{state | socket: "Phoenix.LiveView.Socket", components: "hidden"} + {to_string(:io_lib.format("~tp", [new_state])), state.socket} + + {:ok, state} -> + {to_string(:io_lib.format("~tp", [state])), nil} + + {:error, reason} -> + {reason, nil} + end end %{ @@ -134,7 +141,7 @@ defmodule ObserverWeb.Apps.Process do 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), + meta: meta, state: state, dictionary: dictionary, phx_lv_socket: phx_lv_socket diff --git a/lib/web/components/confirm.ex b/lib/web/components/confirm.ex new file mode 100644 index 0000000..1ad080b --- /dev/null +++ b/lib/web/components/confirm.ex @@ -0,0 +1,74 @@ +defmodule Observer.Web.Components.Confirm do + @moduledoc false + use Phoenix.Component + + attr :id, :string, required: true + slot :header, required: true + slot :footer, required: true + slot :inner_block, required: true + + def content(assigns) do + ~H""" +
+
+
+ + +
+
+
+

+ {render_slot(@header)} +

+
+ {render_slot(@inner_block)} + +
+
+
+
+
+ """ + end + + attr :id, :string, required: true + slot :inner_block, required: true + + def cancel_button(assigns) do + ~H""" + + """ + end + + attr :id, :string, required: true + attr :value, :string, required: true + attr :event, :string, required: true + slot :inner_block, required: true + + def confirm_button(assigns) do + ~H""" + + """ + end +end diff --git a/lib/web/components/core.ex b/lib/web/components/core.ex index 3719804..837a2aa 100644 --- a/lib/web/components/core.ex +++ b/lib/web/components/core.ex @@ -316,7 +316,7 @@ defmodule Observer.Web.Components.Core do def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do assigns |> assign(field: nil, id: assigns.id || field.id) - # |> assign(:errors, Enum.map(field.errors, &translate_error(&1))) + |> assign(:errors, Enum.map(field.errors, &translate_error(&1))) |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end) |> assign_new(:value, fn -> field.value end) |> input() @@ -438,6 +438,20 @@ defmodule Observer.Web.Components.Core do """ end + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, _opts}) do + msg + end + + @doc """ + Translates the errors for a field from a keyword list of errors. + """ + def translate_errors(errors, field) when is_list(errors) do + for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) + end + @doc """ Renders a label. """ diff --git a/lib/web/components/icons.ex b/lib/web/components/icons.ex index 9fd44c4..3d8f92d 100644 --- a/lib/web/components/icons.ex +++ b/lib/web/components/icons.ex @@ -214,6 +214,44 @@ defmodule Observer.Web.Components.Icons do attr :rest, :global + def check_circle(assigns) do + ~H""" + <.svg_outline {@rest}> + + + """ + end + + attr :rest, :global + + def x_mark(assigns) do + ~H""" + <.svg_outline {@rest}> + + + """ + end + + attr :rest, :global + + def x_circle(assigns) do + ~H""" + <.svg_outline {@rest}> + + + """ + end + + attr :rest, :global + def moon(assigns) do ~H""" <.svg_outline {@rest}> diff --git a/lib/web/components/layouts.ex b/lib/web/components/layouts.ex index 7448234..f5110f3 100644 --- a/lib/web/components/layouts.ex +++ b/lib/web/components/layouts.ex @@ -108,4 +108,78 @@ defmodule Observer.Web.Layouts do defp list_pages_by_params(%{"iframe" => "true"}), do: [:tracing, :applications, :metrics] defp list_pages_by_params(_params), do: [:root, :tracing, :applications, :metrics] + + @doc """ + Renders flash notices. + + ## Examples + + <.flash kind={:info} flash={@flash} /> + <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back! + """ + attr :id, :string, doc: "the optional id of flash container" + attr :flash, :map, default: %{}, doc: "the map of flash messages to display" + attr :title, :string, default: nil + attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" + attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" + + slot :inner_block, doc: "the optional inner block that renders the flash message" + + def flash(assigns) do + assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end) + + ~H""" +
hide("##{@id}")} + role="alert" + class="fixed z-40 inset-0 flex items-end justify-center pointer-events-none md:py-3 md:px-4 sm:p-6 sm:items-start sm:justify-end" + {@rest} + > +
+
+
+
+ <%= if @kind == :error do %> +
+ +
+ <% else %> +
+ +
+ <% end %> +
+

+ {msg} +

+
+
+ +
+
+
+
+
+
+ """ + end + + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + time: 200, + transition: + {"transition-all transform ease-in duration-200", + "opacity-100 translate-y-0 sm:scale-100", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} + ) + end end diff --git a/lib/web/components/layouts/live.html.heex b/lib/web/components/layouts/live.html.heex index 1bf5691..2391b9c 100644 --- a/lib/web/components/layouts/live.html.heex +++ b/lib/web/components/layouts/live.html.heex @@ -2,6 +2,8 @@
+ <.flash :if={Phoenix.Flash.get(@flash, :info)} kind={:info} flash={@flash} /> + <.flash :if={Phoenix.Flash.get(@flash, :error)} kind={:error} flash={@flash} />
<.logo params={@params} /> diff --git a/lib/web/helpers.ex b/lib/web/helpers.ex index 37e5143..5e1070a 100644 --- a/lib/web/helpers.ex +++ b/lib/web/helpers.ex @@ -94,4 +94,83 @@ defmodule Observer.Web.Helpers do raise RuntimeError, "nothing stored in the :routing key" end end + + @doc """ + This function converts String PID/PORT to its respective type + + ## Examples + + iex> alias Observer.Web.Helpers + ...> assert {:pid, _any} = Helpers.parse_identifier("#PID<0.308.0>") + ...> assert {:port, _any} = Helpers.parse_identifier("#Port<0.1>") + ...> assert {:none, _any} = Helpers.parse_identifier("#Ref<0.0.0.0>") + """ + def parse_identifier(id) do + cond do + String.contains?(id, "#PID<") -> + {:pid, string_to_pid(id)} + + String.contains?(id, "#Port<") -> + {:port, string_to_port(id)} + + true -> + {:none, id} + end + end + + @doc """ + This function converts String PID to safe ID to be used in HTML components + + ## Examples + + iex> alias Observer.Web.Helpers + ...> assert Helpers.identifier_to_safe_id("#PID<0.308.0>") == "pid-0-308-0" + ...> assert Helpers.identifier_to_safe_id("#PID<0.308.1>") == "pid-0-308-1" + ...> assert Helpers.identifier_to_safe_id("#Port<0.1>") == "port-0-1" + ...> assert_raise RuntimeError, fn -> Helpers.identifier_to_safe_id("<0.1>") end + """ + def identifier_to_safe_id(identifier) when is_binary(identifier) do + cond do + String.contains?(identifier, "#PID<") -> + identifier + |> String.replace(["#PID<"], "pid-") + |> String.replace(["."], "-") + |> String.replace([">"], "") + + String.contains?(identifier, "#Port<") -> + identifier + |> String.replace(["#Port<"], "port-") + |> String.replace(["."], "-") + |> String.replace([">"], "") + + true -> + raise "Invalid identifier" + end + end + + @doc """ + This function converts String PID to PID type + + ## Examples + + iex> alias Observer.Web.Helpers + ...> assert "#PID<0.308.0>" |> Helpers.string_to_pid() |> is_pid() + ...> assert "#PID<0.308.1>" |> Helpers.string_to_pid() |> is_pid() + """ + def string_to_pid(string) do + string |> String.trim_leading("#PID") |> String.to_charlist() |> :erlang.list_to_pid() + end + + @doc """ + This function converts String PORT to Port type + + ## Examples + + iex> alias Observer.Web.Helpers + ...> assert "#Port<0.1>" |> Helpers.string_to_port() |> is_port() + ...> assert "#Port<0.2>" |> Helpers.string_to_port() |> is_port() + """ + def string_to_port(string) do + string |> String.to_charlist() |> :erlang.list_to_port() + end end diff --git a/lib/web/live/index.ex b/lib/web/live/index.ex index f97bb4e..d012a4d 100644 --- a/lib/web/live/index.ex +++ b/lib/web/live/index.ex @@ -77,6 +77,10 @@ defmodule Observer.Web.IndexLive do end @impl Phoenix.LiveView + def handle_event("clear-flash", %{}, socket) do + {:noreply, clear_flash(socket)} + end + def handle_event(message, value, socket) do socket.assigns.page.comp.handle_parent_event(message, value, socket) end diff --git a/lib/web/pages/apps/page.ex b/lib/web/pages/apps/page.ex index 480fe12..faa7044 100644 --- a/lib/web/pages/apps/page.ex +++ b/lib/web/pages/apps/page.ex @@ -11,7 +11,9 @@ defmodule Observer.Web.Apps.Page do alias Observer.Web.Apps.Port alias Observer.Web.Apps.Process alias Observer.Web.Components.Attention + alias Observer.Web.Components.Confirm alias Observer.Web.Components.MultiSelect + alias Observer.Web.Helpers alias Observer.Web.Page alias ObserverWeb.Apps @@ -142,11 +144,39 @@ defmodule Observer.Web.Apps.Page do
<%= if @current_selected_id.type == "pid" do %> - + <% else %> <% end %> + + <%= if @selected_id_action_confirmation do %> + + <:header> +

Attention

+ +

+ {@selected_id_action_confirmation.message} +

+ <:footer> + + Cancel + + + Confirm + + +
+ <% end %> """ end @@ -162,7 +192,10 @@ defmodule Observer.Web.Apps.Page do |> assign(:observer_data, %{}) |> assign(:current_selected_id, reset_current_selected_id()) |> assign(form: to_form(default_form_options())) + |> assign(process_msg_form: to_form(%{"message" => ""})) |> assign(:show_observer_options, false) + |> assign(:process_memory_monitor, false) + |> assign(:selected_id_action_confirmation, nil) end def handle_mount(socket) do @@ -172,7 +205,10 @@ defmodule Observer.Web.Apps.Page do |> assign(:observer_data, %{}) |> assign(:current_selected_id, reset_current_selected_id()) |> assign(form: to_form(default_form_options())) + |> assign(process_msg_form: to_form(%{"message" => ""})) |> assign(:show_observer_options, false) + |> assign(:process_memory_monitor, false) + |> assign(:selected_id_action_confirmation, nil) end # coveralls-ignore-start @@ -203,6 +239,127 @@ defmodule Observer.Web.Apps.Page do {:noreply, socket |> assign(:show_observer_options, show_observer_options)} end + def handle_parent_event( + "request_port_action", + %{"action" => "kill"}, + %{assigns: %{current_selected_id: current_selected_id}} = socket + ) do + {:noreply, + assign( + socket, + :selected_id_action_confirmation, + action_confirmation(:port, current_selected_id.id_string) + )} + end + + def handle_parent_event( + "request_process_action", + %{"action" => "kill"}, + %{assigns: %{current_selected_id: current_selected_id}} = socket + ) do + {:noreply, + assign( + socket, + :selected_id_action_confirmation, + action_confirmation(:process, current_selected_id.id_string) + )} + end + + def handle_parent_event( + "request_process_action", + %{"action" => "garbage_collect"}, + %{assigns: %{current_selected_id: current_selected_id}} = socket + ) do + pid_string = current_selected_id.id_string + true = pid_string |> Helpers.string_to_pid() |> :erlang.garbage_collect() + + {:noreply, + socket + |> put_flash(:info, "Process pid: #{pid_string} successfully garbage collected")} + end + + def handle_parent_event( + "request_process_action", + %{"process-send-message" => message}, + %{assigns: %{current_selected_id: current_selected_id}} = socket + ) do + pid_string = current_selected_id.id_string + pid = Helpers.string_to_pid(pid_string) + + {term, _} = Code.eval_string(message) + send(pid, term) + + {:noreply, + socket + |> put_flash(:info, "Message sent to process pid: #{pid_string} with success")} + end + + def handle_parent_event( + "request_process_action", + _params, + %{ + assigns: %{ + current_selected_id: current_selected_id, + process_memory_monitor: process_memory_monitor + } + } = socket + ) do + new_process_memory_monitor = !process_memory_monitor + text = if new_process_memory_monitor, do: "enabled", else: "disabled" + pid_string = current_selected_id.id_string + + # NOTE: Add monitor enable actions here + + {:noreply, + socket + |> assign(:process_memory_monitor, new_process_memory_monitor) + |> put_flash(:info, "Memory monitoring #{text} for process pid: #{pid_string}")} + end + + def handle_parent_event( + "process-message-form-update", + %{"process-send-message" => message}, + socket + ) do + # NOTE: Validate that the message is valid Elixir syntax by attempting to parse and evaluate it + errors = + try do + case Code.eval_string(message) do + {_term, _} -> + [] + end + rescue + _exception -> + [{:message, {"invalid elixir format", []}}] + end + + {:noreply, assign(socket, process_msg_form: to_form(%{"message" => message}, errors: errors))} + end + + def handle_parent_event("port-close-confirmation", %{"id" => id_string}, socket) do + true = id_string |> Helpers.string_to_port() |> Elixir.Port.close() + + {:noreply, + socket + |> put_flash(:info, "Port id: #{id_string} successfully closed") + |> assign(:selected_id_action_confirmation, nil) + |> assign(:current_selected_id, reset_current_selected_id())} + end + + def handle_parent_event("process-kill-confirmation", %{"id" => id_string}, socket) do + true = id_string |> Helpers.string_to_pid() |> Elixir.Process.exit(:kill) + + {:noreply, + socket + |> put_flash(:info, "Process pid: #{id_string} successfully terminated") + |> assign(:selected_id_action_confirmation, nil) + |> assign(:current_selected_id, reset_current_selected_id())} + end + + def handle_parent_event("confirm-close-modal", _, socket) do + {:noreply, assign(socket, :selected_id_action_confirmation, nil)} + end + def handle_parent_event( "form-update", %{"initial_tree_depth" => depth, "get_state_timeout" => get_state_timeout}, @@ -372,19 +529,9 @@ defmodule Observer.Web.Apps.Page do when id_string != request_id or debouncing < 0 do get_state_timeout = form.params["get_state_timeout"] |> String.to_integer() - # IO.inspect get_state_timeout - 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() - + case Helpers.parse_identifier(request_id) do + {:pid, pid} -> %{ info: Apps.Process.info(pid, get_state_timeout), id_string: request_id, @@ -392,14 +539,8 @@ defmodule Observer.Web.Apps.Page do debouncing: @tooltip_debouncing } - port? -> + {:port, port} -> [service, _app] = String.split(series_name, "::") - - port = - request_id - |> String.to_charlist() - |> :erlang.list_to_port() - node = String.to_existing_atom(service) %{ @@ -409,7 +550,7 @@ defmodule Observer.Web.Apps.Page do debouncing: @tooltip_debouncing } - true -> + {:none, _any} -> reset_current_selected_id(request_id) end @@ -604,4 +745,24 @@ defmodule Observer.Web.Apps.Page do animationDurationUpdate: 750 } end + + defp action_confirmation(:process, id) do + %{ + type: "process", + event: "process-kill-confirmation", + message: "Are you sure you want to terminate process pid: #{id}?", + id_string: id, + id: Helpers.identifier_to_safe_id(id) + } + end + + defp action_confirmation(:port, id) do + %{ + type: "port", + event: "port-close-confirmation", + message: "Are you sure you want to close port id: #{id}?", + id_string: id, + id: Helpers.identifier_to_safe_id(id) + } + end end diff --git a/lib/web/pages/apps/port.ex b/lib/web/pages/apps/port.ex index 3763e32..4f976b1 100644 --- a/lib/web/pages/apps/port.ex +++ b/lib/web/pages/apps/port.ex @@ -4,10 +4,11 @@ defmodule Observer.Web.Apps.Port do use Observer.Web, :html use Phoenix.Component + alias Observer.Web.Apps.PortActions alias Observer.Web.Components.Attention attr :info, :map, required: true - attr :id, :map, required: true + attr :id, :string, required: true def content(assigns) do info = assigns.info @@ -42,6 +43,7 @@ defmodule Observer.Web.Apps.Port do <% true -> %>
+ <:col :let={item}> {item.name} diff --git a/lib/web/pages/apps/port_actions.ex b/lib/web/pages/apps/port_actions.ex new file mode 100644 index 0000000..0b8313a --- /dev/null +++ b/lib/web/pages/apps/port_actions.ex @@ -0,0 +1,40 @@ +defmodule Observer.Web.Apps.PortActions do + @moduledoc """ + Component for managing port actions like closing, etc. + """ + + use Observer.Web, :html + use Phoenix.Component + + attr :id, :string, required: true + attr :on_action, :any, required: true + + def content(assigns) do + ~H""" +
+
+ Port Actions +
+ +
+
+ +
+
+ +

+ Port ID: {@id} +

+
+ """ + end +end diff --git a/lib/web/pages/apps/process.ex b/lib/web/pages/apps/process.ex index 2862042..d6038c2 100644 --- a/lib/web/pages/apps/process.ex +++ b/lib/web/pages/apps/process.ex @@ -4,11 +4,14 @@ defmodule Observer.Web.Apps.Process do use Observer.Web, :html use Phoenix.Component + alias Observer.Web.Apps.ProcessActions alias Observer.Web.Components.Attention alias Observer.Web.Components.CopyToClipboard attr :info, :map, required: true - attr :id, :map, required: true + attr :id, :string, required: true + attr :form, :map, required: true + attr :process_memory_monitor, :boolean, required: true def content(assigns) do info = assigns.info @@ -88,6 +91,13 @@ defmodule Observer.Web.Apps.Process do <% true -> %>
+ + +
+ Process Actions +
+ +
+
+ + + +
+
+ +
+ <.form + for={@form} + phx-submit={@on_action} + id="process-send-msg-form" + phx-change="process-message-form-update" + > +
+
+ + + +
+
+ +
+ + + +

+ Process ID: {@id} +

+
+ """ + end + + defp border_error(true), do: "border-red-200 focus:border-red-400 hover:border-red-300" + defp border_error(_false), do: "border-slate-200 focus:border-slate-400 hover:border-slate-300" +end diff --git a/test/mix/tasks/observer_web.install_test.exs b/test/mix/tasks/observer_web.install_test.exs index 67ce57b..5e1c410 100644 --- a/test/mix/tasks/observer_web.install_test.exs +++ b/test/mix/tasks/observer_web.install_test.exs @@ -2,6 +2,7 @@ defmodule Mix.Tasks.ObserverWeb.InstallTest do use ExUnit.Case, async: true import Igniter.Test + alias Mix.Tasks.ObserverWeb.Install.Docs test "installation adds the route the necessary setup to the router" do test_project() @@ -62,4 +63,56 @@ defmodule Mix.Tasks.ObserverWeb.InstallTest do ...| """) end + + test "No Phoenix router found" do + response = + test_project() + |> apply_igniter!() + |> Igniter.compose_task("observer_web.install") + + assert response.warnings == [ + "No Phoenix router found, Phoenix Liveview is needed for Observer Web\n" + ] + end + + test "No dev routes found" do + assert_raise CaseClauseError, fn -> + test_project() + |> Igniter.Project.Module.create_module(TestWeb.Router, """ + use TestWeb, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, {DevWeb.LayoutView, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/" do + pipe_through :browser + + live_dashboard "/dashboard", metrics: testWeb.Telemetry + forward "/mailbox", Plug.Swoosh.MailboxPreview + end + """) + |> apply_igniter!() + |> Igniter.compose_task("observer_web.install") + end + end + + test "Validate info methods" do + description_text = Docs.long_doc() + assert description_text =~ "Installs Observer Web into your Phoenix application" + + assert description_text =~ + "This task configures your Phoenix application to use the Observer Web dashboard" + + assert description_text =~ "mix observer_web.install" + end end diff --git a/test/observer_web/web/live/apps/page_test.exs b/test/observer_web/web/live/apps/page_test.exs index 91c4428..5ca3cb0 100644 --- a/test/observer_web/web/live/apps/page_test.exs +++ b/test/observer_web/web/live/apps/page_test.exs @@ -5,6 +5,7 @@ defmodule Observer.Web.Apps.PageLiveTest do import Mox import Mock + alias Observer.Web.Helpers alias Observer.Web.Mocks.RpcStubber alias Observer.Web.Mocks.TelemetryStubber @@ -344,6 +345,319 @@ defmodule Observer.Web.Apps.PageLiveTest do assert html =~ "Process #PID<0.0.11111> is either dead or protected" end + test "Select Service+Apps and Kill a process", %{conn: conn} do + node = Node.self() |> to_string + service = Helpers.normalize_id(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, {:apps_page_pid, self()}) + :rpc.pinfo(pid, information) + end) + + TelemetryStubber.defaults() + + {:ok, index_live, _html} = live(conn, "/observer/applications") + + index_live + |> element("#apps-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#apps-multi-select-apps-kernel-add-item") + |> render_click() + + index_live + |> element("#apps-multi-select-services-#{service}-add-item") + |> render_click() + + {:ok, pid} = + Task.start(fn -> + # Perform a long-running operation + :timer.sleep(30_000) + "Long-running task complete!" + end) + + # 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 {:apps_page_pid, apps_page_pid}, 1_000 + + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + + assert index_live + |> element("#process-kill-button") + |> render_click() =~ "Are you sure you want to terminate process pid:" + + assert index_live + |> element("#confirm-button-#{Helpers.identifier_to_safe_id(id)}") + |> render_click() =~ "successfully terminated" + + refute Process.alive?(pid) + end + + test "Select Service+Apps and Cancel before killing a process", %{conn: conn} do + node = Node.self() |> to_string + service = Helpers.normalize_id(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, {:apps_page_pid, self()}) + :rpc.pinfo(pid, information) + end) + + TelemetryStubber.defaults() + + {:ok, index_live, _html} = live(conn, "/observer/applications") + + index_live + |> element("#apps-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#apps-multi-select-apps-kernel-add-item") + |> render_click() + + index_live + |> element("#apps-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 {:apps_page_pid, apps_page_pid}, 1_000 + + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + + assert index_live + |> element("#process-kill-button") + |> render_click() =~ "Are you sure you want to terminate process pid:" + + index_live + |> element("#cancel-button-#{Helpers.identifier_to_safe_id(id)}") + |> render_click() + + assert Process.alive?(pid) + end + + test "Select Service+Apps and Garbage Collect a process", %{conn: conn} do + node = Node.self() |> to_string + service = Helpers.normalize_id(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, {:apps_page_pid, self()}) + :rpc.pinfo(pid, information) + end) + + TelemetryStubber.defaults() + + {:ok, index_live, _html} = live(conn, "/observer/applications") + + index_live + |> element("#apps-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#apps-multi-select-apps-kernel-add-item") + |> render_click() + + index_live + |> element("#apps-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 {:apps_page_pid, apps_page_pid}, 1_000 + + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + + assert index_live + |> element("#process-clean-memory-button") + |> render_click() =~ "successfully garbage collected" + end + + test "Select Service+Apps and Toggle process Monitor", %{conn: conn} do + node = Node.self() |> to_string + service = Helpers.normalize_id(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, {:apps_page_pid, self()}) + :rpc.pinfo(pid, information) + end) + + TelemetryStubber.defaults() + + {:ok, index_live, _html} = live(conn, "/observer/applications") + + index_live + |> element("#apps-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#apps-multi-select-apps-kernel-add-item") + |> render_click() + + index_live + |> element("#apps-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 {:apps_page_pid, apps_page_pid}, 1_000 + + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + + assert index_live + |> element("input[type=\"checkbox\"]") + |> render_click() =~ "Memory monitoring enabled for process pid:" + + assert index_live + |> element("input[type=\"checkbox\"]") + |> render_click() =~ "Memory monitoring disabled for process pid:" + end + + test "Select Service+Apps and Send a message to a process", %{conn: conn} do + node = Node.self() |> to_string + service = Helpers.normalize_id(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, {:apps_page_pid, self()}) + :rpc.pinfo(pid, information) + end) + + TelemetryStubber.defaults() + + {:ok, index_live, _html} = live(conn, "/observer/applications") + + index_live + |> element("#apps-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#apps-multi-select-apps-kernel-add-item") + |> render_click() + + index_live + |> element("#apps-multi-select-services-#{service}-add-item") + |> render_click() + + {:ok, pid} = + Task.start_link(fn -> + # Perform a long-running operation + :timer.sleep(30_000) + "Long-running task complete!" + end) + + # 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 {:apps_page_pid, apps_page_pid}, 1_000 + + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + + index_live + |> element("#process-send-msg-form") + |> render_change(%{"process-send-message" => "{:hello, :world}"}) + + assert index_live + |> element("#process-send-msg-form") + |> render_submit() =~ "Message sent to process pid:" + end + + test "Select Service+Apps and cannot send an invalid message to a process", %{conn: conn} do + node = Node.self() |> to_string + service = Helpers.normalize_id(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, {:apps_page_pid, self()}) + :rpc.pinfo(pid, information) + end) + + TelemetryStubber.defaults() + + {:ok, index_live, _html} = live(conn, "/observer/applications") + + index_live + |> element("#apps-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#apps-multi-select-apps-kernel-add-item") + |> render_click() + + index_live + |> element("#apps-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 {:apps_page_pid, apps_page_pid}, 1_000 + + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + + assert index_live + |> element("#process-send-msg-form") + |> render_change(%{"process-send-message" => "invalid"}) =~ + "border-red-200 focus:border-red-400 hover:border-red-300" + end + test "Select Service+Apps and select a port to request information", %{conn: conn} do node = Node.self() |> to_string service = Helpers.normalize_id(node) @@ -440,6 +754,112 @@ defmodule Observer.Web.Apps.PageLiveTest do assert html =~ "Port #Port<0.100> is either dead or protected" end + test "Select Service+Apps and close a port", %{conn: conn} do + node = Node.self() |> to_string + service = Helpers.normalize_id(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, {:apps_page_pid, self()}) + :rpc.pinfo(pid, information) + end) + + TelemetryStubber.defaults() + + {:ok, index_live, _html} = live(conn, "/observer/applications") + + index_live + |> element("#apps-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#apps-multi-select-apps-kernel-add-item") + |> render_click() + + index_live + |> element("#apps-multi-select-services-#{service}-add-item") + |> render_click() + + port = Port.open({:spawn, "sleep 30000"}, [:binary]) + + # 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 {:apps_page_pid, apps_page_pid}, 1_000 + + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + + assert index_live + |> element("#port-close-button") + |> render_click() =~ "Are you sure you want to close port id:" + + index_live + |> element("#confirm-button-#{Helpers.identifier_to_safe_id(id)}") + |> render_click() + + refute Port.info(port) + end + + test "Select Service+Apps and Cancel before closing a port", %{conn: conn} do + node = Node.self() |> to_string + service = Helpers.normalize_id(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, {:apps_page_pid, self()}) + :rpc.pinfo(pid, information) + end) + + TelemetryStubber.defaults() + + {:ok, index_live, _html} = live(conn, "/observer/applications") + + index_live + |> element("#apps-multi-select-toggle-options") + |> render_click() + + index_live + |> element("#apps-multi-select-apps-kernel-add-item") + |> render_click() + + index_live + |> element("#apps-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 {:apps_page_pid, apps_page_pid}, 1_000 + + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + send(apps_page_pid, {"request-process", %{"id" => id, "series_name" => series_name}}) + + assert index_live + |> element("#port-close-button") + |> render_click() =~ "Are you sure you want to close port id:" + + index_live + |> element("#cancel-button-#{Helpers.identifier_to_safe_id(id)}") + |> render_click() + + assert Port.info(port) + end + test "Select Service+Apps and select a reference to request information", %{conn: conn} do node = Node.self() |> to_string service = Helpers.normalize_id(node) diff --git a/test/observer_web/web/live/index.test.exs b/test/observer_web/web/live/index.test.exs deleted file mode 100644 index 4920c04..0000000 --- a/test/observer_web/web/live/index.test.exs +++ /dev/null @@ -1,39 +0,0 @@ -defmodule Observer.Web.IndexLiveTest do - use Observer.Web.ConnCase, async: false - - import Mox - - alias Observer.Web.Mocks.RpcStubber - alias Observer.Web.Mocks.TelemetryStubber - - setup :verify_on_exit! - - test "forbidding mount using a resolver callback", %{conn: conn} do - TelemetryStubber.defaults() - - assert {:error, {:redirect, redirect}} = live(conn, "/observer-limited") - assert %{to: "/", flash: %{"error" => "Access forbidden"}} = redirect - end - - test "Check iframe OFF allows root button", %{conn: conn} do - RpcStubber.defaults() - - TelemetryStubber.defaults() - - {:ok, _index_live, html} = live(conn, "/observer/tracing") - - assert html =~ "Live Tracing" - assert html =~ "ROOT" - end - - test "Check iframe ON doesn't allow root button", %{conn: conn} do - RpcStubber.defaults() - - TelemetryStubber.defaults() - - {:ok, _index_live, html} = live(conn, "/observer/tracing?iframe=true") - - assert html =~ "Live Tracing" - refute html =~ "ROOT" - end -end diff --git a/test/observer_web/web/live/index_test.exs b/test/observer_web/web/live/index_test.exs new file mode 100644 index 0000000..2d41f18 --- /dev/null +++ b/test/observer_web/web/live/index_test.exs @@ -0,0 +1,83 @@ +defmodule Observer.Web.IndexLiveTest do + use Observer.Web.ConnCase, async: false + + import Mox + + alias Observer.Web.IndexLive, as: ObserverLive + alias Observer.Web.Mocks.RpcStubber + alias Observer.Web.Mocks.TelemetryStubber + + setup :verify_on_exit! + + test "forbidding mount using a resolver callback", %{conn: conn} do + TelemetryStubber.defaults() + + assert {:error, {:redirect, redirect}} = live(conn, "/observer-limited") + assert %{to: "/", flash: %{"error" => "Access forbidden"}} = redirect + end + + test "Check iframe OFF allows root button", %{conn: conn} do + RpcStubber.defaults() + + TelemetryStubber.defaults() + + {:ok, _index_live, html} = live(conn, "/observer/tracing") + + assert html =~ "Live Tracing" + assert html =~ "ROOT" + end + + test "Check iframe ON doesn't allow root button", %{conn: conn} do + RpcStubber.defaults() + + TelemetryStubber.defaults() + + {:ok, _index_live, html} = live(conn, "/observer/tracing?iframe=true") + + assert html =~ "Live Tracing" + refute html =~ "ROOT" + end + + test "Check Update Theme", %{conn: conn} do + RpcStubber.defaults() + + TelemetryStubber.defaults() + + {:ok, index_live, _html} = live(conn, "/observer/tracing") + + # NOTE: This test could use send() but it would have to add a delay, so it was + # called directly + + # Get the socket from the LiveView process + socket = :sys.get_state(index_live.pid).socket + + {:noreply, updated_socket} = + ObserverLive.handle_info({:update_theme, "dark"}, socket) + + assert updated_socket.assigns.theme == "dark" + + {:noreply, updated_socket} = + ObserverLive.handle_info({:update_theme, "light"}, socket) + + assert updated_socket.assigns.theme == "light" + + {:noreply, updated_socket} = + ObserverLive.handle_info({:update_theme, "system"}, socket) + + assert updated_socket.assigns.theme == "system" + end + + test "Check Clear Flash", %{conn: conn} do + RpcStubber.defaults() + + TelemetryStubber.defaults() + + {:ok, index_live, _html} = live(conn, "/observer/tracing") + + # Get the socket from the LiveView process + socket = :sys.get_state(index_live.pid).socket + + {:noreply, _updated_socket} = + ObserverLive.handle_event("clear-flash", %{}, socket) + end +end diff --git a/test/observer_web/web/live/theme_component_test.exs b/test/observer_web/web/live/theme_component_test.exs new file mode 100644 index 0000000..6b61baf --- /dev/null +++ b/test/observer_web/web/live/theme_component_test.exs @@ -0,0 +1,92 @@ +defmodule Observer.Web.ThemeComponentLiveTest do + use Observer.Web.ConnCase, async: false + + import Mox + + alias Observer.Web.Mocks.RpcStubber + alias Observer.Web.Mocks.TelemetryStubber + alias Observer.Web.ThemeComponent + + setup :verify_on_exit! + + test "Check the rendered elements are present", %{conn: conn} do + RpcStubber.defaults() + + TelemetryStubber.defaults() + + {:ok, index_live, _html} = live(conn, "/observer/tracing") + + # Test the rendered output + assert index_live + |> element("#theme-menu-toggle") + |> has_element?() + + assert index_live + |> element("#theme-menu") + |> has_element?() + end + + test "Check cycle theme event", %{conn: conn} do + RpcStubber.defaults() + + TelemetryStubber.defaults() + + {:ok, index_live, _html} = live(conn, "/observer/tracing") + + # NOTE: Since there are some JS events with some back and forward from the client server + # the only way to test the events was calling it directly with the liveview socket + + # Get the socket from the LiveView process + socket = :sys.get_state(index_live.pid).socket + + # Call cycle theme, current light + socket = %{socket | assigns: %{socket.assigns | theme: "light"}} + + {:noreply, _updated_socket} = ThemeComponent.handle_event("cycle-theme", %{}, socket) + + assert_receive {:update_theme, "dark"}, 1_000 + + # Call cycle theme, current dark + socket = %{socket | assigns: %{socket.assigns | theme: "dark"}} + + {:noreply, _updated_socket} = ThemeComponent.handle_event("cycle-theme", %{}, socket) + + assert_receive {:update_theme, "system"}, 1_000 + + # Call cycle theme, current system + socket = %{socket | assigns: %{socket.assigns | theme: "system"}} + + {:noreply, _updated_socket} = ThemeComponent.handle_event("cycle-theme", %{}, socket) + + assert_receive {:update_theme, "light"}, 1_000 + end + + test "Check update theme event", %{conn: conn} do + RpcStubber.defaults() + + TelemetryStubber.defaults() + + {:ok, index_live, _html} = live(conn, "/observer/tracing") + + # NOTE: Since there are some JS events with some back and forward from the client server + # the only way to test the events was calling it directly with the liveview socket + + # Get the socket from the LiveView process + socket = :sys.get_state(index_live.pid).socket + + {:noreply, _socket} = + ThemeComponent.handle_event("update-theme", %{"theme" => "light"}, socket) + + assert_receive {:update_theme, "light"}, 1_000 + + {:noreply, _socket} = + ThemeComponent.handle_event("update-theme", %{"theme" => "dark"}, socket) + + assert_receive {:update_theme, "dark"}, 1_000 + + {:noreply, _socket} = + ThemeComponent.handle_event("update-theme", %{"theme" => "system"}, socket) + + assert_receive {:update_theme, "system"}, 1_000 + end +end