"use client"; /** * DeliveryInspector (TASK-0130) — the delivery log for ONE webhook. * * Endpoints (docs/api/webhooks.md §5/§6): * GET …/webhooks/:id/deliveries → { data: DeliveryRow[], nextCursor } * GET …/webhooks/:id/deliveries/:deliveryId → row + full payload * POST …/webhooks/:id/deliveries/:deliveryId/retry (200; 409 if DELIVERED) * POST …/webhooks/:id/resubmit { deliveryId? | all_dead? | window? } (200) * POST …/webhooks/:id/test (200 { deliveryId, status, statusCode, attempts, latencyMs }) * * A "drawer" (dialog semantics) shows the selected delivery's payload JSON + * the exact X-Factory-Signature to verify ("t=…,v1=…" is shown; the secret * is never re-exposed — the user verifies server-side with their own copy). * A11y: table caption, drawer is role=dialog, test-send result is an * aria-live region. */ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, Loader2, RotateCcw, Send, X } from "lucide-react"; import { toast } from "@/stores/ui-store"; import { apiFetch } from "@/lib/api"; import { Dialog } from "@/components/dialogs/dialog"; import { cn } from "@/lib/utils"; export interface DeliveryRow { id: string; webhookId: string; event: string; eventId?: string; status: "PENDING" | "DELIVERED" | "FAILED" | "REJECTED" | "DEAD_LETTER" | string; attempts: number; statusCode: number | null; error: string | null; deliveredAt: string | null; nextRetryAt: string | null; createdAt: string; originDeliveryId: string | null; latencyMs: number | null; } const STATUS_STYLE: Record = { DELIVERED: "bg-emerald-500/15 text-emerald-600", FAILED: "bg-destructive/15 text-destructive", REJECTED: "bg-amber-500/15 text-amber-600", DEAD_LETTER: "bg-destructive/25 text-destructive", PENDING: "bg-muted text-muted-foreground" }; async function copyText(text: string): Promise { try { await navigator.clipboard.writeText(text); return true; } catch { try { const ta = document.createElement("textarea"); ta.value = text; ta.setAttribute("readonly", ""); ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); const ok = document.execCommand("copy"); document.body.removeChild(ta); return ok; } catch { return false; } } } function DeliveryDrawer({ workspaceId, webhookId, deliveryId, onClose }: { workspaceId: string; webhookId: string; deliveryId: string; onClose: () => void; }) { const qc = useQueryClient(); const [copiedPayload, setCopiedPayload] = useState(false); const [copiedSig, setCopiedSig] = useState(false); const detail = useQuery({ queryKey: ["workspaces", workspaceId, "webhooks", webhookId, "deliveries", deliveryId], queryFn: async () => { const body = await apiFetch>( `/workspaces/${workspaceId}/webhooks/${webhookId}/deliveries/${deliveryId}` ); return (body.data ?? body) as { payload?: unknown } & Record; }, enabled: !!workspaceId }); const retry = useMutation({ mutationFn: async () => { await apiFetch(`/workspaces/${workspaceId}/webhooks/${webhookId}/deliveries/${deliveryId}/retry`, { method: "POST", body: {} }); return { ok: true as const }; }, onSuccess: () => { toast("success", "Delivery re-queued (attempt counter reset)."); qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "webhooks"] }); onClose(); }, onError: (e) => toast("error", (e as Error).message || "Could not retry the delivery.") }); const row = (detail.data ?? {}) as Partial & { payload?: unknown }; const payloadObj = (row.payload ?? {}) as Record; const ts = typeof payloadObj.occurredAt === "string" ? "t=" : "t=…"; const signature = `X-Factory-Signature: ${ts},v1=HMAC-SHA256(secret, ".")`; return ( {row.event} · {row.status} · attempt {row.attempts} } onClose={onClose} width="max-w-2xl" footer={
{copiedPayload ? "Payload copied" : ""} {copiedSig ? "Signature hint copied" : ""} {row.status === "FAILED" || row.status === "REJECTED" || row.status === "DEAD_LETTER" ? ( ) : null}
} > {detail.isLoading ? (

Loading delivery…

) : (

Status

{row.status}

Attempts

{row.attempts}

HTTP

{row.statusCode ?? "—"}

Latency

{row.latencyMs != null ? `${row.latencyMs} ms` : "—"}

{row.nextRetryAt ? (

Next retry: {new Date(row.nextRetryAt).toLocaleString()}

) : null} {row.error ? (

{row.error}

) : null}

Payload (envelope)

              {JSON.stringify(payloadObj, null, 2)}
            

Signature to verify (with your secret)

{signature}

v1 = HMAC-SHA256 over the string "<t>.<rawBody>" using this webhook's signing secret.

)}
); } export function DeliveryInspector({ workspaceId, webhookId }: { workspaceId: string; webhookId: string; }) { const qc = useQueryClient(); const [openId, setOpenId] = useState(null); const deliveries = useQuery({ queryKey: ["workspaces", workspaceId, "webhooks", webhookId, "deliveries"], queryFn: async (): Promise => { const body = await apiFetch<{ data?: DeliveryRow[] } | DeliveryRow[]>( `/workspaces/${workspaceId}/webhooks/${webhookId}/deliveries?limit=50` ); const b = body as { data?: DeliveryRow[] } | DeliveryRow[]; if (Array.isArray(b)) return b; return b.data ?? []; }, enabled: !!workspaceId, refetchInterval: 10_000 // the log is live (retries land while you watch) }); const resubmit = useMutation({ mutationFn: async () => apiFetch<{ resubmitted: number }>(`/workspaces/${workspaceId}/webhooks/${webhookId}/resubmit`, { method: "POST", body: { all_dead: true } }), onSuccess: (r) => { toast("success", `Resubmitted ${r.resubmitted} dead-lettered delivery(ies).`); qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "webhooks"] }); }, onError: (e) => toast("error", (e as Error).message || "Nothing to resubmit.") }); const testSend = useMutation({ mutationFn: () => apiFetch<{ deliveryId: string; status: string; statusCode: number | null }>( `/workspaces/${workspaceId}/webhooks/${webhookId}/test`, { method: "POST", body: {} } ), onSuccess: (r) => { toast( r.status === "DELIVERED" ? "success" : "error", `Test delivery ${r.status.toLowerCase()}` + (r.statusCode != null ? ` (HTTP ${r.statusCode})` : "") ); qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "webhooks"] }); }, onError: (e) => toast("error", (e as Error).message || "Test delivery failed.") }); const rows = deliveries.data ?? []; const deadCount = rows.filter((r) => r.status === "DEAD_LETTER").length; return (

Delivery log

{deadCount > 0 ? ( ) : null}
{deliveries.isLoading ? (

Loading…

) : rows.length === 0 ? (

No deliveries yet — send a test.

) : (
{rows.map((d) => ( ))}
Recent deliveries for this webhook
Status Event Attempts HTTP When Detail
{d.status} {d.event} {d.attempts} {d.statusCode ?? "—"} {d.deliveredAt ? new Date(d.deliveredAt).toLocaleString() : new Date(d.createdAt).toLocaleString()} {d.nextRetryAt ? ( retry {new Date(d.nextRetryAt).toLocaleTimeString()} ) : null}
)} {openId ? ( setOpenId(null)} /> ) : null}
); }