Webhook Event Log
A webhook delivery log: status codes as compact pills, rows expand to show the payload, failed rows can be retried.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/event-log.json'use client'
import { useState } from 'react'
import './event-log.css'
export interface WebhookEvent { id: string; type: string; status: number; at: string; ms: number; payload: object }
export interface EventLogProps {
events: WebhookEvent[]
onRetry?: (id: string) => Promise<number>
defaultFilter?: 'all' | 'failed'
fill?: 'dark' | 'light'
className?: string
}
const TEXT: Record<number, string> = { 200: 'OK', 201: 'Created', 204: 'No Content', 400: 'Bad Request', 401: 'Unauthorized', 404: 'Not Found', 410: 'Gone', 500: 'Server Error', 502: 'Bad Gateway', 504: 'Timeout' }
/** Expandable webhook delivery log with retry. */
export function EventLog({ events, onRetry, defaultFilter = 'all', fill = 'dark', className }: EventLogProps) {
const [list, setList] = useState(events)
const [open, setOpen] = useState<string | null>(null)
const [filter, setFilter] = useState(defaultFilter)
const [busy, setBusy] = useState<string | null>(null)
const [msg, setMsg] = useState('')
const shown = list.filter((e) => filter === 'all' || e.status >= 300)
const retry = async (id: string) => {
setBusy(id)
const s = onRetry ? await onRetry(id) : await new Promise<number>((r) => setTimeout(() => r(200), 900))
setList((l) => l.map((e) => (e.id === id ? { ...e, status: s, at: 'just now' } : e)))
setBusy(null); setMsg(`Redelivered: ${s} ${TEXT[s] ?? ''}`)
}
return (
<div className={['sd-evl', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-evl__top">Deliveries
<div className="sd-evl__f" role="radiogroup" aria-label="Filter">{(['all', 'failed'] as const).map((f) => <button key={f} type="button" role="radio" aria-checked={filter === f} onClick={() => setFilter(f)}>{f === 'all' ? 'All' : 'Failed'}</button>)}</div>
</div>
<div className="sd-evl__list">
{shown.map((e) => (
<div key={e.id} className="sd-evl__row">
<button type="button" className="sd-evl__h" aria-expanded={open === e.id} onClick={() => setOpen(open === e.id ? null : e.id)} aria-label={`${e.type}, ${e.status} ${TEXT[e.status] ?? ''}, ${e.at}`}>
<span className="sd-evl__code" data-bad={e.status >= 300 || undefined} data-wait={busy === e.id || undefined}>{busy === e.id ? '···' : e.status}</span>
<span className="sd-evl__type">{e.type}</span>
<span className="sd-evl__at">{e.at}</span>
</button>
<div className="sd-evl__body" data-open={open === e.id || undefined}>
<div>
<pre className="sd-evl__pre">{JSON.stringify(e.payload, null, 2)}</pre>
<div className="sd-evl__meta">
<span>{e.status} {TEXT[e.status] ?? ''} · {e.ms} ms</span>
{e.status >= 300 && <button type="button" className="sd-evl__retry" tabIndex={open === e.id ? 0 : -1} disabled={busy === e.id} onClick={() => retry(e.id)}>{busy === e.id ? 'Retrying…' : 'Retry'}</button>}
</div>
</div>
</div>
</div>
))}
</div>
<span className="sr" aria-live="polite">{msg}</span>
</div>
)
}.sd-evl { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-evl[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-evl button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-evl :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-evl .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-evl { width: min(100%, 340px); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; font-variant-numeric: tabular-nums; }
.sd-evl__top { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--line); font-size: 13px; font-weight: 600; }
.sd-evl__f { display: flex; gap: 2px; padding: 2px; border-radius: 9px; background: var(--soft); }
.sd-evl__f button { height: 26px; padding: 0 9px; border-radius: 7px; font-size: 11.5px; color: var(--mute); }
.sd-evl__f button[aria-checked='true'] { background: var(--bg) !important; color: var(--fg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-evl__list { max-height: 360px; overflow: auto; }
.sd-evl__row { border-bottom: 1px solid var(--line); }
.sd-evl__h { display: grid; grid-template-columns: 46px 1fr auto; gap: 10px; align-items: center; width: 100%; padding: 10px 12px; text-align: left; }
.sd-evl__h:hover, .sd-evl__h[aria-expanded='true'] { background: var(--soft) !important; }
.sd-evl__code { justify-self: start; padding: 3px 7px; border-radius: 6px; font: 600 11.5px/1 ui-monospace, monospace; box-shadow: inset 0 0 0 1px var(--line); }
.sd-evl__code[data-bad] { background: var(--strong); color: var(--on); box-shadow: none; }
.sd-evl__code[data-wait] { animation: sd-evl-pulse 900ms ease-in-out infinite; }
@keyframes sd-evl-pulse { 50% { opacity: 0.4; } }
.sd-evl__type { font: 12.5px/1.2 ui-monospace, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sd-evl__at { font-size: 11.5px; color: var(--mute); }
.sd-evl__body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 260ms ease; }
.sd-evl__body[data-open] { grid-template-rows: 1fr; }
.sd-evl__body > div { overflow: hidden; }
.sd-evl__pre { margin: 0 12px; padding: 10px; border-radius: 10px; background: var(--soft); font: 11.5px/1.5 ui-monospace, monospace; white-space: pre; overflow-x: auto; }
.sd-evl__meta { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px 12px; font-size: 12px; color: var(--mute); }
.sd-evl__retry { height: 28px; padding: 0 12px; border-radius: 8px; background: var(--strong) !important; color: var(--on) !important; font-weight: 600; font-size: 12px; }
.sd-evl__retry:disabled { opacity: 0.5; }
@media (prefers-reduced-motion: reduce) { .sd-evl *, .sd-evl *::before, .sd-evl *::after { animation: none !important; transition: none !important; } }Overview
Each row shows event type, status code and time. Expanding a row reveals the JSON payload and response time; failed deliveries offer Retry, which shows a pending state then the new result.
A filter toggles between all, succeeded and failed deliveries.
When to use
- Developer dashboards, integrations, API platforms.
When not to use
- Large volumes: use a paginated data table with search.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/event-log.json$
npx shadcn@latest add https://sundaeui.dev/r/event-log.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/event-log.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/event-log.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy event-log.tsx and event-log.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { EventLog } from '@/components/sundae/event-log'
<EventLog />Props
| Prop | Type | Default |
|---|---|---|
events*Deliveries. | { id: string; type: string; status: number; at: string; ms: number; payload: object }[] | — |
onRetryRedeliver; resolves with the new status. | (id: string) => Promise<number> | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Rows are disclosure buttons; status is spoken as “200 OK” rather than a colour.
- Retry results are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-evl; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Webhook Event Log free?
- Yes. Webhook Event Log is free for personal and commercial projects, with full source on this page.
- How do I install Webhook Event Log?
- Run npx shadcn@latest add https://sundaeui.dev/r/event-log.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Webhook Event Log support keyboard and screen readers?
- Rows are disclosure buttons; status is spoken as “200 OK” rather than a colour. Retry results are announced.
- Does Webhook Event Log respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →JSON Viewer
Data · RefinedLog Stream
Data · RefinedStacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined - Pro
Tick Gauge
Data · Refined Countdown Timer
Data · Refined