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.

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