1"use client";
2
3
19import { useState } from "react";
20import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
21import { Check, Copy, Loader2, RotateCcw, Send, X } from "lucide-react";
22import { toast } from "@/stores/ui-store";
23import { apiFetch } from "@/lib/api";
24import { Dialog } from "@/components/dialogs/dialog";
25import { cn } from "@/lib/utils";
26
27export interface DeliveryRow {
28 id: string;
29 webhookId: string;
30 event: string;
31 eventId?: string;
32 status: "PENDING" | "DELIVERED" | "FAILED" | "REJECTED" | "DEAD_LETTER" | string;
33 attempts: number;
34 statusCode: number | null;
35 error: string | null;
36 deliveredAt: string | null;
37 nextRetryAt: string | null;
38 createdAt: string;
39 originDeliveryId: string | null;
40 latencyMs: number | null;
41}
42
43const STATUS_STYLE: Record<string, string> = {
44 DELIVERED: "bg-emerald-500/15 text-emerald-600",
45 FAILED: "bg-destructive/15 text-destructive",
46 REJECTED: "bg-amber-500/15 text-amber-600",
47 DEAD_LETTER: "bg-destructive/25 text-destructive",
48 PENDING: "bg-muted text-muted-foreground"
49};
50
51async function copyText(text: string): Promise<boolean> {
52 try {
53 await navigator.clipboard.writeText(text);
54 return true;
55 } catch {
56 try {
57 const ta = document.createElement("textarea");
58 ta.value = text;
59 ta.setAttribute("readonly", "");
60 ta.style.position = "fixed";
61 ta.style.opacity = "0";
62 document.body.appendChild(ta);
63 ta.select();
64 const ok = document.execCommand("copy");
65 document.body.removeChild(ta);
66 return ok;
67 } catch {
68 return false;
69 }
70 }
71}
72
73function DeliveryDrawer({
74 workspaceId,
75 webhookId,
76 deliveryId,
77 onClose
78}: {
79 workspaceId: string;
80 webhookId: string;
81 deliveryId: string;
82 onClose: () => void;
83}) {
84 const qc = useQueryClient();
85 const [copiedPayload, setCopiedPayload] = useState(false);
86 const [copiedSig, setCopiedSig] = useState(false);
87
88 const detail = useQuery({
89 queryKey: ["workspaces", workspaceId, "webhooks", webhookId, "deliveries", deliveryId],
90 queryFn: async () => {
91 const body = await apiFetch<Record<string, unknown>>(
92 `/workspaces/${workspaceId}/webhooks/${webhookId}/deliveries/${deliveryId}`
93 );
94 return (body.data ?? body) as { payload?: unknown } & Record<string, unknown>;
95 },
96 enabled: !!workspaceId
97 });
98
99 const retry = useMutation({
100 mutationFn: async () => {
101 await apiFetch(`/workspaces/${workspaceId}/webhooks/${webhookId}/deliveries/${deliveryId}/retry`, {
102 method: "POST",
103 body: {}
104 });
105 return { ok: true as const };
106 },
107 onSuccess: () => {
108 toast("success", "Delivery re-queued (attempt counter reset).");
109 qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "webhooks"] });
110 onClose();
111 },
112 onError: (e) => toast("error", (e as Error).message || "Could not retry the delivery.")
113 });
114
115 const row = (detail.data ?? {}) as Partial<DeliveryRow> & { payload?: unknown };
116 const payloadObj = (row.payload ?? {}) as Record<string, unknown>;
117 const ts = typeof payloadObj.occurredAt === "string" ? "t=<occurredAt ms>" : "t=…";
118 const signature = `X-Factory-Signature: ${ts},v1=HMAC-SHA256(secret, "<t>.<body>")`;
119
120 return (
121 <Dialog
122 open
123 title={`Delivery ${deliveryId.slice(0, 8)}…`}
124 description={
125 <span className="font-mono text-xs">
126 {row.event} · {row.status} · attempt {row.attempts}
127 </span>
128 }
129 onClose={onClose}
130 width="max-w-2xl"
131 footer={
132 <div className="flex w-full items-center justify-between gap-2">
133 <span role="status" className="sr-only">
134 {copiedPayload ? "Payload copied" : ""}
135 {copiedSig ? "Signature hint copied" : ""}
136 </span>
137 <button
138 type="button"
139 onClick={onClose}
140 className="inline-flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent"
141 >
142 <X className="size-3.5" aria-hidden /> Close
143 </button>
144 {row.status === "FAILED" || row.status === "REJECTED" || row.status === "DEAD_LETTER" ? (
145 <button
146 type="button"
147 onClick={() => retry.mutate()}
148 disabled={retry.isPending}
149 className="inline-flex items-center gap-1 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
150 >
151 {retry.isPending ? (
152 <Loader2 className="size-3.5 animate-spin" aria-hidden />
153 ) : (
154 <RotateCcw className="size-3.5" aria-hidden />
155 )}
156 Retry delivery
157 </button>
158 ) : null}
159 </div>
160 }
161 >
162 {detail.isLoading ? (
163 <p className="text-sm text-muted-foreground">Loading delivery…</p>
164 ) : (
165 <div className="space-y-3">
166 <div className="grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
167 <div>
168 <p className="text-muted-foreground">Status</p>
169 <span className={cn("mt-0.5 inline-block rounded px-1.5 py-0.5 text-[11px] font-medium", STATUS_STYLE[row.status ?? ""] ?? "bg-muted")}>
170 {row.status}
171 </span>
172 </div>
173 <div>
174 <p className="text-muted-foreground">Attempts</p>
175 <p className="mt-0.5 font-mono">{row.attempts}</p>
176 </div>
177 <div>
178 <p className="text-muted-foreground">HTTP</p>
179 <p className="mt-0.5 font-mono">{row.statusCode ?? "—"}</p>
180 </div>
181 <div>
182 <p className="text-muted-foreground">Latency</p>
183 <p className="mt-0.5 font-mono">{row.latencyMs != null ? `${row.latencyMs} ms` : "—"}</p>
184 </div>
185 </div>
186 {row.nextRetryAt ? (
187 <p className="text-xs text-muted-foreground">
188 Next retry: <span className="font-mono">{new Date(row.nextRetryAt).toLocaleString()}</span>
189 </p>
190 ) : null}
191 {row.error ? (
192 <p className="rounded-md bg-destructive/10 px-2 py-1.5 font-mono text-xs text-destructive">
193 {row.error}
194 </p>
195 ) : null}
196 <div>
197 <div className="mb-1 flex items-center justify-between">
198 <p className="text-xs font-medium text-muted-foreground">Payload (envelope)</p>
199 <button
200 type="button"
201 onClick={async () => {
202 const ok = await copyText(JSON.stringify(payloadObj, null, 2));
203 setCopiedPayload(ok);
204 setTimeout(() => setCopiedPayload(false), 2000);
205 }}
206 className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
207 >
208 {copiedPayload ? (
209 <Check className="size-3 text-emerald-600" aria-hidden />
210 ) : (
211 <Copy className="size-3" aria-hidden />
212 )}
213 Copy
214 </button>
215 </div>
216 <pre className="max-h-64 overflow-auto rounded-md bg-muted/50 p-2 font-mono text-[11px] leading-relaxed">
217 {JSON.stringify(payloadObj, null, 2)}
218 </pre>
219 </div>
220 <div>
221 <div className="mb-1 flex items-center justify-between">
222 <p className="text-xs font-medium text-muted-foreground">
223 Signature to verify (with your secret)
224 </p>
225 <button
226 type="button"
227 onClick={async () => {
228 const ok = await copyText(signature);
229 setCopiedSig(ok);
230 setTimeout(() => setCopiedSig(false), 2000);
231 }}
232 className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
233 >
234 {copiedSig ? (
235 <Check className="size-3 text-emerald-600" aria-hidden />
236 ) : (
237 <Copy className="size-3" aria-hidden />
238 )}
239 Copy
240 </button>
241 </div>
242 <pre className="overflow-x-auto rounded-md bg-muted/50 p-2 font-mono text-[11px]">{signature}</pre>
243 <p className="mt-1 text-[11px] text-muted-foreground">
244 v1 = HMAC-SHA256 over the string <code className="font-mono">"<t>.<rawBody>"</code> using
245 this webhook's signing secret.
246 </p>
247 </div>
248 </div>
249 )}
250 </Dialog>
251 );
252}
253
254export function DeliveryInspector({
255 workspaceId,
256 webhookId
257}: {
258 workspaceId: string;
259 webhookId: string;
260}) {
261 const qc = useQueryClient();
262 const [openId, setOpenId] = useState<string | null>(null);
263
264 const deliveries = useQuery({
265 queryKey: ["workspaces", workspaceId, "webhooks", webhookId, "deliveries"],
266 queryFn: async (): Promise<DeliveryRow[]> => {
267 const body = await apiFetch<{ data?: DeliveryRow[] } | DeliveryRow[]>(
268 `/workspaces/${workspaceId}/webhooks/${webhookId}/deliveries?limit=50`
269 );
270 const b = body as { data?: DeliveryRow[] } | DeliveryRow[];
271 if (Array.isArray(b)) return b;
272 return b.data ?? [];
273 },
274 enabled: !!workspaceId,
275 refetchInterval: 10_000
276 });
277
278 const resubmit = useMutation({
279 mutationFn: async () =>
280 apiFetch<{ resubmitted: number }>(`/workspaces/${workspaceId}/webhooks/${webhookId}/resubmit`, {
281 method: "POST",
282 body: { all_dead: true }
283 }),
284 onSuccess: (r) => {
285 toast("success", `Resubmitted ${r.resubmitted} dead-lettered delivery(ies).`);
286 qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "webhooks"] });
287 },
288 onError: (e) => toast("error", (e as Error).message || "Nothing to resubmit.")
289 });
290
291 const testSend = useMutation({
292 mutationFn: () =>
293 apiFetch<{ deliveryId: string; status: string; statusCode: number | null }>(
294 `/workspaces/${workspaceId}/webhooks/${webhookId}/test`,
295 { method: "POST", body: {} }
296 ),
297 onSuccess: (r) => {
298 toast(
299 r.status === "DELIVERED" ? "success" : "error",
300 `Test delivery ${r.status.toLowerCase()}` +
301 (r.statusCode != null ? ` (HTTP ${r.statusCode})` : "")
302 );
303 qc.invalidateQueries({ queryKey: ["workspaces", workspaceId, "webhooks"] });
304 },
305 onError: (e) => toast("error", (e as Error).message || "Test delivery failed.")
306 });
307
308 const rows = deliveries.data ?? [];
309 const deadCount = rows.filter((r) => r.status === "DEAD_LETTER").length;
310
311 return (
312 <div className="rounded-md border border-border bg-muted/20 p-3">
313 <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
314 <p className="text-xs font-semibold">Delivery log</p>
315 <div className="flex gap-1.5">
316 {deadCount > 0 ? (
317 <button
318 type="button"
319 onClick={() => resubmit.mutate()}
320 disabled={resubmit.isPending}
321 className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
322 >
323 <RotateCcw className="size-3" aria-hidden />
324 Resubmit {deadCount} dead
325 </button>
326 ) : null}
327 <button
328 type="button"
329 onClick={() => testSend.mutate()}
330 disabled={testSend.isPending}
331 className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
332 >
333 {testSend.isPending ? (
334 <Loader2 className="size-3 animate-spin" aria-hidden />
335 ) : (
336 <Send className="size-3" aria-hidden />
337 )}
338 Test send
339 </button>
340 </div>
341 </div>
342 {deliveries.isLoading ? (
343 <p className="text-xs text-muted-foreground">Loading…</p>
344 ) : rows.length === 0 ? (
345 <p className="text-xs text-muted-foreground">No deliveries yet — send a test.</p>
346 ) : (
347 <div className="overflow-x-auto">
348 <table className="w-full text-left text-xs">
349 <caption className="sr-only">Recent deliveries for this webhook</caption>
350 <thead>
351 <tr className="border-b border-border text-[11px] text-muted-foreground">
352 <th scope="col" className="py-1 pr-2 font-medium">Status</th>
353 <th scope="col" className="py-1 pr-2 font-medium">Event</th>
354 <th scope="col" className="py-1 pr-2 font-medium">Attempts</th>
355 <th scope="col" className="py-1 pr-2 font-medium">HTTP</th>
356 <th scope="col" className="py-1 pr-2 font-medium">When</th>
357 <th scope="col" className="py-1 font-medium">Detail</th>
358 </tr>
359 </thead>
360 <tbody>
361 {rows.map((d) => (
362 <tr key={d.id} className="border-b border-border/50 last:border-0">
363 <td className="py-1.5 pr-2">
364 <span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", STATUS_STYLE[d.status] ?? "bg-muted text-muted-foreground")}>
365 {d.status}
366 </span>
367 </td>
368 <td className="py-1.5 pr-2 font-mono">{d.event}</td>
369 <td className="py-1.5 pr-2 font-mono">{d.attempts}</td>
370 <td className="py-1.5 pr-2 font-mono">{d.statusCode ?? "—"}</td>
371 <td className="py-1.5 pr-2 text-muted-foreground">
372 {d.deliveredAt ? new Date(d.deliveredAt).toLocaleString() : new Date(d.createdAt).toLocaleString()}
373 {d.nextRetryAt ? (
374 <span className="block text-[10px]">retry {new Date(d.nextRetryAt).toLocaleTimeString()}</span>
375 ) : null}
376 </td>
377 <td className="py-1.5">
378 <button
379 type="button"
380 onClick={() => setOpenId(d.id)}
381 className="rounded-md border px-2 py-0.5 text-[11px] hover:bg-accent"
382 >
383 Open
384 </button>
385 </td>
386 </tr>
387 ))}
388 </tbody>
389 </table>
390 </div>
391 )}
392
393 {openId ? (
394 <DeliveryDrawer
395 workspaceId={workspaceId}
396 webhookId={webhookId}
397 deliveryId={openId}
398 onClose={() => setOpenId(null)}
399 />
400 ) : null}
401 </div>
402 );
403}
404