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