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.

495 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 token below. Most
134 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">
139 {otelEnv(TELEMETRY_OTLP_ENDPOINT, slug, "g1to_…")}
140 </pre>
141 </div>
142 {isFailed(settings) ? (
143 <div className="mt-4">
144 <Unavailable what="Whether apps on g1t.page report here" compact />
145 </div>
146 ) : (
147 <Form method="post" className="mt-4">
148 <SwitchCard
149 title="Apps on g1t.page report here"
150 defaultChecked={settings.deployments}
151 onCheckedChange={(on) => submit({ intent: "deployments", deployments: on ? "on" : "" }, { method: "post", preventScrollReset: true })}
152 >
153 Each request the project's apps answer, with their logs and exceptions, arrives on its own; their OpenTelemetry SDKs are given the endpoint and
154 the project's deployments token. Off keeps what is here and stops what is new.
155 </SwitchCard>
156 </Form>
157 )}
158 </section>
159
160 <section className="mt-10" aria-labelledby="tokens-title">
161 <div className="flex items-center justify-between gap-3">
162 <h2 id="tokens-title" className="text-sm font-semibold">
163 Ingest tokens
164 </h2>
165 <NewToken />
166 </div>
167 <p className="mt-1 text-sm text-muted">
168 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 it is made. Apps
169 on g1t.page need none; g1t gives them their own.
170 </p>
171 {isFailed(tokens) ? (
172 <div className="mt-3">
173 <Unavailable what={tokens.what} />
174 </div>
175 ) : tokens.length === 0 ? (
176 <Card className="mt-3 p-4 text-sm text-muted">None yet.</Card>
177 ) : (
178 <Card className="mt-3 divide-y divide-line">
179 {tokens.map((t) => (
180 <div key={t.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
181 <div className="min-w-0">
182 <p className="flex items-center gap-2 text-sm font-medium">
183 <KeyRound size={14} className="text-faint" />
184 {t.name}
185 </p>
186 <p className="mt-0.5 text-xs text-faint">
187 <span className="font-mono">{t.hint}</span> · made <TimeAgo at={t.created_us / 1000} /> ·{" "}
188 {t.last_used_us ? (
189 <>
190 used <TimeAgo at={t.last_used_us / 1000} />
191 </>
192 ) : (
193 "never used"
194 )}
195 </p>
196 </div>
197 <AlertDialog>
198 <AlertDialogTrigger asChild>
199 <Button variant="outline" size="sm" aria-label={`Revoke ${t.name}`}>
200 <Trash2 size={14} />
201 Revoke
202 </Button>
203 </AlertDialogTrigger>
204 <AlertDialogContent>
205 <Form method="post" className="grid gap-4">
206 <input type="hidden" name="intent" value="revoke-token" />
207 <input type="hidden" name="id" value={t.id} />
208 <AlertDialogHeader>
209 <AlertDialogTitle>Revoke {t.name}?</AlertDialogTitle>
210 <AlertDialogDescription>Anything sending with it is refused at once. What it already sent stays.</AlertDialogDescription>
211 </AlertDialogHeader>
212 <AlertDialogFooter>
213 <AlertDialogCancel>Cancel</AlertDialogCancel>
214 <AlertDialogAction asChild>
215 <button type="submit">Revoke token</button>
216 </AlertDialogAction>
217 </AlertDialogFooter>
218 </Form>
219 </AlertDialogContent>
220 </AlertDialog>
221 </div>
222 ))}
223 </Card>
224 )}
225 </section>
226
227 <section className="mt-10" aria-labelledby="exports-title">
228 <div className="flex items-center justify-between gap-3">
229 <h2 id="exports-title" className="text-sm font-semibold">
230 Exports
231 </h2>
232 <ExportDialog />
233 </div>
234 <p className="mt-1 text-sm text-muted">
235 Send everything that arrives on to any OTLP/HTTP endpoint as well: a collector of your own, or any tool that takes OpenTelemetry. Requests on g1t.page
236 go as server spans, their logs as log records.
237 </p>
238 {isFailed(exports) && (
239 <div className="mt-3">
240 <Unavailable what={exports.what} />
241 </div>
242 )}
243 {!isFailed(exports) && exports.length > 0 && (
244 <Card className="mt-3 divide-y divide-line">
245 {exports.map((e) => (
246 <div key={e.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
247 <div className="min-w-0">
248 <p className="flex items-center gap-2 text-sm font-medium">
249 <Send size={14} className="text-faint" />
250 {e.name}
251 {!e.enabled && <Badge tone="neutral">Off</Badge>}
252 </p>
253 <p className="mt-0.5 truncate font-mono text-xs text-muted">{e.endpoint}</p>
254 <p className="mt-0.5 text-xs text-faint">
255 {e.signals.join(", ")}
256 {e.header_names.length > 0 && ` · headers: ${e.header_names.join(", ")}`}
257 {e.last_sent_us && (
258 <>
259 {" "}
260 · last sent <TimeAgo at={e.last_sent_us / 1000} />
261 </>
262 )}
263 </p>
264 {e.last_error && <p className="mt-1 text-xs text-danger">{e.last_error}</p>}
265 </div>
266 <div className="flex gap-2">
267 <ExportDialog existing={e} />
268 <AlertDialog>
269 <AlertDialogTrigger asChild>
270 <Button variant="outline" size="sm" aria-label={`Remove ${e.name}`}>
271 <Trash2 size={14} />
272 </Button>
273 </AlertDialogTrigger>
274 <AlertDialogContent>
275 <Form method="post" className="grid gap-4">
276 <input type="hidden" name="intent" value="delete-export" />
277 <input type="hidden" name="id" value={e.id} />
278 <AlertDialogHeader>
279 <AlertDialogTitle>Remove {e.name}?</AlertDialogTitle>
280 <AlertDialogDescription>Nothing more is sent there. What was sent stays there.</AlertDialogDescription>
281 </AlertDialogHeader>
282 <AlertDialogFooter>
283 <AlertDialogCancel>Cancel</AlertDialogCancel>
284 <AlertDialogAction asChild>
285 <button type="submit">Remove export</button>
286 </AlertDialogAction>
287 </AlertDialogFooter>
288 </Form>
289 </AlertDialogContent>
290 </AlertDialog>
291 </div>
292 </div>
293 ))}
294 </Card>
295 )}
296 </section>
297
298 <section className="mt-10" aria-labelledby="retention-title">
299 <h2 id="retention-title" className="text-sm font-semibold">
300 Retention
301 </h2>
302 <p className="mt-1 text-sm text-muted">
303 How long requests, logs, spans and metric points are kept. The Overview's per-minute numbers are kept for 90 days whatever this is.
304 </p>
305 {isFailed(settings) ? (
306 <div className="mt-3">
307 <Unavailable what="The retention" />
308 </div>
309 ) : (
310 <Form method="post" className="mt-3 flex items-center gap-2">
311 <input type="hidden" name="intent" value="retention" />
312 <SelectField
313 name="retention_days"
314 aria-label="Retention"
315 className="w-36"
316 defaultValue={String(settings.retention_days)}
317 options={RETENTION_CHOICES.map((d) => ({ value: String(d), label: `${d} day${d === 1 ? "" : "s"}` }))}
318 />
319 <SubmitButton variant="outline" match={{ intent: "retention" }}>
320 Save
321 </SubmitButton>
322 </Form>
323 )}
324 </section>
325
326 <section className="mt-10" aria-labelledby="usage-title">
327 <h2 id="usage-title" className="text-sm font-semibold">
328 This month
329 </h2>
330 {isFailed(usage) ? (
331 <div className="mt-3">
332 <Unavailable what={usage.what} />
333 </div>
334 ) : (
335 <Card className="mt-3 grid grid-cols-2 gap-4 p-4 sm:grid-cols-3">
336 {(
337 [
338 ["Received", bytes(usage.ingested_bytes)],
339 ["Received today", bytes(usage.today_bytes ?? 0)],
340 ["Requests", count(usage.requests)],
341 ["Log lines", count(usage.logs)],
342 ["Spans", count(usage.spans)],
343 ["Metric points", count(usage.metric_points)],
344 ["Kept now", bytes(usage.stored_bytes)],
345 ] as const
346 ).map(([label, value]) => (
347 <div key={label}>
348 <p className="text-xs text-muted">{label}</p>
349 <p className="mt-0.5 text-lg font-semibold tabular-nums">{value}</p>
350 </div>
351 ))}
352 </Card>
353 )}
354 </section>
355 </div>
356 );
357}
358
359/** A new token, made in a dialog that then shows it, once. */
360function NewToken() {
361 const [open, setOpen] = useState(false);
362 // Each opening is a fresh form: a token shown once is not shown again.
363 const [round, setRound] = useState(0);
364 const fetcher = useFetcher<typeof action>({ key: `new-token-${round}` });
365 const made =
366 fetcher.data && "secret" in fetcher.data && typeof fetcher.data.secret === "string"
367 ? { name: String(fetcher.data.name ?? ""), secret: fetcher.data.secret }
368 : null;
369 return (
370 <Dialog
371 open={open}
372 onOpenChange={(next) => {
373 if (next) setRound((n) => n + 1);
374 setOpen(next);
375 }}
376 >
377 <DialogTrigger asChild>
378 <Button variant="outline" size="sm">
379 <Plus size={14} />
380 New token
381 </Button>
382 </DialogTrigger>
383 <DialogContent>
384 {made ? (
385 <div className="grid gap-4">
386 <DialogHeader>
387 <DialogTitle>{made.name}</DialogTitle>
388 <DialogDescription>
389 Copy it now: it is not shown again. Send it as <code>authorization: Bearer …</code>.
390 </DialogDescription>
391 </DialogHeader>
392 <CopyLine text={made.secret} />
393 <DialogFooter>
394 <Button onClick={() => setOpen(false)}>Done</Button>
395 </DialogFooter>
396 </div>
397 ) : (
398 <fetcher.Form method="post" className="grid gap-4">
399 <input type="hidden" name="intent" value="create-token" />
400 <DialogHeader>
401 <DialogTitle>New ingest token</DialogTitle>
402 <DialogDescription>For one piece of software, so it can be revoked on its own.</DialogDescription>
403 </DialogHeader>
404 <Field>
405 <FieldLabel htmlFor="token-name">Name</FieldLabel>
406 <Input id="token-name" name="name" required maxLength={80} placeholder="api on fly.io" autoComplete="off" />
407 </Field>
408 {fetcher.data && "error" in fetcher.data && <ErrorText>{fetcher.data.error}</ErrorText>}
409 <DialogFooter>
410 <SubmitButton fetcher={fetcher}>Make token</SubmitButton>
411 </DialogFooter>
412 </fetcher.Form>
413 )}
414 </DialogContent>
415 </Dialog>
416 );
417}
418
419function ExportDialog({ existing }: { existing?: TelemetryExport }) {
420 const fetcher = useFetcher<typeof action>();
421 const [open, setOpen] = useState(false);
422 useEffect(() => {
423 if (fetcher.state === "idle" && fetcher.data && "notice" in fetcher.data) setOpen(false);
424 }, [fetcher.state, fetcher.data]);
425 const signals: TelemetryExport["signals"] = existing?.signals ?? ["traces", "logs", "metrics"];
426 return (
427 <Dialog open={open} onOpenChange={setOpen}>
428 <DialogTrigger asChild>
429 {existing ? (
430 <Button variant="outline" size="sm">
431 Edit
432 </Button>
433 ) : (
434 <Button variant="outline" size="sm">
435 <Plus size={14} />
436 New export
437 </Button>
438 )}
439 </DialogTrigger>
440 <DialogContent>
441 <fetcher.Form method="post" className="grid gap-4">
442 <input type="hidden" name="intent" value="put-export" />
443 {existing && <input type="hidden" name="id" value={existing.id} />}
444 <DialogHeader>
445 <DialogTitle>{existing ? `Edit ${existing.name}` : "New export"}</DialogTitle>
446 <DialogDescription>An OTLP/HTTP endpoint: g1t adds /v1/traces, /v1/logs and /v1/metrics to it.</DialogDescription>
447 </DialogHeader>
448 <Field>
449 <FieldLabel htmlFor="export-name">Name</FieldLabel>
450 <Input id="export-name" name="name" required maxLength={80} defaultValue={existing?.name} placeholder="Our collector" />
451 </Field>
452 <Field>
453 <FieldLabel htmlFor="export-endpoint">Endpoint</FieldLabel>
454 <Input
455 id="export-endpoint"
456 name="endpoint"
457 required
458 type="url"
459 defaultValue={existing?.endpoint}
460 placeholder="https://otel.example.com"
461 className="font-mono"
462 />
463 </Field>
464 <fieldset className="grid gap-2">
465 <legend className="text-sm font-medium">Send</legend>
466 {(["traces", "logs", "metrics"] as const).map((s) => (
467 <label key={s} className="flex items-center gap-2 text-sm capitalize">
468 <Checkbox name="signal" value={s} defaultChecked={signals.includes(s)} />
469 {s}
470 </label>
471 ))}
472 </fieldset>
473 <Field>
474 <FieldLabel htmlFor="export-headers">Headers</FieldLabel>
475 <Textarea id="export-headers" name="headers" rows={3} className="font-mono text-xs" placeholder={"authorization: Bearer …\nx-api-key: …"} />
476 <FieldDescription>
477 One <code>name: value</code> a line, kept as secrets and never shown again.
478 {existing?.header_names.length
479 ? ` Kept now: ${existing.header_names.join(", ")}. Give one again to change it, or with nothing after the colon to remove it.`
480 : ""}
481 </FieldDescription>
482 </Field>
483 <label className="flex items-center gap-2 text-sm">
484 <Checkbox name="enabled" defaultChecked={existing?.enabled ?? true} />
485 Sending
486 </label>
487 {fetcher.data && "error" in fetcher.data && <ErrorText>{fetcher.data.error}</ErrorText>}
488 <DialogFooter>
489 <SubmitButton fetcher={fetcher}>{existing ? "Save" : "Add export"}</SubmitButton>
490 </DialogFooter>
491 </fetcher.Form>
492 </DialogContent>
493 </Dialog>
494 );
495}