defmodule Observer.Web.System.Page do @moduledoc """ This is the live component responsible for the System pillar: a read-only snapshot of a selected node's runtime information, resource counts against their VM limits, and per-allocator carrier utilization - the web equivalent of the observer GUI's System and Memory Allocators tabs. """ @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.SystemInfo @impl Phoenix.LiveComponent def render(assigns) do ~H"""
<:inner_form> <.form for={@form} id="system-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>
Could not read {@form.params["service"]}: {inspect(@snapshot_error)}
Collecting system information...
{label}: {value}

Limits

<:col :let={limit} label="RESOURCE">{limit.name} <:col :let={limit} label="COUNT">{limit.count} <:col :let={limit} label="LIMIT">{limit.limit} <:col :let={limit} label="USED">
{limit.percent}%
Operating system data (load averages, CPU, OS memory and disks) requires the :os_mon application on the selected service. Add :os_mon to your extra_applications to enable it.

Operating System

OS: {@os_data.os} {label}: {value}
OS Memory
{@os_data.memory.used_percent}% of {format_bytes(@os_data.memory.total_bytes)} used ({format_bytes( @os_data.memory.available_bytes )} available)
<:col :let={cpu} label="CPU">{cpu.id} <:col :let={cpu} label="UTILIZATION">
{cpu.busy_percent}%
<:col :let={disk} label="DISK">{disk.mount} <:col :let={disk} label="SIZE">{format_bytes(disk.total_kbytes * 1_024)} <:col :let={disk} label="USED">
{disk.capacity_percent}%

Memory Allocators

<:col :let={alloc} label="ALLOCATOR">{alloc.name} <:col :let={alloc} label="BLOCKS SIZE">{format_bytes(alloc.blocks_size)} <:col :let={alloc} label="CARRIERS SIZE">{format_bytes(alloc.carriers_size)} <:col :let={alloc} label="UTILIZATION"> {alloc.utilization_percent}% -
""" end @impl Page def handle_mount(socket) when is_connected?(socket) do :net_kernel.monitor_nodes(true) socket |> assign_defaults() |> restart_tick_chain() end def handle_mount(socket) do assign_defaults(socket) end defp assign_defaults(socket) do socket |> assign(:services, services()) |> assign(:node_info, nil) |> assign(:limits, []) |> assign(:allocators, []) |> assign(:os_data, nil) |> assign(:snapshot_error, nil) |> assign(:tick_gen, 0) |> assign( :form, to_form(%{"service" => to_string(Node.self()), "refresh_seconds" => "5"}) ) 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, "System") end @impl Page def handle_parent_event("form-update", params, socket) do {:noreply, socket |> assign(:form, to_form(params)) |> restart_tick_chain()} end def handle_parent_event("system-refresh", _params, socket) do {:noreply, restart_tick_chain(socket)} end # Refresh ticks carry a generation counter (same pattern as the Network and Logs pillars): # changing any control bumps the generation and starts a new timer chain, so a tick from a # cancelled chain that was already in flight is ignored instead of spawning a second chain. defp restart_tick_chain(socket) do tick_gen = socket.assigns.tick_gen + 1 send(self(), {:system_tick, tick_gen}) assign(socket, :tick_gen, tick_gen) end @impl Page def handle_info({:system_tick, gen}, %{assigns: %{tick_gen: gen}} = socket) do node = selected_service(socket) socket = case SystemInfo.node_info(node) do {:ok, node_info} -> socket |> assign(:node_info, node_info) |> assign(:limits, SystemInfo.limits(node)) |> assign(:allocators, SystemInfo.allocators(node)) |> assign(:os_data, fetch_os_data(node)) |> assign(:snapshot_error, nil) {:error, reason} -> assign(socket, :snapshot_error, reason) end refresh_seconds = positive_int(socket.assigns.form.params["refresh_seconds"], 0) if refresh_seconds > 0 do Process.send_after(self(), {:system_tick, gen}, refresh_seconds * 1_000) end {:noreply, socket} end def handle_info({:system_tick, _stale_gen}, socket) do {:noreply, socket} 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 {:noreply, socket |> assign(:form, to_form(Map.put(form.params, "service", to_string(Node.self())))) |> restart_tick_chain()} else {:noreply, socket} end end defp positive_int(value, default) do case Integer.parse(value || "") do {int, ""} when int >= 0 -> int _invalid -> default end end defp fetch_os_data(node) do case SystemInfo.os_data(node) do {:ok, os_data} -> os_data {:error, :os_mon_not_started} -> :os_mon_not_started {:error, _reason} -> nil 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 attention_msg do assigns = %{} ~H""" A read-only snapshot of the selected service: runtime information, resource usage against the VM limits, memory allocator carrier utilization (low utilization on a busy allocator is a sign of fragmentation) and operating system data when :os_mon is running. The snapshot refreshes automatically at the selected interval - press REFRESH to update immediately, or pause the interval. """ end defp usage_color(percent) when percent >= 90, do: "bg-red-500" defp usage_color(percent) when percent >= 70, do: "bg-yellow-500" defp usage_color(_percent), do: "bg-green-500" defp format_uptime(ms) do seconds = div(ms, 1_000) days = div(seconds, 86_400) hours = div(rem(seconds, 86_400), 3_600) minutes = div(rem(seconds, 3_600), 60) cond do days > 0 -> "#{days}d #{hours}h #{minutes}m" hours > 0 -> "#{hours}h #{minutes}m" true -> "#{minutes}m #{rem(seconds, 60)}s" end 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