status/resource-monitor.ex
Resource monitor
A live resource monitor with CPU and memory lines running across a grid and their current values.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.status_resource_monitor animated />
</div>
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
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
/* 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; }