empowered.house ↗

status/resource-monitor.ex

Resource monitor

A live resource monitor with CPU and memory lines running across a grid and their current values.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.status_resource_monitor animated />
</div>
default
isometric
three resources · custom copy
one resource · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
title string "Agent runtime"
status string "Live" Next to the pulsing dot, up to 10 characters.
caption string "60 s ago" The label at the left end of the grid.
series list [%{name: "CPU", value: "42%", values: [28, 34, ...]}, ...] One to three `%{name, values}`, the first one lime. Values are on the 0 to `max` scale, oldest first. Optional `value` is the current reading as text, otherwise the last value is written as a percentage.
max any 100 The top of the scale.
Frame props, shared by every composition
Prop Type Default What it does
animated boolean false Turns motion on. Without it the composition renders in its final, still state.
trigger "mount" | "in-view" | "in-view-repeat" "in-view" When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view.
hover boolean false Enables the hover micro-interactions the composition supports.
glow boolean true The warm incandescent halo behind the composition.
fade_out boolean false Fades the bottom edge into the surface underneath.
isometric boolean false Tilts the composition into the isometric plane of the platform stack.
theme "dark" | "light" "dark" `dark` for the site canvas, `light` for the cream reversed surface.
class string nil Extra classes for the frame.
rest global — Any other HTML attribute, set on the frame.

Source

Two files. Copy them into your app if you would rather own the code than depend on the library.

lib/eh_visuals/status/resource-monitor.ex
resource-monitor.ex
defmodule EHVisuals.Status.ResourceMonitor do
  @moduledoc """
  A live resource monitor: one line per resource running across a grid, with
  the current value of each above it.
  """

  use EHVisuals.Composition,
    name: "status/resource-monitor",
    title: "Resource monitor",
    description:
      "A live resource monitor with CPU and memory lines running across a grid and their current values."

  @series [
    %{
      name: "CPU",
      value: "42%",
      values: [28, 34, 31, 45, 52, 47, 38, 41, 58, 63, 49, 44, 51, 46, 39, 35, 43, 55, 48, 42]
    },
    %{
      name: "Memory",
      value: "6.4 GB",
      values: [54, 54, 55, 55, 56, 57, 57, 58, 58, 59, 60, 60, 61, 61, 61, 62, 62, 63, 63, 64]
    }
  ]

  # The grid: 340 wide from x = 20, 100 tall down to y = 206.
  @left 20
  @width 340
  @bottom 206
  @tall 100

  attr(:title, :string, default: "Agent runtime")
  attr(:status, :string, default: "Live", doc: "Next to the pulsing dot, up to 10 characters.")
  attr(:caption, :string, default: "60 s ago", doc: "The label at the left end of the grid.")

  attr(:series, :list,
    default: @series,
    doc:
      "One to three `%{name, values}`, the first one lime. Values are on the 0 to `max` scale, oldest first. Optional `value` is the current reading as text, otherwise the last value is written as a percentage."
  )

  attr(:max, :any, default: 100, doc: "The top of the scale.")

  attr(:label, :string,
    default: "A live monitor with a CPU line and a memory line running across a grid"
  )

  frame_attrs()

  @spec status_resource_monitor(map()) :: Phoenix.LiveView.Rendered.t()
  def status_resource_monitor(assigns) do
    top = if is_number(assigns.max) and assigns.max > 0, do: assigns.max, else: 100

    lines =
      for {series, index} <- assigns.series |> Enum.take(3) |> Enum.with_index() do
        points = points(Map.get(series, :values, []), top)

        %{
          index: index,
          name: series.name,
          value: Map.get(series, :value) || reading(Map.get(series, :values, [])),
          x: 20 + index * 108,
          points: Enum.map_join(points, " ", fn {x, y} -> "#{x},#{y}" end),
          last: List.last(points)
        }
      end

    assigns =
      assigns
      |> assign(:lines, lines)
      |> assign(:status_w, Float.round(String.length(assigns.status) * 5.9, 1))
      |> assign(:rows, for(step <- 0..4, do: @bottom - step * @tall / 4 + 0.5))
      |> assign(:columns, for(step <- 0..10, do: @left + step * @width / 10 + 0.5))
      |> assign(:box, %{left: @left, right: @left + @width, top: @bottom - @tall, bottom: @bottom})

    ~H"""
    <.frame name="status/resource-monitor" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 380 240">
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="379" height="239" rx="12" />
          <text class="ehv-t-strong ehv-a-fade ehv-i-1" x="20" y="35" font-size="14">{@title}</text>
          <g class="ehv-a-fade ehv-i-1">
            <circle class="ehv-fill-lime ehv-a-ping" cx={360 - @status_w - 9} cy="30.5" r="3" />
            <circle class="ehv-fill-lime" cx={360 - @status_w - 9} cy="30.5" r="3" />
            <text class="ehv-mono ehv-t-lime" x="360" y="33.5" font-size="8.5" text-anchor="end">
              {String.upcase(@status)}
            </text>
          </g>
          <g :for={line <- @lines} class={"ehv-a-fade ehv-i-#{line.index + 2}"}>
            <line
              class={["ehv-round", "ehv-status-resource-monitor__line--#{line.index}"]}
              x1={line.x}
              x2={line.x + 12}
              y1="59.5"
              y2="59.5"
            />
            <text class="ehv-mono" x={line.x + 18} y="62.5" font-size="8">
              {String.upcase(line.name)}
            </text>
            <text class="ehv-t-display" x={line.x} y="89" font-size="22">{line.value}</text>
          </g>
          <g class="ehv-a-fade ehv-i-3">
            <line
              :for={x <- @columns}
              class="ehv-status-resource-monitor__grid"
              x1={x}
              x2={x}
              y1={@box.top}
              y2={@box.bottom}
            />
            <line
              :for={y <- @rows}
              class="ehv-status-resource-monitor__grid"
              x1={@box.left}
              x2={@box.right}
              y1={y}
              y2={y}
            />
            <line
              class="ehv-line-soft"
              x1={@box.left}
              x2={@box.right}
              y1={@box.bottom + 0.5}
              y2={@box.bottom + 0.5}
            />
            <text class="ehv-mono" x={@box.left} y="224" font-size="8">
              {String.upcase(@caption)}
            </text>
            <text class="ehv-mono ehv-t-2" x={@box.right} y="224" font-size="8" text-anchor="end">
              NOW
            </text>
          </g>
          <g :for={line <- Enum.reverse(@lines)}>
            <polyline
              :if={line.last}
              class={[
                "ehv-round ehv-a-draw ehv-i-#{line.index + 4}",
                "ehv-status-resource-monitor__line--#{line.index}"
              ]}
              points={line.points}
              pathLength="1"
            />
            <g :if={line.last}>
              <circle
                :if={line.index == 0}
                class="ehv-fill-lime ehv-a-ping ehv-i-14"
                cx={elem(line.last, 0)}
                cy={elem(line.last, 1)}
                r="4"
              />
              <circle
                class={[
                  "ehv-a-pop ehv-i-14",
                  "ehv-status-resource-monitor__dot--#{line.index}"
                ]}
                cx={elem(line.last, 0)}
                cy={elem(line.last, 1)}
                r="3.5"
              />
            </g>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  defp points([], _top), do: []

  defp points(values, top) do
    step = @width / max(length(values) - 1, 1)

    values
    |> Enum.with_index()
    |> Enum.map(fn {value, index} ->
      share = value |> max(0) |> min(top) |> Kernel./(top)
      {Float.round(@left + index * step, 1), Float.round(@bottom - share * @tall, 1)}
    end)
  end

  defp reading([]), do: ""
  defp reading(values), do: "#{List.last(values)}%"
end
assets/css/status/resource-monitor.css
resource-monitor.css
/* status/resource-monitor */
.ehv-status-resource-monitor { --ehv-max-w: 410px; }

/* The first resource is lime, the others step back in ink. */
.ehv-status-resource-monitor__line--0 { fill: none; stroke: var(--ehv-lime); stroke-width: 1.6; }
.ehv-status-resource-monitor__line--1 { fill: none; stroke: var(--ehv-ink-2); stroke-width: 1.25; }
.ehv-status-resource-monitor__line--2 { fill: none; stroke: var(--ehv-ink-4); stroke-width: 1.25; }

.ehv-status-resource-monitor__dot--0 { fill: var(--ehv-lime-fill); }
.ehv-status-resource-monitor__dot--1 { fill: var(--ehv-surface-1); stroke: var(--ehv-ink-2); stroke-width: 1.25; }
.ehv-status-resource-monitor__dot--2 { fill: var(--ehv-surface-1); stroke: var(--ehv-ink-4); stroke-width: 1.25; }

.ehv--hover .ehv-status-resource-monitor__dot--0 { transition: r 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-status-resource-monitor__dot--0 { r: 5; }

/* The grid has to read as a grid on the dark card, so it is a step up from faint. */
.ehv-status-resource-monitor__grid { fill: none; stroke: var(--ehv-border-soft); stroke-width: 1; }