Webhooks
blockdevopsWebhook manager in two SectionCards: endpoints with event chips, live success rate and a pause/resume switch, plus a delivery log pairing relative timestamps with HTTP status pills, durations, retry counts and retry actions. Includes an add-endpoint dialog. Stateful demo — swap the stub data for your source.
Installation
$pnpm dlx uipkge-ng@latest add webhooks$npx uipkge-ng@latest add webhooks$yarn dlx uipkge-ng@latest add webhooks$bunx uipkge-ng@latest add webhooksManifest:
https://uipkge.dev/r/angular/webhooks.jsonInstalls to:src/app/components/blocks/webhooks/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
WebhookEndpointinterface WebhookEndpoint {
id: string
url: string
enabled: boolean
events: WebhookEvent[]
successRate: string
}WebhookDeliveryinterface WebhookDelivery {
id: string
endpointId: string
event: WebhookEvent
status: number
durationMs: number
retries: number
date: Date
}npm dependencies
Files installed (2)
src/app/components/blocks/webhooks/webhooks.component.ts10.9 kB
// Webhook manager in two SectionCards: endpoints with event chips, live success rate // and a pause/resume switch, plus a delivery log pairing relative timestamps with HTTP // status pills, durations, retry counts and retry actions. Includes an add-endpoint dialog. // Port of the React block 1:1. import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core' import { LucideAngularModule, Plus, RotateCw, Webhook } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiChipComponent } from '@/ui/chip/chip.component' import { UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, } from '@/ui/dialog/dialog.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiRelativeTimeComponent } from '@/ui/relative-time/relative-time.component' import { UiSectionCardComponent } from '@/ui/section-card/section-card.component' export type WebhookEvent = 'order.created' | 'order.refunded' | 'customer.updated' | 'invoice.paid' export interface WebhookEndpoint { id: string url: string enabled: boolean events: WebhookEvent[] successRate: string } export interface WebhookDelivery { id: string endpointId: string event: WebhookEvent status: number durationMs: number retries: number date: Date } const now = Date.now() const minutesAgo = (m: number) => new Date(now - m * 60_000) const stubEndpoints: WebhookEndpoint[] = [ { id: 'e1', url: 'https://api.acme.com/hooks/orders', enabled: true, events: ['order.created', 'order.refunded'], successRate: '99.2% · 24h', }, { id: 'e2', url: 'https://hooks.billing.io/acme', enabled: true, events: ['invoice.paid'], successRate: '97.8% · 24h', }, { id: 'e3', url: 'https://staging.acme.dev/hooks/all', enabled: false, events: ['order.created', 'customer.updated', 'invoice.paid'], successRate: '—', }, ] const stubDeliveries: WebhookDelivery[] = [ { id: 'd1', endpointId: 'e1', event: 'order.created', status: 200, durationMs: 312, retries: 0, date: minutesAgo(2) }, { id: 'd2', endpointId: 'e2', event: 'invoice.paid', status: 200, durationMs: 188, retries: 0, date: minutesAgo(9) }, { id: 'd3', endpointId: 'e1', event: 'order.refunded', status: 500, durationMs: 10_240, retries: 2, date: minutesAgo(26), }, { id: 'd4', endpointId: 'e1', event: 'order.created', status: 404, durationMs: 96, retries: 1, date: minutesAgo(41) }, { id: 'd5', endpointId: 'e2', event: 'invoice.paid', status: 200, durationMs: 244, retries: 0, date: minutesAgo(58) }, { id: 'd6', endpointId: 'e3', event: 'customer.updated', status: 410, durationMs: 51, retries: 0, date: minutesAgo(75), }, ] const allEvents: WebhookEvent[] = ['order.created', 'order.refunded', 'customer.updated', 'invoice.paid'] function cloneEndpoints(list: WebhookEndpoint[]): WebhookEndpoint[] { return list.map((e) => ({ ...e, events: [...e.events] })) } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-webhooks, [ui-webhooks]', standalone: true, // React renders the root <div> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiChipComponent, UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, UiInputComponent, UiRelativeTimeComponent, UiSectionCardComponent, ], template: ` <div data-slot="webhooks" [class]="rootClass"> <div ui-section-card title="Endpoints" description="HTTP endpoints that receive signed webhook payloads."> <button ui-button size="sm" slot="header-action" (click)="createOpen.set(true)"> <lucide-icon [img]="Plus" aria-hidden="true" /> Add endpoint </button> <ul class="-mb-4 divide-y"> @for (endpoint of endpointList(); track endpoint.id) { <li class="flex items-center gap-3 py-4"> <span class="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md" aria-hidden="true" > <lucide-icon [img]="Webhook" class="size-4" /> </span> <div class="min-w-0 flex-1"> <p class="truncate font-mono text-xs font-medium">{{ endpoint.url }}</p> <div class="mt-1.5 flex flex-wrap items-center gap-1.5"> @for (event of endpoint.events; track event) { <span ui-chip variant="outline" size="sm">{{ event }}</span> } </div> </div> <div class="hidden text-right sm:block"> <p class="text-xs font-medium">{{ endpoint.enabled ? endpoint.successRate : 'Paused' }}</p> <p class="text-muted-foreground text-xs">{{ endpoint.events.length }} events</p> </div> <button type="button" role="switch" [attr.aria-checked]="endpoint.enabled" [attr.aria-label]="endpoint.enabled ? 'Pause endpoint' : 'Resume endpoint'" [class]="switchClass(endpoint.enabled)" (click)="toggleEndpoint(endpoint)" > <span class="bg-background inline-block size-4 translate-y-0.5 rounded-full shadow transition-transform" [class]="endpoint.enabled ? 'translate-x-[18px]' : 'translate-x-0.5'" ></span> </button> </li> } </ul> </div> <div ui-section-card title="Recent deliveries" description="Latest payload attempts across all endpoints."> <ul class="-my-4 divide-y"> @for (delivery of deliveryList(); track delivery.id) { <li class="flex flex-wrap items-center gap-3 py-3"> <ui-relative-time [date]="delivery.date" class="text-muted-foreground w-16 shrink-0 text-xs" /> <span ui-badge [variant]="statusTone(delivery.status)" class="w-12 shrink-0 justify-center font-mono"> {{ delivery.status }} </span> <p class="min-w-[10rem] flex-1 truncate text-sm">{{ delivery.event }}</p> <p class="text-muted-foreground hidden w-20 shrink-0 text-right font-mono text-xs sm:block"> {{ formatDuration(delivery.durationMs) }} </p> @if (delivery.retries > 0) { <p class="text-warning shrink-0 text-xs font-medium">retry {{ delivery.retries }}</p> } @if (delivery.status >= 400) { <button ui-button variant="ghost" size="icon-sm" aria-label="Retry delivery"> <lucide-icon [img]="RotateCw" aria-hidden="true" /> </button> } </li> } @if (deliveryList().length === 0) { <li class="text-muted-foreground py-8 text-center text-xs"> No deliveries yet — payloads will appear here once endpoints receive events. </li> } </ul> </div> <ui-dialog [open]="createOpen()" (openChange)="createOpen.set($event)"> <ui-dialog-content> <div ui-dialog-header> <h2 ui-dialog-title>Add endpoint</h2> <p ui-dialog-description>Choose the events this URL should receive.</p> </div> <ui-input [value]="newUrl()" (valueChange)="newUrl.set($event)" placeholder="https://api.yourapp.com/hooks/…" class="font-mono text-xs" /> <div class="flex flex-wrap gap-2"> @for (event of allEvents; track event) { <button type="button" class="focus-visible:ring-ring rounded-full focus-visible:ring-2 focus-visible:outline-none" (click)="toggleEvent(event)" > <span ui-chip [variant]="newEvents().includes(event) ? 'filled' : 'outline'">{{ event }}</span> </button> } </div> <div ui-dialog-footer> <button ui-button variant="outline" (click)="createOpen.set(false)">Cancel</button> <button ui-button [disabled]="!newUrl() || newEvents().length === 0" (click)="createEndpoint()"> Create </button> </div> </ui-dialog-content> </ui-dialog> </div> `, }) export class UiWebhooksComponent { protected readonly Plus = Plus protected readonly RotateCw = RotateCw protected readonly Webhook = Webhook protected readonly allEvents = allEvents @Input() set endpoints(v: WebhookEndpoint[] | undefined) { this.endpointList.set(cloneEndpoints(v ?? stubEndpoints)) } @Input() set deliveries(v: WebhookDelivery[] | undefined) { this.deliveryList.set(v ?? stubDeliveries) } @Input('class') className?: string readonly endpointList = signal<WebhookEndpoint[]>(cloneEndpoints(stubEndpoints)) readonly deliveryList = signal<WebhookDelivery[]>(stubDeliveries) readonly createOpen = signal(false) readonly newUrl = signal('') readonly newEvents = signal<WebhookEvent[]>([]) readonly endpointCount = computed(() => this.endpointList().length) get rootClass(): string { return cn('space-y-4', this.className) } switchClass(enabled: boolean): string { return cn( 'focus-visible:ring-ring relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none focus-visible:ring-2', enabled ? 'bg-primary' : 'bg-muted-foreground/30', ) } toggleEndpoint(endpoint: WebhookEndpoint): void { this.endpointList.update((list) => list.map((e) => (e.id === endpoint.id ? { ...e, enabled: !e.enabled } : e))) } toggleEvent(event: WebhookEvent): void { this.newEvents.update((events) => (events.includes(event) ? events.filter((e) => e !== event) : [...events, event])) } createEndpoint(): void { if (!this.newUrl() || this.newEvents().length === 0) return this.endpointList.update((list) => [ ...list, { id: `e${list.length + 1}`, url: this.newUrl(), enabled: true, events: [...this.newEvents()], successRate: '—' }, ]) this.newUrl.set('') this.newEvents.set([]) this.createOpen.set(false) } statusTone(status: number): 'success' | 'warning' | 'destructive' { if (status < 300) return 'success' if (status < 500) return 'warning' return 'destructive' } formatDuration(durationMs: number): string { return durationMs >= 1000 ? `${(durationMs / 1000).toFixed(1)} s` : `${durationMs} ms` } }src/app/components/blocks/webhooks/index.ts0.1 kB
export { UiWebhooksComponent, type WebhookEvent, type WebhookEndpoint, type WebhookDelivery, } from './webhooks.component'
Raw manifest:https://uipkge.dev/r/angular/webhooks.json