test-fire-button.tsx131 lines · main
1'use client';
2
3import { useRouter } from 'next/navigation';
4import { useState, useTransition } from 'react';
5
6interface Props {
7 projectId: string;
8 endpointId: string;
9 apiOrigin: string;
10}
11
12interface TestFireResult {
13 ok: boolean;
14 status: number | null;
15 durationMs: number;
16 responseBody: string | null;
17 networkError: string | null;
18}
19
20export function TestFireButton({ projectId, endpointId, apiOrigin }: Props) {
21 const router = useRouter();
22 const [phase, setPhase] = useState<'idle' | 'firing'>('idle');
23 const [result, setResult] = useState<TestFireResult | null>(null);
24 const [, startTransition] = useTransition();
25
26 async function fire() {
27 setPhase('firing');
28 setResult(null);
29 try {
30 const res = await fetch(
31 `${apiOrigin}/v1/projects/${projectId}/webhooks/${endpointId}/test-fire`,
32 { method: 'POST', credentials: 'include' },
33 );
34 const json = (await res.json()) as TestFireResult;
35 setResult(json);
36 // Refresh the server component so the new delivery row (recorded
37 // by the public route during the round-trip) lands in the
38 // deliveries log without an extra reload.
39 startTransition(() => router.refresh());
40 } catch (err) {
41 setResult({
42 ok: false,
43 status: null,
44 durationMs: 0,
45 responseBody: null,
46 networkError: err instanceof Error ? err.message : 'fetch failed',
47 });
48 } finally {
49 setPhase('idle');
50 }
51 }
52
53 return (
54 <>
55 <button
56 type="button"
57 onClick={fire}
58 disabled={phase === 'firing'}
59 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)] disabled:opacity-50"
60 >
61 {phase === 'firing' ? 'firing…' : 'test fire'}
62 </button>
63
64 {result ? (
65 <div className="md:col-span-2">
66 <TestResult result={result} onDismiss={() => setResult(null)} />
67 </div>
68 ) : null}
69 </>
70 );
71}
72
73function TestResult({
74 result,
75 onDismiss,
76}: {
77 result: TestFireResult;
78 onDismiss: () => void;
79}) {
80 const tone = result.ok
81 ? 'border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] text-[var(--color-primary)]'
82 : 'border-[var(--color-error)] text-[var(--color-error)]';
83 const label = result.networkError
84 ? `network · ${result.networkError}`
85 : result.status === null
86 ? 'no response'
87 : `${result.status}${result.ok ? ' ok' : ''}`;
88
89 return (
90 <div className="mt-2 flex flex-col gap-2 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-3">
91 <div className="flex items-center justify-between gap-2">
92 <div className="flex items-center gap-2">
93 <span
94 className={`rounded-full border px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider ${tone}`}
95 >
96 {label}
97 </span>
98 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
99 {result.durationMs}ms
100 </span>
101 </div>
102 <button
103 type="button"
104 onClick={onDismiss}
105 className="font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
106 >
107 dismiss
108 </button>
109 </div>
110 {result.responseBody ? (
111 <details className="font-mono text-[10px] text-[var(--color-text-muted)]">
112 <summary className="cursor-pointer">response body</summary>
113 <pre className="mt-1 max-h-32 overflow-auto rounded-md bg-[var(--color-code-bg)] p-2 text-[var(--color-code-text)]">
114 {result.responseBody}
115 </pre>
116 </details>
117 ) : null}
118 {!result.ok && !result.networkError ? (
119 <p className="font-mono text-[10px] text-[var(--color-text-muted)]">
120 {result.status === 401
121 ? 'signature rejected — check the secret on your source matches the one shown when you created the webhook.'
122 : result.status === 410
123 ? 'endpoint is paused — resume it to accept deliveries.'
124 : result.status === 502
125 ? 'function returned an error. open the deliveries log for the full error message.'
126 : 'unexpected response — open the deliveries log for details.'}
127 </p>
128 ) : null}
129 </div>
130 );
131}