defmodule Observer.Web.Ets.Page do @moduledoc """ This is the live component responsible for the ETS pillar: every ETS table on a selected node with its metadata (owner, protection, type, size, memory), searchable and sortable, plus an optional bounded content preview gated behind the `:table_content_inspection` config (see `ObserverWeb.Ets` - table contents are live production data, so previews are off by default). """ @behaviour Observer.Web.Page use Observer.Web, :live_component alias Observer.Web.Components.Attention alias Observer.Web.Components.Core alias Observer.Web.Page alias ObserverWeb.Ets alias ObserverWeb.Mnesia @impl Phoenix.LiveComponent def render(assigns) do assigns = assign(assigns, :source, source_atom(assigns.form.params["source"])) ~H"""
<:inner_form> <.form for={@form} id="ets-update-form" class="flex flex-col md:flex-row md:items-end shrink-0 ml-2 mr-2 py-2 text-xs text-center text-zinc-800 dark:text-white whitespace-nowrap gap-x-5 gap-y-1" phx-change="form-update" > <:inner_button>
Mnesia is not running on {@form.params["service"]}.
Could not list tables on {@form.params["service"]}: {inspect(@tables_error)}
Collecting ETS tables...
Tables: {length(@tables)} Total memory: {format_bytes(Enum.sum(Enum.map(@tables, & &1.memory)))} Showing: {length(@rows)}
<:col :let={{table, _index}} label="NAME"> <:col :let={{table, _index}} label="TYPE">{table.type} <:col :let={{table, _index}} label={if @source == :mnesia, do: "STORAGE", else: "PROTECTION"} > {if @source == :mnesia, do: table.storage, else: table.protection} <:col :let={{table, _index}} label="OWNER"> <:col :let={{table, _index}} label="OBJECTS">{table.size} <:col :let={{table, _index}} label="MEMORY">{format_bytes(table.memory)} <:col :let={{_table, index}} label="">

Table {inspect(@details.table.name)}

{label}: {value}
Content inspection is disabled. Table contents are live production data - to enable bounded, read-only previews, set config :observer_web, table_content_inspection: true.
This table's contents are not accessible (private table, or it no longer exists).

First objects (bounded preview)

The table is empty.
  • {object}
""" end @impl Page def handle_mount(socket) when is_connected?(socket) do :net_kernel.monitor_nodes(true) send(self(), :ets_refresh) assign_defaults(socket) end def handle_mount(socket) do assign_defaults(socket) end defp assign_defaults(socket) do socket |> assign(:services, services()) |> assign(:tables, nil) |> assign(:rows, nil) |> assign(:details, nil) |> assign(:tables_error, nil) |> assign( :form, to_form(%{ "source" => "ets", "service" => to_string(Node.self()), "sort_by" => "memory", "search" => "" }) ) 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 assign(socket, :page_title, "ETS") end @impl Page def handle_parent_event("form-update", params, socket) do service_changed? = params["service"] != socket.assigns.form.params["service"] or params["source"] != socket.assigns.form.params["source"] socket = assign(socket, :form, to_form(params)) if service_changed? do send(self(), :ets_refresh) {:noreply, socket |> assign(:tables, nil) |> assign(:rows, nil) |> assign(:details, nil)} else {:noreply, assign_rows(socket)} end end def handle_parent_event("ets-refresh", _params, socket) do send(self(), :ets_refresh) {:noreply, socket} end def handle_parent_event("ets-select-row", %{"index" => index}, socket) do case Enum.at(socket.assigns.rows || [], positive_int(index, 0)) do nil -> {:noreply, socket} table -> node = selected_service(socket) content = case source(socket) do :mnesia -> Mnesia.table_content(node, table.name) :ets -> Ets.table_content(node, table.handle) end {:noreply, assign(socket, :details, %{table: table, content: content})} end end def handle_parent_event("ets-details-close", _params, socket) do {:noreply, assign(socket, :details, nil)} end @impl Page def handle_info(:ets_refresh, socket) do case list_tables(socket) do {:ok, tables} -> {:noreply, socket |> assign(:tables, tables) |> assign(:tables_error, nil) |> assign_rows()} {:error, reason} -> {:noreply, assign(socket, :tables_error, reason)} end end def handle_info({:nodeup, _node}, socket) do {:noreply, assign(socket, :services, services())} end def handle_info({:nodedown, node}, %{assigns: %{form: form}} = socket) do socket = assign(socket, :services, services()) if to_string(node) == form.params["service"] do send(self(), :ets_refresh) params = %{form.params | "service" => to_string(Node.self())} {:noreply, socket |> assign(:form, to_form(params)) |> assign(:details, nil)} else {:noreply, socket} end end defp assign_rows(%{assigns: %{tables: nil}} = socket), do: socket defp assign_rows(%{assigns: %{tables: tables, form: form}} = socket) do search = String.downcase(form.params["search"] || "") sort_by = case form.params["sort_by"] do "size" -> :size "name" -> :name _memory -> :memory end rows = tables |> Enum.filter( &(search == "" or String.contains?(String.downcase(inspect(&1.name)), search)) ) |> sort_rows(sort_by) assign(socket, :rows, rows) end defp sort_rows(tables, :name), do: Enum.sort_by(tables, &inspect(&1.name)) defp sort_rows(tables, key), do: Enum.sort_by(tables, &Map.fetch!(&1, key), :desc) defp source(socket), do: source_atom(socket.assigns.form.params["source"]) defp source_atom("mnesia"), do: :mnesia defp source_atom(_ets), do: :ets defp details_badges(table, :ets) do [ {"type", table.type}, {"protection", table.protection}, {"owner", table.owner_label}, {"objects", table.size}, {"memory", format_bytes(table.memory)}, {"compressed", table.compressed} ] end defp details_badges(table, :mnesia) do [ {"type", table.type}, {"storage", table.storage}, {"owner", table.owner_label}, {"objects", table.size}, {"memory", format_bytes(table.memory)}, {"index", inspect(table.index)} ] end defp list_tables(socket) do node = selected_service(socket) case source(socket) do :mnesia -> Mnesia.list_tables(node) :ets -> Ets.list_tables(node) end end defp services do Enum.map([Node.self() | Node.list()], &{&1, to_string(&1)}) end # The form's service value only becomes a node if it matches a currently known node - a # crafted payload (or a node that just left the cluster) safely falls back to the local one. defp selected_service(socket) do service = socket.assigns.form.params["service"] [Node.self() | Node.list()] |> Enum.find(Node.self(), &(to_string(&1) == service)) end defp positive_int(value, default) do case Integer.parse(value || "") do {int, ""} when int >= 0 -> int _invalid -> default end end defp attention_msg do assigns = %{} ~H""" Every ETS table on the selected service with its owner, protection, type and memory footprint. Table contents are live production data: previews are read-only, bounded to the first objects, and disabled unless explicitly enabled in the configuration. Data is collected on demand - press REFRESH to update. """ end defp format_bytes(bytes) when bytes >= 1_073_741_824, do: "#{Float.round(bytes / 1_073_741_824, 1)} GB" defp format_bytes(bytes) when bytes >= 1_048_576, do: "#{Float.round(bytes / 1_048_576, 1)} MB" defp format_bytes(bytes) when bytes >= 1_024, do: "#{Float.round(bytes / 1_024, 1)} KB" defp format_bytes(bytes), do: "#{bytes} B" end