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

302 lines12,317 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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