empowered.house ↗

api/logs.ex

Logs

Services streaming into one log console, with lines at INFO, WARN and ERROR levels.

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

<div class="h-96 rounded-xl border border-border">
  <.api_logs animated />
</div>
default
isometric
two services · all quiet
four services · hover · no glow
light surface
isometric · fade out · still

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
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
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); }