flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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