Skip to content

g1t/apps/web/app/components/webhooks.tsx

323 lines12,805 bytesCodeBlame
1/**
2 * A repository's or a workspace's webhooks: the addresses events are sent
3 * to, adding one, and every delivery with what was sent and what came back.
4 */
5import { ChevronRight, Pause, Play, RotateCw, Send, Trash2 } from "lucide-react";
6import { useState } from "react";
7import { Form, Link } from "react-router";
8
9import { EVENT_TYPES, type Hook, type HookDelivery } from "@g1t/contracts";
10
11import type { WebhooksAction, WebhooksData } from "../lib/webhooks.server";
12import { CopyLine, EmptyState, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui";
13import { CheckboxOption } from "./ui/checkbox";
14import { RadioGroup, RadioOption } from "./ui/radio-group";
15
16/** The events, in groups people recognise. */
17const GROUPS: { title: string; events: string[] }[] = [
18 { title: "Code", events: ["git.push", "repo.created", "repo.forked"] },
19 {
20 title: "Issues",
21 events: ["issue.opened", "issue.updated", "issue.assigned", "issue.closed", "issue.reopened", "comment.created"],
22 },
23 {
24 title: "Pull requests",
25 events: ["pull.opened", "pull.ready", "pull.updated", "pull.merge_requested", "pull.merged", "pull.closed"],
26 },
27 { title: "Checks, reviews and the queue", events: ["checks.completed", "workflow.completed", "review.completed", "queue.changed"] },
28 { title: "Agents", events: ["session.appended", "agent.asked"] },
29 { title: "Access", events: ["repo.collaborator_added", "repo.collaborator_removed", "repo.collaborator_role_changed"] },
30 {
31 title: "Teams",
32 events: [
33 "team.created",
34 "team.edited",
35 "team.deleted",
36 "team.member_added",
37 "team.member_role_changed",
38 "team.member_removed",
39 "team.repo_added",
40 "team.repo_role_changed",
41 "team.repo_removed",
42 ],
43 },
44];
45
46function StatusDot({ status }: { status: string | null }) {
47 const color =
48 status === "delivered" ? "bg-accent" : status === "failed" ? "bg-danger" : status === "pending" ? "bg-warn" : "bg-faint";
49 return <span aria-hidden="true" className={`size-2 shrink-0 rounded-full ${color}`} />;
50}
51
52function describeEvents(events: string[]): string {
53 if (events.includes("*")) return "Every event";
54 return events.length === 1 ? events[0] : `${events.length} events`;
55}
56
57/** JSON, indented for reading, or the text as it is. */
58function pretty(text: string | null): string {
59 if (!text) return "";
60 try {
61 return JSON.stringify(JSON.parse(text), null, 2);
62 } catch {
63 return text;
64 }
65}
66
67function DeliveryRow({ delivery, manage }: { delivery: HookDelivery; manage: boolean }) {
68 return (
69 <details className="group border-t border-line first:border-t-0">
70 <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-2.5 text-sm hover:bg-raised/40">
71 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
72 <StatusDot status={delivery.status} />
73 <span className="font-mono text-[0.8125rem]">{delivery.event}</span>
74 <span className="min-w-0 truncate text-xs text-muted">
75 {delivery.responseStatus != null
76 ? `${delivery.responseStatus}`
77 : delivery.error
78 ? delivery.error
79 : "Not yet sent"}
80 {delivery.attempts > 1 && ` · ${delivery.attempts} attempts`}
81 {delivery.status === "pending" && delivery.nextAttemptAt && " · will try again"}
82 </span>
83 <span className="ml-auto shrink-0 font-mono text-xs text-faint">
84 {delivery.durationMs != null && `${delivery.durationMs}ms · `}
85 <TimeAgo at={delivery.createdAt} />
86 </span>
87 </summary>
88 <div className="grid gap-4 border-t border-line bg-bg/40 p-4 lg:grid-cols-2">
89 <div className="min-w-0">
90 <p className="mb-1.5 text-xs font-medium text-muted">Request</p>
91 <pre className="max-h-80 overflow-auto rounded-lg bg-surface p-3 font-mono text-xs ring-1 ring-line">
92 <code>{pretty(delivery.payload)}</code>
93 </pre>
94 </div>
95 <div className="min-w-0">
96 <p className="mb-1.5 text-xs font-medium text-muted">
97 Response{delivery.responseStatus != null && ` · ${delivery.responseStatus}`}
98 </p>
99 <pre className="max-h-80 overflow-auto rounded-lg bg-surface p-3 font-mono text-xs ring-1 ring-line">
100 <code>{delivery.error ?? (pretty(delivery.responseBody) || "No body.")}</code>
101 </pre>
102 {manage && (
103 <Form method="post" className="mt-3">
104 <input type="hidden" name="intent" value="redeliver" />
105 <input type="hidden" name="delivery" value={delivery.id} />
106 <SubmitButton
107 variant="quiet"
108 match={{ intent: "redeliver", delivery: delivery.id }}
109 pending="Redelivering…"
110 >
111 <RotateCw size={14} />
112 Redeliver
113 </SubmitButton>
114 </Form>
115 )}
116 </div>
117 </div>
118 </details>
119 );
120}
121
122function HookRow({ hook, open, deliveries, manage }: { hook: Hook; open: boolean; deliveries: HookDelivery[]; manage: boolean }) {
123 return (
124 <li className="border-t border-line first:border-t-0">
125 <div className="flex items-center gap-3 px-4 py-3">
126 <StatusDot status={hook.active ? hook.lastStatus : null} />
127 <div className="min-w-0 grow">
128 <p className="truncate font-mono text-[0.8125rem]">{hook.url}</p>
129 <p className="truncate text-xs text-muted">
130 {describeEvents(hook.events)}
131 {!hook.active && " · paused"}
132 {hook.lastDeliveredAt && (
133 <>
134 {" · last sent "}
135 <TimeAgo at={hook.lastDeliveredAt} />
136 </>
137 )}
138 {` · secret ${hook.secretHint}`}
139 </p>
140 </div>
141 <Link
142 to={open ? "?" : `?hook=${hook.id}`}
143 preventScrollReset
144 className="shrink-0 rounded-md px-2.5 py-1.5 text-xs text-muted transition-colors hover:bg-raised hover:text-fg"
145 >
146 {open ? "Hide deliveries" : "Deliveries"}
147 </Link>
148 {manage && (
149 <Form method="post" className="flex shrink-0 gap-1">
150 <input type="hidden" name="id" value={hook.id} />
151 <IconButton intent="ping" id={hook.id} label="Send a ping">
152 <Send size={14} />
153 </IconButton>
154 <input type="hidden" name="active" value={hook.active ? "false" : "true"} />
155 <IconButton intent="toggle" id={hook.id} label={hook.active ? "Pause" : "Resume"}>
156 {hook.active ? <Pause size={14} /> : <Play size={14} />}
157 </IconButton>
158 <IconButton intent="delete" id={hook.id} label="Delete">
159 <Trash2 size={14} />
160 </IconButton>
161 </Form>
162 )}
163 </div>
164 {open && (
165 <div className="mx-4 mb-4 overflow-hidden rounded-lg border border-line">
166 {deliveries.length === 0 ? (
167 <p className="px-4 py-3 text-sm text-muted">Nothing sent yet.</p>
168 ) : (
169 deliveries.map((delivery) => <DeliveryRow key={delivery.id} delivery={delivery} manage={manage} />)
170 )}
171 </div>
172 )}
173 </li>
174 );
175}
176
177/** One webhook's action, as an icon: a spinner in its place while that action goes. */
178function IconButton({ intent, id, label, children }: { intent: string; id: string; label: string; children: React.ReactNode }) {
179 return (
180 <SubmitButton
181 icon
182 name="intent"
183 value={intent}
184 match={{ id }}
185 title={label}
186 aria-label={label}
187 className="rounded-md p-2 text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
188 >
189 {children}
190 </SubmitButton>
191 );
192}
193
194function AddWebhook() {
195 const [which, setWhich] = useState<"all" | "some">("all");
196 return (
197 <Form method="post" className="space-y-4 rounded-xl border border-line bg-surface p-5">
198 <input type="hidden" name="intent" value="create" />
199 <p className="font-medium">Add a webhook</p>
200 <Field label="Payload URL" hint="An HTTPS address on the public internet. g1t sends it a ping as soon as you add it.">
201 <Input name="url" type="url" required placeholder="https://example.com/g1t/events" />
202 </Field>
203 <fieldset>
204 <legend className="mb-1.5 text-sm font-medium text-muted">Which events</legend>
205 <RadioGroup
206 name="which"
207 value={which}
208 onValueChange={(value) => setWhich(value as typeof which)}
209 aria-label="Which events"
210 className="flex flex-wrap gap-x-6 gap-y-2"
211 >
212 <RadioOption value="all" label="Everything, including events added later" />
213 <RadioOption value="some" label="Let me choose" />
214 </RadioGroup>
215 {which === "some" && (
216 <div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
217 {GROUPS.map((group) => (
218 <div key={group.title}>
219 <p className="mb-1.5 text-xs font-medium text-faint">{group.title}</p>
220 <div className="space-y-1">
221 {group.events
222 .filter((event) => (EVENT_TYPES as readonly string[]).includes(event))
223 .map((event) => (
224 <CheckboxOption
225 key={event}
226 name="event"
227 value={event}
228 label={event}
229 className="items-center"
230 labelClassName="font-mono text-[0.8125rem]"
231 />
232 ))}
233 </div>
234 </div>
235 ))}
236 </div>
237 )}
238 </fieldset>
239 <Field label="Secret" hint="Optional. Deliveries are signed with it. Leave it empty and g1t makes one, shown once.">
240 <Input name="secret" type="password" autoComplete="off" />
241 </Field>
242 <SubmitButton match={{ intent: "create" }} pending="Adding…">
243 Add webhook
244 </SubmitButton>
245 </Form>
246 );
247}
248
249export function WebhooksPanel({
250 data,
251 action,
252 manage,
253 scope,
254}: {
255 data: WebhooksData;
256 action: WebhooksAction | undefined;
257 manage: boolean;
258 /** Says whose events these are. */
259 scope: string;
260}) {
261 const created = action?.created;
262 return (
263 <div className="max-w-4xl space-y-6">
264 {created && (
265 <div className="space-y-3 rounded-xl border border-accent-dim/60 bg-accent/5 p-5 text-sm">
266 <p className="font-medium">Added. g1t sent it a ping.</p>
267 {created.secret ? (
268 <>
269 <p className="text-muted">
270 Every delivery is signed with this secret in <code>X-G1t-Signature-256</code>. It is shown this once.
271 </p>
272 <CopyLine text={created.secret} />
273 </>
274 ) : (
275 <p className="text-muted">Deliveries are signed with the secret you gave.</p>
276 )}
277 </div>
278 )}
279 {action?.sent && (
280 <p className="text-sm text-muted">
281 Sent {action.sent.event}:{" "}
282 {action.sent.status === "delivered" ? (
283 <span className="text-accent">delivered, {action.sent.responseStatus}</span>
284 ) : (
285 <span className="text-danger">{action.sent.error ?? `answered ${action.sent.responseStatus}`}</span>
286 )}
287 .
288 </p>
289 )}
290 <ErrorText>{action?.error}</ErrorText>
291
292 {data.hooks.length === 0 ? (
293 <EmptyState title="No webhooks yet">
294 Add one and {scope} sends its events to your address as they happen, signed, and retried until it answers.
295 </EmptyState>
296 ) : (
297 <ul className="overflow-hidden rounded-xl border border-line bg-surface">
298 {data.hooks.map((hook) => (
299 <HookRow
300 key={hook.id}
301 hook={hook}
302 open={data.open === hook.id}
303 deliveries={data.open === hook.id ? data.deliveries : []}
304 manage={manage}
305 />
306 ))}
307 </ul>
308 )}
309
310 {/* Keyed to the webhook just added, so the form starts empty for the next. */}
311 {manage && <AddWebhook key={created?.hook.id ?? "new"} />}
312 <p className="text-xs text-faint">
313 Each delivery is an HTTPS POST of JSON with <code>X-G1t-Event</code>, <code>X-G1t-Delivery</code> and{" "}
314 <code>X-G1t-Signature-256</code>. One that is not answered with a 2xx is tried again after 1 minute, 5 minutes,
315 30 minutes, 2 hours and 5 hours.{" "}
316 <a href="https://docs.g1t.sh/guides/webhooks/" className="text-muted underline underline-offset-4">
317 How to check the signature
318 </a>
319 .
320 </p>
321 </div>
322 );
323}