Skip to content

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

478 linesCodeBlameRaw
1import { KeyRound, Plus, Send, Trash2 } from "lucide-react";
2import { useEffect, useState } from "react";
3import { Form, useFetcher, useSubmit } from "react-router";
4
5import { RETENTION_CHOICES, TELEMETRY_OTLP_ENDPOINT, type TelemetryExport } from "@g1t/contracts/telemetry";
6
7import type { Route } from "./+types/observability-settings";
8import { ObservabilityHeader } from "../../components/observability";
9import { CopyLine, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
10import {
11 AlertDialog,
12 AlertDialogAction,
13 AlertDialogCancel,
14 AlertDialogContent,
15 AlertDialogDescription,
16 AlertDialogFooter,
17 AlertDialogHeader,
18 AlertDialogTitle,
19 AlertDialogTrigger,
20} from "../../components/ui/alert-dialog";
21import { Badge } from "../../components/ui/badge";
22import { Button } from "../../components/ui/button";
23import { Card } from "../../components/ui/card";
24import { Checkbox } from "../../components/ui/checkbox";
25import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../../components/ui/dialog";
26import { Field, FieldDescription, FieldLabel } from "../../components/ui/field";
27import { Input } from "../../components/ui/input";
28import { SelectField } from "../../components/ui/select";
29import { SwitchCard } from "../../components/ui/switch";
30import { Textarea } from "../../components/ui/textarea";
31import { page } from "../../lib/meta";
32import { bytes, count, otelEnv } from "../../lib/observability";
33import { observed } from "../../lib/observability.server";
34import { Unavailable } from "../../components/boundary";
35import { isFailed, part } from "../../lib/partial";
36import { assertSameOrigin, requireUser } from "../../lib/session.server";
37
38export function meta({ params, ...args }: Route.MetaArgs) {
39 return page(args, { title: `Observability settings · ${params.owner}/${params.repo} · g1t` });
40}
41
42export async function loader({ params, context, request }: Route.LoaderArgs) {
43 const { projectId, service, project } = await observed(context, params, request, "manage_integrations");
44 // The workspace goes along, so the project's store knows whose free cap applies.
45 const ref = { project_id: projectId, workspace: (params.owner ?? "").toLowerCase() };
46 // Each is a section of its own, which says so in its place when it could
47 // not be read; a form whose values could not be read is not shown, so
48 // nothing is saved over them.
49 const [settings, tokens, exports, usage] = await Promise.all([
50 part(service.settings(ref), "These settings"),
51 part(service.tokens(ref), "Ingest tokens"),
52 part(service.exports(ref), "Exports"),
53 part(service.usage(ref), "This month's usage"),
54 ]);
55 return { settings, tokens, exports, usage, slug: project.slug };
56}
57
58/** `Name: value` lines, as an export's headers are written in its form. */
59function headersOf(text: string): Record<string, string> {
60 const out: Record<string, string> = {};
61 for (const line of text.split("\n")) {
62 const at = line.indexOf(":");
63 if (at <= 0) continue;
64 const name = line.slice(0, at).trim().toLowerCase();
65 if (/^[a-z0-9-]{1,64}$/.test(name)) out[name] = line.slice(at + 1).trim();
66 }
67 return out;
68}
69
70export async function action({ request, params, context }: Route.ActionArgs) {
71 assertSameOrigin(request);
72 const user = requireUser(context, request);
73 const { projectId, service } = await observed(context, params, request, "manage_integrations");
74 const form = await request.formData();
75 const intent = String(form.get("intent") ?? "");
76 const ref = { project_id: projectId, workspace: (params.owner ?? "").toLowerCase() };
77 try {
78 switch (intent) {
79 case "retention":
80 await service.setSettings({ ...ref, retention_days: Number(form.get("retention_days")) });
81 return { notice: "Retention saved. What is past it goes within the hour." };
82 case "deployments":
83 await service.setSettings({ ...ref, deployments: form.get("deployments") === "on" });
84 return { notice: form.get("deployments") === "on" ? "Apps on g1t.page report here." : "Apps on g1t.page no longer report here." };
85 case "create-token": {
86 const made = await service.createToken({ ...ref, name: String(form.get("name") ?? ""), created_by: user.id });
87 return { secret: made.secret, name: made.token.name };
88 }
89 case "revoke-token":
90 await service.revokeToken({ ...ref, id: String(form.get("id")) });
91 return { notice: "Token revoked. Anything sending with it is refused from now on." };
92 case "put-export": {
93 const signals = form.getAll("signal").map(String) as TelemetryExport["signals"];
94 const headers = headersOf(String(form.get("headers") ?? ""));
95 await service.putExport({
96 ...ref,
97 id: form.get("id") ? String(form.get("id")) : null,
98 name: String(form.get("name") ?? ""),
99 endpoint: String(form.get("endpoint") ?? ""),
100 signals,
101 headers,
102 enabled: form.get("enabled") === "on",
103 });
104 return { notice: "Export saved. Telemetry is sent there as it arrives." };
105 }
106 case "delete-export":
107 await service.deleteExport({ ...ref, id: String(form.get("id")) });
108 return { notice: "Export removed." };
109 }
110 } catch (error) {
111 return { error: String(error).replace(/^Error: (telemetry \w+ failed with status \d+: )?/, "") };
112 }
113 return { error: "Unknown request." };
114}
115
116export default function ObservabilitySettings({ loaderData, actionData }: Route.ComponentProps) {
117 const { settings, tokens, exports, usage, slug } = loaderData;
118 const submit = useSubmit();
119
120 return (
121 <div className="max-w-4xl">
122 <ObservabilityHeader title="Observability settings" about="Where the project's telemetry comes from, how long it is kept, and where else it goes." />
123 <div className="mt-4 min-h-5">
124 {actionData && "notice" in actionData && <p className="text-sm text-success">{actionData.notice}</p>}
125 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
126 </div>
127
128 <section className="mt-4" aria-labelledby="send-title">
129 <h2 id="send-title" className="text-sm font-semibold">
130 Sending here
131 </h2>
132 <p className="mt-1 text-sm text-muted">
133 Any OpenTelemetry SDK or collector sends traces, logs and metrics over OTLP/HTTP (JSON or protobuf) to this endpoint, with an ingest
134 token below. Most SDKs read these variables:
135 </p>
136 <div className="mt-3 space-y-2">
137 <CopyLine text={TELEMETRY_OTLP_ENDPOINT} />
138 <pre className="overflow-x-auto rounded-lg border border-line bg-surface p-3 font-mono text-xs">{otelEnv(TELEMETRY_OTLP_ENDPOINT, slug, "g1to_…")}</pre>
139 </div>
140 {isFailed(settings) ? (
141 <div className="mt-4">
142 <Unavailable what="Whether apps on g1t.page report here" compact />
143 </div>
144 ) : (
145 <Form method="post" className="mt-4">
146 <SwitchCard
147 title="Apps on g1t.page report here"
148 defaultChecked={settings.deployments}
149 onCheckedChange={(on) => submit({ intent: "deployments", deployments: on ? "on" : "" }, { method: "post", preventScrollReset: true })}
150 >
151 Each request the project's apps answer, with their logs and exceptions, arrives on its own; their OpenTelemetry SDKs are given the
152 endpoint and the project's deployments token. Off keeps what is here and stops what is new.
153 </SwitchCard>
154 </Form>
155 )}
156 </section>
157
158 <section className="mt-10" aria-labelledby="tokens-title">
159 <div className="flex items-center justify-between gap-3">
160 <h2 id="tokens-title" className="text-sm font-semibold">
161 Ingest tokens
162 </h2>
163 <NewToken />
164 </div>
165 <p className="mt-1 text-sm text-muted">
166 For software running anywhere other than g1t.page: a token lets it send to this project and do nothing else. Each is shown once, when
167 it is made. Apps on g1t.page need none; g1t gives them their own.
168 </p>
169 {isFailed(tokens) ? (
170 <div className="mt-3">
171 <Unavailable what={tokens.what} />
172 </div>
173 ) : tokens.length === 0 ? (
174 <Card className="mt-3 p-4 text-sm text-muted">None yet.</Card>
175 ) : (
176 <Card className="mt-3 divide-y divide-line">
177 {tokens.map((t) => (
178 <div key={t.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
179 <div className="min-w-0">
180 <p className="flex items-center gap-2 text-sm font-medium">
181 <KeyRound size={14} className="text-faint" />
182 {t.name}
183 </p>
184 <p className="mt-0.5 text-xs text-faint">
185 <span className="font-mono">{t.hint}</span> · made <TimeAgo at={t.created_us / 1000} /> ·{" "}
186 {t.last_used_us ? (
187 <>
188 used <TimeAgo at={t.last_used_us / 1000} />
189 </>
190 ) : (
191 "never used"
192 )}
193 </p>
194 </div>
195 <AlertDialog>
196 <AlertDialogTrigger asChild>
197 <Button variant="outline" size="sm" aria-label={`Revoke ${t.name}`}>
198 <Trash2 size={14} />
199 Revoke
200 </Button>
201 </AlertDialogTrigger>
202 <AlertDialogContent>
203 <Form method="post" className="grid gap-4">
204 <input type="hidden" name="intent" value="revoke-token" />
205 <input type="hidden" name="id" value={t.id} />
206 <AlertDialogHeader>
207 <AlertDialogTitle>Revoke {t.name}?</AlertDialogTitle>
208 <AlertDialogDescription>Anything sending with it is refused at once. What it already sent stays.</AlertDialogDescription>
209 </AlertDialogHeader>
210 <AlertDialogFooter>
211 <AlertDialogCancel>Cancel</AlertDialogCancel>
212 <AlertDialogAction asChild>
213 <button type="submit">Revoke token</button>
214 </AlertDialogAction>
215 </AlertDialogFooter>
216 </Form>
217 </AlertDialogContent>
218 </AlertDialog>
219 </div>
220 ))}
221 </Card>
222 )}
223 </section>
224
225 <section className="mt-10" aria-labelledby="exports-title">
226 <div className="flex items-center justify-between gap-3">
227 <h2 id="exports-title" className="text-sm font-semibold">
228 Exports
229 </h2>
230 <ExportDialog />
231 </div>
232 <p className="mt-1 text-sm text-muted">
233 Send everything that arrives on to any OTLP/HTTP endpoint as well: a collector of your own, or any tool that takes OpenTelemetry.
234 Requests on g1t.page go as server spans, their logs as log records.
235 </p>
236 {isFailed(exports) && (
237 <div className="mt-3">
238 <Unavailable what={exports.what} />
239 </div>
240 )}
241 {!isFailed(exports) && exports.length > 0 && (
242 <Card className="mt-3 divide-y divide-line">
243 {exports.map((e) => (
244 <div key={e.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
245 <div className="min-w-0">
246 <p className="flex items-center gap-2 text-sm font-medium">
247 <Send size={14} className="text-faint" />
248 {e.name}
249 {!e.enabled && <Badge tone="neutral">Off</Badge>}
250 </p>
251 <p className="mt-0.5 truncate font-mono text-xs text-muted">{e.endpoint}</p>
252 <p className="mt-0.5 text-xs text-faint">
253 {e.signals.join(", ")}
254 {e.header_names.length > 0 && ` · headers: ${e.header_names.join(", ")}`}
255 {e.last_sent_us && (
256 <>
257 {" "}
258 · last sent <TimeAgo at={e.last_sent_us / 1000} />
259 </>
260 )}
261 </p>
262 {e.last_error && <p className="mt-1 text-xs text-danger">{e.last_error}</p>}
263 </div>
264 <div className="flex gap-2">
265 <ExportDialog existing={e} />
266 <AlertDialog>
267 <AlertDialogTrigger asChild>
268 <Button variant="outline" size="sm" aria-label={`Remove ${e.name}`}>
269 <Trash2 size={14} />
270 </Button>
271 </AlertDialogTrigger>
272 <AlertDialogContent>
273 <Form method="post" className="grid gap-4">
274 <input type="hidden" name="intent" value="delete-export" />
275 <input type="hidden" name="id" value={e.id} />
276 <AlertDialogHeader>
277 <AlertDialogTitle>Remove {e.name}?</AlertDialogTitle>
278 <AlertDialogDescription>Nothing more is sent there. What was sent stays there.</AlertDialogDescription>
279 </AlertDialogHeader>
280 <AlertDialogFooter>
281 <AlertDialogCancel>Cancel</AlertDialogCancel>
282 <AlertDialogAction asChild>
283 <button type="submit">Remove export</button>
284 </AlertDialogAction>
285 </AlertDialogFooter>
286 </Form>
287 </AlertDialogContent>
288 </AlertDialog>
289 </div>
290 </div>
291 ))}
292 </Card>
293 )}
294 </section>
295
296 <section className="mt-10" aria-labelledby="retention-title">
297 <h2 id="retention-title" className="text-sm font-semibold">
298 Retention
299 </h2>
300 <p className="mt-1 text-sm text-muted">
301 How long requests, logs, spans and metric points are kept. The Overview's per-minute numbers are kept for 90 days whatever this is.
302 </p>
303 {isFailed(settings) ? (
304 <div className="mt-3">
305 <Unavailable what="The retention" />
306 </div>
307 ) : (
308 <Form method="post" className="mt-3 flex items-center gap-2">
309 <input type="hidden" name="intent" value="retention" />
310 <SelectField
311 name="retention_days"
312 aria-label="Retention"
313 className="w-36"
314 defaultValue={String(settings.retention_days)}
315 options={RETENTION_CHOICES.map((d) => ({ value: String(d), label: `${d} day${d === 1 ? "" : "s"}` }))}
316 />
317 <SubmitButton variant="outline" match={{ intent: "retention" }}>
318 Save
319 </SubmitButton>
320 </Form>
321 )}
322 </section>
323
324 <section className="mt-10" aria-labelledby="usage-title">
325 <h2 id="usage-title" className="text-sm font-semibold">
326 This month
327 </h2>
328 {isFailed(usage) ? (
329 <div className="mt-3">
330 <Unavailable what={usage.what} />
331 </div>
332 ) : (
333 <Card className="mt-3 grid grid-cols-2 gap-4 p-4 sm:grid-cols-3">
334 {(
335 [
336 ["Received", bytes(usage.ingested_bytes)],
337 ["Received today", bytes(usage.today_bytes ?? 0)],
338 ["Requests", count(usage.requests)],
339 ["Log lines", count(usage.logs)],
340 ["Spans", count(usage.spans)],
341 ["Metric points", count(usage.metric_points)],
342 ["Kept now", bytes(usage.stored_bytes)],
343 ] as const
344 ).map(([label, value]) => (
345 <div key={label}>
346 <p className="text-xs text-muted">{label}</p>
347 <p className="mt-0.5 text-lg font-semibold tabular-nums">{value}</p>
348 </div>
349 ))}
350 </Card>
351 )}
352 </section>
353 </div>
354 );
355}
356
357/** A new token, made in a dialog that then shows it, once. */
358function NewToken() {
359 const [open, setOpen] = useState(false);
360 // Each opening is a fresh form: a token shown once is not shown again.
361 const [round, setRound] = useState(0);
362 const fetcher = useFetcher<typeof action>({ key: `new-token-${round}` });
363 const made = fetcher.data && "secret" in fetcher.data && typeof fetcher.data.secret === "string" ? { name: String(fetcher.data.name ?? ""), secret: fetcher.data.secret } : null;
364 return (
365 <Dialog
366 open={open}
367 onOpenChange={(next) => {
368 if (next) setRound((n) => n + 1);
369 setOpen(next);
370 }}
371 >
372 <DialogTrigger asChild>
373 <Button variant="outline" size="sm">
374 <Plus size={14} />
375 New token
376 </Button>
377 </DialogTrigger>
378 <DialogContent>
379 {made ? (
380 <div className="grid gap-4">
381 <DialogHeader>
382 <DialogTitle>{made.name}</DialogTitle>
383 <DialogDescription>Copy it now: it is not shown again. Send it as <code>authorization: Bearer …</code>.</DialogDescription>
384 </DialogHeader>
385 <CopyLine text={made.secret} />
386 <DialogFooter>
387 <Button onClick={() => setOpen(false)}>Done</Button>
388 </DialogFooter>
389 </div>
390 ) : (
391 <fetcher.Form method="post" className="grid gap-4">
392 <input type="hidden" name="intent" value="create-token" />
393 <DialogHeader>
394 <DialogTitle>New ingest token</DialogTitle>
395 <DialogDescription>For one piece of software, so it can be revoked on its own.</DialogDescription>
396 </DialogHeader>
397 <Field>
398 <FieldLabel htmlFor="token-name">Name</FieldLabel>
399 <Input id="token-name" name="name" required maxLength={80} placeholder="api on fly.io" autoComplete="off" />
400 </Field>
401 {fetcher.data && "error" in fetcher.data && <ErrorText>{fetcher.data.error}</ErrorText>}
402 <DialogFooter>
403 <SubmitButton fetcher={fetcher}>Make token</SubmitButton>
404 </DialogFooter>
405 </fetcher.Form>
406 )}
407 </DialogContent>
408 </Dialog>
409 );
410}
411
412function ExportDialog({ existing }: { existing?: TelemetryExport }) {
413 const fetcher = useFetcher<typeof action>();
414 const [open, setOpen] = useState(false);
415 useEffect(() => {
416 if (fetcher.state === "idle" && fetcher.data && "notice" in fetcher.data) setOpen(false);
417 }, [fetcher.state, fetcher.data]);
418 const signals: TelemetryExport["signals"] = existing?.signals ?? ["traces", "logs", "metrics"];
419 return (
420 <Dialog open={open} onOpenChange={setOpen}>
421 <DialogTrigger asChild>
422 {existing ? (
423 <Button variant="outline" size="sm">
424 Edit
425 </Button>
426 ) : (
427 <Button variant="outline" size="sm">
428 <Plus size={14} />
429 New export
430 </Button>
431 )}
432 </DialogTrigger>
433 <DialogContent>
434 <fetcher.Form method="post" className="grid gap-4">
435 <input type="hidden" name="intent" value="put-export" />
436 {existing && <input type="hidden" name="id" value={existing.id} />}
437 <DialogHeader>
438 <DialogTitle>{existing ? `Edit ${existing.name}` : "New export"}</DialogTitle>
439 <DialogDescription>An OTLP/HTTP endpoint: g1t adds /v1/traces, /v1/logs and /v1/metrics to it.</DialogDescription>
440 </DialogHeader>
441 <Field>
442 <FieldLabel htmlFor="export-name">Name</FieldLabel>
443 <Input id="export-name" name="name" required maxLength={80} defaultValue={existing?.name} placeholder="Our collector" />
444 </Field>
445 <Field>
446 <FieldLabel htmlFor="export-endpoint">Endpoint</FieldLabel>
447 <Input id="export-endpoint" name="endpoint" required type="url" defaultValue={existing?.endpoint} placeholder="https://otel.example.com" className="font-mono" />
448 </Field>
449 <fieldset className="grid gap-2">
450 <legend className="text-sm font-medium">Send</legend>
451 {(["traces", "logs", "metrics"] as const).map((s) => (
452 <label key={s} className="flex items-center gap-2 text-sm capitalize">
453 <Checkbox name="signal" value={s} defaultChecked={signals.includes(s)} />
454 {s}
455 </label>
456 ))}
457 </fieldset>
458 <Field>
459 <FieldLabel htmlFor="export-headers">Headers</FieldLabel>
460 <Textarea id="export-headers" name="headers" rows={3} className="font-mono text-xs" placeholder={"authorization: Bearer …\nx-api-key: …"} />
461 <FieldDescription>
462 One <code>name: value</code> a line, kept as secrets and never shown again.
463 {existing?.header_names.length ? ` Kept now: ${existing.header_names.join(", ")}. Give one again to change it, or with nothing after the colon to remove it.` : ""}
464 </FieldDescription>
465 </Field>
466 <label className="flex items-center gap-2 text-sm">
467 <Checkbox name="enabled" defaultChecked={existing?.enabled ?? true} />
468 Sending
469 </label>
470 {fetcher.data && "error" in fetcher.data && <ErrorText>{fetcher.data.error}</ErrorText>}
471 <DialogFooter>
472 <SubmitButton fetcher={fetcher}>{existing ? "Save" : "Add export"}</SubmitButton>
473 </DialogFooter>
474 </fetcher.Form>
475 </DialogContent>
476 </Dialog>
477 );
478}