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.

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