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
"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


Files
"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


Files
"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


Files
"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

