The portal timeline is now available

Webhook Dashboards, Ready to Paste

The Repost portal's surfaces as blocks: delivery history, endpoint management, the dead-letter queue and the event catalog. Wired to your data, owned by you. Open Source.

Files
components/webhook-dashboard.tsx
"use client";

import {
  RepostPortalProvider,
  type PortalClient,
  useDeliveryAttempts,
  useLogsFeed,
  useLogsHistogram,
  usePortalContext,
  useReplayDelivery,
} from "@repost/portal-react";
import { useCallback, useMemo, useState } from "react";
import { DeliveryDetail, RowDetailSheet } from "@/components/delivery-detail";
import { DeliveryHistoryTable, deliveryHistoryRowId } from "@/components/delivery-history-table";
import {
  TimelineHistogram,
  useTimelineScale,
  type TimelineDateRange,
  type TimelineRange,
  type TimelineSeries,
  type TimelineSlot,
} from "@/components/timeline-histogram";
import { WebhookMetricsCards } from "@/components/webhook-metrics-cards";
import { StatusIndicator } from "@/components/ui/status-indicator";
import { DEFAULT_LIVE_DATE_WINDOW, type LiveDateWindow } from "@/hooks/live-date-window";
import { cn } from "@/lib/utils";
import type { WebhookLogRow } from "@/lib/webhook-types";

export type WebhookDashboardProps = {
  /**
   * Return a portal access token minted by your backend. Called on load, on
   * expiry, and after auth failures — never expose your Repost API key to
   * the browser.
   */
  getToken?: () => Promise<string>;
  /**
   * A pre-built client, instead of `getToken` — used by previews and tests to
   * run the block against fixtures.
   */
  client?: PortalClient;
  /** Narrow the dashboard to one endpoint. */
  endpointId?: string;
  /** How far back the timeline can browse. Default 90 days. */
  historyWindowDays?: number;
  /** The dashboard fills its parent — size it there (e.g. "h-[720px]"). */
  className?: string;
};

/** The portal's status classes, stacked in the timeline. */
const DELIVERY_SERIES: TimelineSeries[] = [
  { key: "2xx", label: "2xx", color: "var(--status-success)", statuses: ["2xx"] },
  { key: "3xx", label: "3xx", color: "var(--status-info)", statuses: ["3xx"] },
  { key: "4xx", label: "4xx", color: "var(--status-warning)", statuses: ["4xx"] },
  { key: "5xx", label: "5xx", color: "var(--status-error)", statuses: ["5xx"] },
];

/**
 * The full webhook dashboard, wired the way the Repost portal wires it:
 * headline metrics, the timeline histogram with its drag-to-select scrubber,
 * the live virtualized delivery history, and per-delivery attempt detail.
 * All data flows through @repost/portal-react hooks at the top of this file
 * and into purely presentational components as props — swap any part freely.
 */
export function WebhookDashboard({ getToken, endpointId, historyWindowDays = 90, client, className }: WebhookDashboardProps) {
  return (
    <RepostPortalProvider {...(client ? { client } : { getToken: getToken! })}>
      <WebhookDashboardContent endpointId={endpointId} historyWindowDays={historyWindowDays} className={className} />
    </RepostPortalProvider>
  );
}

/** `timestamp:[a TO b]` — the query clause the portal API filters logs with. */
const timestampClause = (range: TimelineRange): string => `timestamp:[${range.from.toISOString()} TO ${range.to.toISOString()}]`;

const inferIntervalSecs = (slots: TimelineSlot[], dateFrom: Date, dateTo: Date): number => {
  if (slots.length > 1) {
    return Math.max(1, Math.round((slots[1]!.timestamp.getTime() - slots[0]!.timestamp.getTime()) / 1000));
  }
  return Math.max(1, Math.round((dateTo.getTime() - dateFrom.getTime()) / 1000));
};

function WebhookDashboardContent({
  endpointId,
  historyWindowDays,
  className,
}: {
  endpointId?: string;
  historyWindowDays: number;
  className?: string;
}) {
  // ── Timeline state: the pinned range (null = live window) and the zoom ────
  const [selection, setSelection] = useState<TimelineRange | null>(null);
  const [liveDateWindow, setLiveDateWindow] = useState<LiveDateWindow>(DEFAULT_LIVE_DATE_WINDOW);
  const [live, setLive] = useState(true);

  const {
    scale,
    defaultRange,
    options,
    activeOptionKey,
    selectSpan,
    frameOptions,
    activeFrameKey,
    selectFrame,
    jump,
    canJumpBack,
    canJumpForward,
  } = useTimelineScale({ selection, liveDateWindow, planWindowDays: historyWindowDays });

  const visibleSelection = selection ?? defaultRange;

  // ── Data: histogram over the visible frame, feed over the pinned range ────
  const context = usePortalContext();
  const histogram = useLogsHistogram({ endpointId, dateFrom: scale.from, dateTo: scale.to });
  const feedQuery = selection ? timestampClause(selection) : undefined;
  const feed = useLogsFeed({ endpointId, query: feedQuery, live });

  const [selectedRow, setSelectedRow] = useState<WebhookLogRow | null>(null);
  const attempts = useDeliveryAttempts(selectedRow?.deliveryId ?? null);
  const replay = useReplayDelivery();

  const slots = useMemo<TimelineSlot[]>(
    () =>
      (histogram.data?.slots ?? []).map((slot) => ({
        timestamp: new Date(slot.timestamp),
        total: slot.total,
        counts: slot.counts,
      })),
    [histogram.data],
  );
  const intervalSecs = inferIntervalSecs(slots, scale.from, scale.to);

  // ── Handlers ──────────────────────────────────────────────────────────────
  const handleApplyRange = useCallback((range: TimelineRange) => setSelection(range), []);
  const handleSelectSpan = useCallback(
    (option: (typeof options)[number]) => {
      selectSpan(option);
      setLiveDateWindow(option.key);
      setSelection(null);
    },
    [selectSpan],
  );
  const handleDateRangeChange = useCallback((range: TimelineDateRange | undefined) => {
    setSelection(range?.from && range.to ? { from: range.from, to: range.to } : null);
  }, []);
  const handleRowClick = (row: WebhookLogRow) => {
    replay.reset();
    setSelectedRow((current) => (current && deliveryHistoryRowId(current) === deliveryHistoryRowId(row) ? null : row));
  };

  const dateRange = useMemo(
    () => ({
      value: { from: visibleSelection.from, to: visibleSelection.to },
      onChange: handleDateRangeChange,
      isUserRange: selection !== null,
      endLabel: selection === null ? "now" : undefined,
    }),
    [handleDateRangeChange, selection, visibleSelection.from, visibleSelection.to],
  );

  return (
    <div className={cn("flex h-full min-h-0 flex-col gap-4", className)}>
      <header className="flex items-center gap-2">
        <h2 className="text-lg font-semibold">{context.data ? `${context.data.appName} webhooks` : "Webhooks"}</h2>
        <span className="ml-auto inline-flex items-center gap-1.5 text-xs text-muted-foreground">
          <StatusIndicator color={feed.status === "live" ? "emerald" : "amber"} pulse={feed.status === "live"} />
          {feed.status === "live" ? "Live" : feed.status === "off" ? "Paused" : feed.status}
        </span>
      </header>

      <WebhookMetricsCards slots={histogram.data?.slots ?? []} isLoading={histogram.isLoading} />

      <TimelineHistogram
        slots={histogram.isLoading && slots.length === 0 ? undefined : slots}
        intervalSecs={intervalSecs}
        dateFrom={scale.from}
        dateTo={scale.to}
        series={DELIVERY_SERIES}
        isLoading={histogram.isLoading}
        selection={visibleSelection}
        onApplyRange={handleApplyRange}
        scaleOptions={options}
        activeOptionKey={activeOptionKey}
        onSelectSpan={handleSelectSpan}
        frameOptions={frameOptions}
        activeFrameKey={activeFrameKey}
        onSelectFrame={selectFrame}
        onJump={jump}
        canJumpBack={canJumpBack}
        canJumpForward={canJumpForward}
        emptyMessage="No deliveries in range"
        dateRange={dateRange}
      />

      <DeliveryHistoryTable
        className="min-h-0 flex-1"
        rows={feed.rows}
        isLoading={feed.isLoading}
        onRowClick={handleRowClick}
        selectedRowId={selectedRow ? deliveryHistoryRowId(selectedRow) : null}
        onLoadOlder={feed.hasMore ? feed.loadOlder : undefined}
        isLoadingOlder={feed.isLoadingMore}
        streaming={live && feed.status !== "off"}
        streamStatus={feed.status}
        streamDroppedCount={feed.droppedCount}
        onToggleStream={() => setLive((value) => !value)}
        onClearStreamDropped={feed.refresh}
      />

      {/* The portal's golden drawer: clicking a row opens the delivery
          aggregate — state header, attempt trail, request/response
          inspectors — in the non-modal right-hand sheet. */}
      <RowDetailSheet
        open={selectedRow !== null}
        onClose={() => {
          setSelectedRow(null);
          replay.reset();
        }}
        title="Delivery details"
        description="Delivery attempt detail view"
      >
        {selectedRow && (
          <DeliveryDetail
            deliveryId={selectedRow.deliveryId}
            initialAttempt={{ generation: selectedRow.generation, attempt: selectedRow.attempt }}
            delivery={attempts.data?.delivery ?? null}
            attempts={attempts.data?.attempts ?? []}
            isLoading={attempts.isLoading}
            isError={attempts.isError}
            onReplay={(delivery) => replay.mutate({ deliveryId: delivery.id })}
            replayPending={replay.isPending}
            replayQueued={replay.data !== undefined}
            replayError={replay.error?.message ?? null}
          />
        )}
      </RowDetailSheet>
    </div>
  );
}
Headline metrics, the portal's timeline histogram with drag-to-select scrubber, live virtualized delivery history, and attempt detail.
webhook-dashboard
webhook-dashboard
Files
components/endpoint-management.tsx
"use client";

import {
  RepostPortalProvider,
  type PortalClient,
  useCreateEndpoint,
  useDeleteEndpoint,
  useDlqCountByEndpoint,
  useEndpoints,
  usePauseEndpoint,
  useResumeEndpoint,
  useRevealEndpointSecret,
  useUpdateEndpoint,
} from "@repost/portal-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { EndpointForm, type EndpointFormValues } from "@/components/endpoint-form";
import { EndpointsList, type EndpointListItem } from "@/components/endpoints-list";

export type EndpointManagementProps = {
  /** Return a portal access token minted by your backend. */
  getToken?: () => Promise<string>;
  /**
   * A pre-built client, instead of `getToken` — used by previews and tests to
   * run the block against fixtures.
   */
  client?: PortalClient;
};

/**
 * Endpoint management, wired: list with status + DLQ badges, create/edit
 * form, pause/resume/delete, and secret reveal. Hooks at the top, props
 * down — every presentational piece is swappable.
 */
export function EndpointManagement({ getToken, client }: EndpointManagementProps) {
  return (
    <RepostPortalProvider {...(client ? { client } : { getToken: getToken! })}>
      <EndpointManagementContent />
    </RepostPortalProvider>
  );
}

type PanelState = { mode: "closed" } | { mode: "create" } | { mode: "edit"; endpoint: EndpointListItem };

function EndpointManagementContent() {
  const [panel, setPanel] = useState<PanelState>({ mode: "closed" });
  const [revealedSecret, setRevealedSecret] = useState<string | null>(null);

  const endpoints = useEndpoints();
  const dlqCounts = useDlqCountByEndpoint();
  const create = useCreateEndpoint();
  const update = useUpdateEndpoint();
  const remove = useDeleteEndpoint();
  const pause = usePauseEndpoint();
  const resume = useResumeEndpoint();
  const reveal = useRevealEndpointSecret();

  const rows = useMemo<EndpointListItem[]>(() => {
    const countByEndpoint = new Map((dlqCounts.data ?? []).map((entry) => [entry.endpointId, entry.count]));
    return (endpoints.data ?? []).map((endpoint) => ({
      id: endpoint.id,
      url: endpoint.url,
      description: endpoint.description ?? null,
      subscriptions: endpoint.subscriptions,
      status: endpoint.status,
      dlqCount: countByEndpoint.get(endpoint.id) ?? 0,
    }));
  }, [endpoints.data, dlqCounts.data]);

  const busyEndpointId = pause.isPending || resume.isPending || remove.isPending || reveal.isPending ? "*" : null;

  const submitForm = (values: EndpointFormValues) => {
    if (panel.mode === "edit") {
      update.mutate({ endpointId: panel.endpoint.id, ...values });
    } else {
      create.mutate(values);
    }
    setPanel({ mode: "closed" });
  };

  return (
    <div className="flex flex-col gap-4">
      <header className="flex items-center justify-between">
        <h2 className="text-lg font-semibold">Endpoints</h2>
        <Button size="sm" onClick={() => setPanel(panel.mode === "create" ? { mode: "closed" } : { mode: "create" })}>
          {panel.mode === "create" ? "Close" : "Add endpoint"}
        </Button>
      </header>

      {panel.mode !== "closed" && (
        <div className="rounded-md border border-border bg-card p-4">
          <EndpointForm
            key={panel.mode === "edit" ? panel.endpoint.id : "create"}
            initialValues={panel.mode === "edit" ? { ...panel.endpoint, description: panel.endpoint.description ?? undefined } : undefined}
            onSubmit={submitForm}
            isSubmitting={create.isPending || update.isPending}
            error={create.error?.message ?? update.error?.message ?? null}
            submitLabel={panel.mode === "edit" ? "Save changes" : "Create endpoint"}
          />
        </div>
      )}

      {create.data?.secret && (
        <SecretNotice label="Endpoint created. Signing secret (shown once):" secret={create.data.secret} onDismiss={() => create.reset()} />
      )}
      {revealedSecret && <SecretNotice label="Active signing secret:" secret={revealedSecret} onDismiss={() => setRevealedSecret(null)} />}

      <EndpointsList
        endpoints={rows}
        isLoading={endpoints.isLoading}
        busyEndpointId={busyEndpointId}
        onEdit={(endpoint) => setPanel({ mode: "edit", endpoint })}
        onPause={(endpoint) => pause.mutate({ endpointId: endpoint.id })}
        onResume={(endpoint) => resume.mutate({ endpointId: endpoint.id })}
        onDelete={(endpoint) => remove.mutate({ endpointId: endpoint.id })}
        onRevealSecret={(endpoint) =>
          reveal.mutateAsync({ endpointId: endpoint.id }).then((secrets) => {
            const active = secrets.versions.find((version) => version.active) ?? secrets.versions[0];
            setRevealedSecret(active?.whsec ?? null);
          })
        }
      />
    </div>
  );
}

function SecretNotice({ label, secret, onDismiss }: { label: string; secret: string; onDismiss: () => void }) {
  return (
    <div className="flex items-center gap-2 rounded-md border border-status-warning/40 bg-status-warning-muted p-3 text-sm">
      <span className="shrink-0 text-muted-foreground">{label}</span>
      <code className="min-w-0 flex-1 truncate font-mono">{secret}</code>
      <Button variant="ghost" size="sm" onClick={onDismiss}>
        Dismiss
      </Button>
    </div>
  );
}
Endpoint list, create/edit form, pause, resume, and secrets.
endpoint-management
endpoint-management
Files
components/webhook-dlq.tsx
"use client";

import { RepostPortalProvider, type PortalClient, useDlq, useDlqCount, useReplayDelivery } from "@repost/portal-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DlqPanel } from "@/components/dlq-panel";

export type WebhookDlqProps = {
  /** Return a portal access token minted by your backend. */
  getToken?: () => Promise<string>;
  /**
   * A pre-built client, instead of `getToken` — used by previews and tests to
   * run the block against fixtures.
   */
  client?: PortalClient;
  /** Narrow to one endpoint. */
  endpointId?: string;
};

/**
 * The dead-letter queue, wired: count badge, exhausted deliveries, and
 * one-click replay. Hooks at the top, props down.
 */
export function WebhookDlq({ getToken, endpointId, client }: WebhookDlqProps) {
  return (
    <RepostPortalProvider {...(client ? { client } : { getToken: getToken! })}>
      <WebhookDlqContent endpointId={endpointId} />
    </RepostPortalProvider>
  );
}

function WebhookDlqContent({ endpointId }: { endpointId?: string }) {
  const [replayingId, setReplayingId] = useState<string | null>(null);
  const dlq = useDlq({ endpointId });
  const count = useDlqCount({ endpointId });
  const replay = useReplayDelivery();

  return (
    <div className="flex flex-col gap-3">
      <header className="flex items-center gap-2">
        <h2 className="text-lg font-semibold">Dead-letter queue</h2>
        {count.data !== undefined && count.data > 0 && (
          <Badge variant="error" typography="mono">
            {count.data}
          </Badge>
        )}
      </header>

      <DlqPanel
        deliveries={(dlq.items ?? []).map((delivery) => ({
          id: delivery.id,
          type: delivery.type,
          endpointId: delivery.endpointId,
          attemptCount: delivery.attemptCount,
          lastStatusCode: delivery.lastStatusCode,
          lastErrorClass: delivery.lastErrorClass,
          createdAt: delivery.createdAt,
        }))}
        isLoading={dlq.isLoading}
        replayingId={replayingId}
        onReplay={(delivery) => {
          setReplayingId(delivery.id);
          replay
            .mutateAsync({ deliveryId: delivery.id })
            .finally(() => setReplayingId(null));
        }}
      />

      {dlq.hasNextPage && (
        <Button variant="outline" size="sm" disabled={dlq.isFetchingNextPage} onClick={dlq.fetchNextPage}>
          {dlq.isFetchingNextPage ? "Loading…" : "Load more"}
        </Button>
      )}
    </div>
  );
}
Dead-lettered deliveries with one-click replay.
webhook-dlq
webhook-dlq
Files
components/event-catalog.tsx
"use client";

import { RepostPortalProvider, type PortalClient, useEndpoints, useEventTypes, useSendSampleEvent } from "@repost/portal-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { EventCatalogBrowser, type CatalogEventType } from "@/components/event-catalog-browser";

export type EventCatalogProps = {
  /** Return a portal access token minted by your backend. */
  getToken?: () => Promise<string>;
  /**
   * A pre-built client, instead of `getToken` — used by previews and tests to
   * run the block against fixtures.
   */
  client?: PortalClient;
};

/**
 * The event catalog, wired: browse the environment's event types with
 * schema-derived samples and send signed test events to the customer's
 * first endpoint. Hooks at the top, props down.
 */
export function EventCatalog({ getToken, client }: EventCatalogProps) {
  return (
    <RepostPortalProvider {...(client ? { client } : { getToken: getToken! })}>
      <EventCatalogContent />
    </RepostPortalProvider>
  );
}

function EventCatalogContent() {
  const [sendingEventTypeId, setSendingEventTypeId] = useState<string | null>(null);
  const [lastOutcome, setLastOutcome] = useState<string | null>(null);

  const eventTypes = useEventTypes();
  const endpoints = useEndpoints();
  const sendSample = useSendSampleEvent();

  const targetEndpoint = useMemo(() => (endpoints.data ?? []).find((endpoint) => endpoint.status === "ENABLED"), [endpoints.data]);

  const handleSendSample = (eventType: CatalogEventType, samplePayload: unknown) => {
    if (!targetEndpoint) {
      setLastOutcome("No enabled endpoint to send to — create one first.");
      return;
    }
    setSendingEventTypeId(eventType.id);
    sendSample
      .mutateAsync({
        eventTypeId: eventType.id,
        destination: { kind: "endpoint", endpointId: targetEndpoint.id },
        data: samplePayload,
      })
      .then((result) =>
        setLastOutcome(result.delivered ? `Delivered to ${targetEndpoint.url} (${result.status})` : `Failed: ${result.error ?? result.status}`),
      )
      .catch((error: Error) => setLastOutcome(`Failed: ${error.message}`))
      .finally(() => setSendingEventTypeId(null));
  };

  return (
    <div className="flex flex-col gap-3">
      <header className="flex items-center gap-2">
        <h2 className="text-lg font-semibold">Event catalog</h2>
        {lastOutcome && (
          <Badge variant={lastOutcome.startsWith("Delivered") ? "success" : "warning"} size="sm">
            {lastOutcome}
          </Badge>
        )}
      </header>
      <EventCatalogBrowser
        eventTypes={eventTypes.data ?? []}
        isLoading={eventTypes.isLoading}
        onSendSample={handleSendSample}
        sendingEventTypeId={sendingEventTypeId}
      />
    </div>
  );
}
Event types with sample payloads and signed test sends.
event-catalog
event-catalog