api/webhook.ex
Webhook
An event delivered to an endpoint with retries: failed attempts, the backoff between them and the delivery.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.api_webhook animated />
</div>
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
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
/* 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); }