empowered.house ↗

api/webhook.ex

Webhook

An event delivered to an endpoint with retries: failed attempts, the backoff between them and the delivery.

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

<div class="h-96 rounded-xl border border-border">
  <.api_webhook animated />
</div>
default
isometric
delivered first time · custom copy
still retrying · hover
gave up · light surface
isometric · fade out · still

Props

Prop Type Default What it does
eyebrow string "Webhook delivery"
event string "order.approved" Up to 16 characters.
endpoint string "hooks.northwind.com/orders" Up to 28 characters.
attempts list [%{status: "failed", wait: "30 s", result: "503"}, %{status: "failed", ...}, ...] Two to five `%{status, result, wait}`. Status is `failed`, `delivered` or `pending`. `wait` is the backoff before the next attempt.
outcome any nil The footer line. Left empty, it is written from the attempts.
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/webhook.ex
webhook.ex
defmodule EHVisuals.Api.Webhook do
  @moduledoc """
  One webhook delivery: the event, the endpoint it goes to, every attempt on
  a rail with the backoff between them, and how it ended.
  """

  use EHVisuals.Composition,
    name: "api/webhook",
    title: "Webhook",
    description:
      "An event delivered to an endpoint with retries: failed attempts, the backoff between them and the delivery."

  @attempts [
    %{status: "failed", result: "503", wait: "30 s"},
    %{status: "failed", result: "Timeout", wait: "2 min"},
    %{status: "delivered", result: "200 OK"}
  ]

  attr(:eyebrow, :string, default: "Webhook delivery")
  attr(:event, :string, default: "order.approved", doc: "Up to 16 characters.")
  attr(:endpoint, :string, default: "hooks.northwind.com/orders", doc: "Up to 28 characters.")

  attr(:attempts, :list,
    default: @attempts,
    doc:
      "Two to five `%{status, result, wait}`. Status is `failed`, `delivered` or `pending`. `wait` is the backoff before the next attempt."
  )

  attr(:outcome, :any,
    default: nil,
    doc: "The footer line. Left empty, it is written from the attempts."
  )

  attr(:label, :string,
    default: "A webhook that fails twice and is delivered on the third attempt"
  )

  frame_attrs()

  @spec api_webhook(map()) :: Phoenix.LiveView.Rendered.t()
  def api_webhook(assigns) do
    list = Enum.take(assigns.attempts, 5)
    count = length(list)
    gap = if count > 1, do: 268 / (count - 1), else: 0

    items =
      for {attempt, index} <- Enum.with_index(list) do
        wait = Map.get(attempt, :wait)

        %{
          index: index,
          status: attempt.status,
          result: Map.get(attempt, :result, ""),
          wait: wait,
          wait_w: if(wait, do: Float.round(16 + String.length(wait) * 5.1, 1), else: 0),
          x: Float.round(if(count > 1, do: 56 + index * gap, else: 190.0), 1),
          next_x: Float.round(56 + (index + 1) * gap, 1),
          last: index == count - 1
        }
      end

    statuses = Enum.map(items, & &1.status)
    event_w = Float.round(20 + String.length(assigns.event) * 6.0, 1)

    assigns =
      assigns
      |> assign(:items, items)
      |> assign(:tone, tone(statuses))
      |> assign(:footer, assigns.outcome || outcome(statuses))
      |> assign(:event_w, event_w)

    ~H"""
    <.frame name="api/webhook" 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-mono ehv-a-fade ehv-i-1" x="20" y="30" font-size="9">
            {String.upcase(@eyebrow)}
          </text>
          <g class="ehv-a-fade ehv-i-2">
            <rect class="ehv-card-3" x="20.5" y="44.5" width={@event_w} height="27" rx="7" />
            <text class="ehv-code" x="30.5" y="62" font-size="10">{@event}</text>
            <line
              class="ehv-line-ink"
              x1={@event_w + 30}
              x2={@event_w + 48}
              y1="58"
              y2="58"
            />
            <path class="ehv-fill-2" d={"M#{@event_w + 48} 54 l7 4 l-7 4 z"} />
            <text class="ehv-code ehv-t-2" x={@event_w + 64} y="62" font-size="10">{@endpoint}</text>
          </g>
          <line class="ehv-line-faint" x1="20" x2="360" y1="88.5" y2="88.5" />

          <g :for={item <- @items}>
            <g :if={not item.last} class={"ehv-a-fade ehv-i-#{min(item.index * 3 + 4, 24)}"}>
              <line
                class="ehv-line ehv-dash"
                x1={item.x + 14}
                x2={item.next_x - 14}
                y1="124"
                y2="124"
              />
              <g :if={item.wait}>
                <rect
                  class="ehv-well"
                  x={(item.x + item.next_x) / 2 - item.wait_w / 2}
                  y="115.5"
                  width={item.wait_w}
                  height="17"
                  rx="5"
                />
                <text
                  class="ehv-mono ehv-t-2"
                  x={(item.x + item.next_x) / 2}
                  y="127"
                  font-size="7.5"
                  text-anchor="middle"
                >
                  {String.upcase(item.wait)}
                </text>
              </g>
            </g>
            <g class={"ehv-a-pop ehv-i-#{min(item.index * 3 + 3, 24)}"}>
              <g :if={item.status == "failed"}>
                <circle class="ehv-fill-coral-soft" cx={item.x} cy="124" r="11" />
                <path class="ehv-line-coral ehv-round" d={"M#{item.x - 4} 120 l8 8 m0 -8 l-8 8"} />
              </g>
              <g :if={item.status == "delivered"}>
                <circle class="ehv-fill-lime-soft" cx={item.x} cy="124" r="11" />
                <path
                  class="ehv-line-lime ehv-round"
                  d={"M#{item.x - 4.6} 124 l3.2 3.2 l5.8 -6.6"}
                />
              </g>
              <g :if={item.status not in ["failed", "delivered"]}>
                <circle class="ehv-line ehv-dash" cx={item.x} cy="124" r="10.5" />
                <circle class="ehv-fill-3 ehv-a-pulse" cx={item.x} cy="124" r="2.5" />
              </g>
            </g>
            <g class={"ehv-a-fade ehv-i-#{min(item.index * 3 + 3, 24)}"}>
              <text
                class={[item.status not in ["failed", "delivered"] && "ehv-t-3"]}
                x={item.x}
                y="156"
                font-size="11"
                text-anchor="middle"
              >
                Attempt {item.index + 1}
              </text>
              <text
                class={["ehv-mono", result(item.status)]}
                x={item.x}
                y="170"
                font-size="7.5"
                text-anchor="middle"
              >
                {String.upcase(item.result)}
              </text>
            </g>
          </g>

          <g class="ehv-a-rise ehv-i-12">
            <rect
              class={"ehv-api-webhook__outcome--#{@tone}"}
              x="20"
              y="190"
              width="340"
              height="30"
              rx="6"
            />
            <text
              class={["ehv-mono", "ehv-api-webhook__outcome-text--#{@tone}"]}
              x="190"
              y="208.5"
              font-size="9"
              text-anchor="middle"
            >
              {String.upcase(@footer)}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  defp result("failed"), do: "ehv-t-coral"
  defp result("delivered"), do: "ehv-t-lime"
  defp result(_status), do: nil

  defp tone(statuses) do
    cond do
      "delivered" in statuses -> "go"
      Enum.all?(statuses, &(&1 == "failed")) -> "stop"
      true -> "wait"
    end
  end

  defp outcome(statuses) do
    case tone(statuses) do
      "go" -> "Delivered on attempt #{Enum.find_index(statuses, &(&1 == "delivered")) + 1}"
      "stop" -> "Gave up after #{length(statuses)} attempts"
      "wait" -> "Retrying · attempt #{length(statuses)} of #{length(statuses)}"
    end
  end
end
assets/css/api/webhook.css
webhook.css
/* api/webhook */
.ehv-api-webhook { --ehv-max-w: 410px; --ehv-step: 130ms; }

.ehv-api-webhook__outcome--go { fill: var(--ehv-lime-fill); }
.ehv-api-webhook__outcome--wait { fill: var(--ehv-canvas); stroke: var(--ehv-border-soft); stroke-width: 1; }
.ehv-api-webhook__outcome--stop { fill: var(--ehv-coral-soft); stroke: var(--ehv-coral); stroke-width: 1; }

.ehv svg .ehv-mono.ehv-api-webhook__outcome-text--go { fill: var(--ehv-on-lime); }
.ehv svg .ehv-mono.ehv-api-webhook__outcome-text--wait { fill: var(--ehv-ink-2); }
.ehv svg .ehv-mono.ehv-api-webhook__outcome-text--stop { fill: var(--ehv-coral); }