api/logs.ex
Logs
Services streaming into one log console, with lines at INFO, WARN and ERROR levels.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.api_logs animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title |
string | "orders · production" |
|
status |
string | "Live" |
|
services |
list | ["API", "Worker", "ERP sync"] |
One to four service names, up to 8 characters each. |
lines |
list | [%{time: "09:41:02", level: "info", text: "api POST /orders 201 in 38 ms"}, %{time: "09:41:03", ...}, ...] |
Up to eight `%{time, level, text}`. Level is `info`, `warn` or `error`. Text up to 34 characters. |
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/api/logs.ex
defmodule EHVisuals.Api.Logs do
@moduledoc """
Services on the left streaming into one log console on the right, with a
line per event at its level.
"""
use EHVisuals.Composition,
name: "api/logs",
title: "Logs",
description:
"Services streaming into one log console, with lines at INFO, WARN and ERROR levels."
@lines [
%{time: "09:41:02", level: "info", text: "api POST /orders 201 in 38 ms"},
%{time: "09:41:03", level: "info", text: "worker invoice INV-1042 posted"},
%{time: "09:41:05", level: "warn", text: "erp-sync slow reply, retry 2 of 5"},
%{time: "09:41:07", level: "error", text: "erp-sync timeout after 30 s"},
%{time: "09:41:08", level: "info", text: "worker job requeued in 60 s"},
%{time: "09:41:09", level: "info", text: "api GET /stock 200 in 12 ms"}
]
attr(:title, :string, default: "orders · production")
attr(:status, :string, default: "Live")
attr(:services, :list,
default: ["API", "Worker", "ERP sync"],
doc: "One to four service names, up to 8 characters each."
)
attr(:lines, :list,
default: @lines,
doc:
"Up to eight `%{time, level, text}`. Level is `info`, `warn` or `error`. Text up to 34 characters."
)
attr(:label, :string,
default: "Three services streaming into a log console with one warning and one error"
)
frame_attrs()
@spec api_logs(map()) :: Phoenix.LiveView.Rendered.t()
def api_logs(assigns) do
rows = assigns.lines |> Enum.take(8) |> Enum.with_index()
names = Enum.take(assigns.services, 4)
count = max(length(names), 1)
height = max(84 + length(rows) * 24, count * 44 + 16)
mid = height / 2
pitch = min(60, (height - 16) / count)
nodes =
for {name, index} <- Enum.with_index(names) do
y = Float.round(mid + (index - (count - 1) / 2) * pitch, 1)
%{
name: name,
index: index,
y: y,
path: "M94.5 #{y} C124 #{y} 120 #{mid} 150 #{mid}"
}
end
assigns =
assigns
|> assign(:rows, rows)
|> assign(:nodes, nodes)
|> assign(:mid, mid)
|> assign(:height, height)
|> assign(:caret_y, 53 + length(rows) * 24)
|> assign(:pill_w, Float.round(24 + String.length(assigns.status) * 5.8, 1))
~H"""
<.frame name="api/logs" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 460 #{@height}"}>
<g class="ehv-h-lift">
<path
:for={node <- @nodes}
class={"ehv-line ehv-a-draw ehv-i-#{node.index + 2}"}
d={node.path}
pathLength="1"
/>
<g :if={@animated} class="ehv-smil ehv-a-fade ehv-i-8">
<circle :for={node <- @nodes} class="ehv-fill-lime" r="2.5" opacity="0">
<animateMotion
path={node.path}
dur="2.6s"
begin={"#{node.index * 0.8}s"}
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0;1;1;0"
keyTimes="0;0.2;0.8;1"
dur="2.6s"
begin={"#{node.index * 0.8}s"}
repeatCount="indefinite"
/>
</circle>
</g>
<g :for={node <- @nodes} class={"ehv-a-slide ehv-i-#{node.index + 1}"}>
<rect
class="ehv-card ehv-api-logs__chip"
x="10.5"
y={node.y - 17}
width="84"
height="34"
rx="8"
/>
<rect class="ehv-node-soft" x="20.5" y={node.y - 4} width="8" height="8" rx="2.5" />
<text class="ehv-mono ehv-t-2" x="36" y={node.y + 3} font-size="8.5">
{String.upcase(node.name)}
</text>
</g>
<rect
class="ehv-card ehv-a-rise"
x="150.5"
y="0.5"
width="299"
height={@height - 1}
rx="12"
/>
<circle class="ehv-node ehv-a-pop ehv-i-3" cx="150.5" cy={@mid} r="3.5" />
<g class="ehv-a-fade ehv-i-2">
<text class="ehv-mono" x="166" y="24" font-size="8.5">{String.upcase(@title)}</text>
<rect
class="ehv-fill-lime-soft"
x={438 - @pill_w}
y="10"
width={@pill_w}
height="20"
rx="6"
/>
<circle class="ehv-fill-lime ehv-a-pulse" cx={438 - @pill_w + 11} cy="20" r="2.5" />
<text class="ehv-mono ehv-t-lime" x="430" y="23.5" font-size="8.5" text-anchor="end">
{String.upcase(@status)}
</text>
<line class="ehv-line-soft" x1="150.5" x2="449.5" y1="39.5" y2="39.5" />
</g>
<g :for={{line, index} <- @rows} class={"ehv-a-fade ehv-i-#{index + 4}"}>
<rect
:if={line.level == "error"}
class="ehv-fill-coral-soft"
x="158"
y={47 + index * 24}
width="284"
height="22"
rx="5"
/>
<text class="ehv-code ehv-t-3" x="166" y={62 + index * 24} font-size="9">
{line.time}
</text>
<text class={["ehv-code", level(line.level)]} x="214" y={62 + index * 24} font-size="9">
{String.upcase(line.level)}
</text>
<text class={["ehv-code", tone(line.level)]} x="250" y={62 + index * 24} font-size="9">
{line.text}
</text>
</g>
<rect
class="ehv-fill-lime ehv-a-pulse ehv-i-12"
x="166"
y={@caret_y}
width="6"
height="11"
rx="1.5"
/>
</g>
</svg>
</.frame>
"""
end
defp level("error"), do: "ehv-t-coral ehv-t-strong"
defp level("warn"), do: "ehv-t-strong"
defp level(_level), do: "ehv-t-3"
defp tone("error"), do: "ehv-t-coral"
defp tone("warn"), do: nil
defp tone(_level), do: "ehv-t-2"
end
assets/css/api/logs.css
/* api/logs */
.ehv-api-logs { --ehv-max-w: 520px; --ehv-iso-scale: 0.82; --ehv-step: 110ms; }
.ehv--hover .ehv-api-logs__chip { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-api-logs__chip:hover { stroke: var(--ehv-lime); }