Menu

Change language

GitHub repo

Webhooks

blockdevops

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. Stateful demo — swap the stub data for your source.

Installation

$npx uipkge-ng@latest add webhooks
Manifest:https://uipkge.dev/r/angular/webhooks.jsonInstalls to:src/app/components/blocks/webhooks/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

WebhookEndpoint
interface WebhookEndpoint {
  id: string
  url: string
  enabled: boolean
  events: WebhookEvent[]
  successRate: string
}
WebhookDelivery
interface WebhookDelivery {
  id: string
  endpointId: string
  event: WebhookEvent
  status: number
  durationMs: number
  retries: number
  date: Date
}

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

Raw manifest:https://uipkge.dev/r/angular/webhooks.json