| 1 | import { KeyRound, Plus, Send, Trash2 } from "lucide-react"; |
| 2 | import { useEffect, useState } from "react"; |
| 3 | import { Form, useFetcher, useSubmit } from "react-router"; |
| 4 | |
| 5 | import { RETENTION_CHOICES, TELEMETRY_OTLP_ENDPOINT, type TelemetryExport } from "@g1t/contracts/telemetry"; |
| 6 | |
| 7 | import type { Route } from "./+types/observability-settings"; |
| 8 | import { ObservabilityHeader } from "../../components/observability"; |
| 9 | import { CopyLine, ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; |
| 10 | import { |
| 11 | AlertDialog, |
| 12 | AlertDialogAction, |
| 13 | AlertDialogCancel, |
| 14 | AlertDialogContent, |
| 15 | AlertDialogDescription, |
| 16 | AlertDialogFooter, |
| 17 | AlertDialogHeader, |
| 18 | AlertDialogTitle, |
| 19 | AlertDialogTrigger, |
| 20 | } from "../../components/ui/alert-dialog"; |
| 21 | import { Badge } from "../../components/ui/badge"; |
| 22 | import { Button } from "../../components/ui/button"; |
| 23 | import { Card } from "../../components/ui/card"; |
| 24 | import { Checkbox } from "../../components/ui/checkbox"; |
| 25 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../../components/ui/dialog"; |
| 26 | import { Field, FieldDescription, FieldLabel } from "../../components/ui/field"; |
| 27 | import { Input } from "../../components/ui/input"; |
| 28 | import { SelectField } from "../../components/ui/select"; |
| 29 | import { SwitchCard } from "../../components/ui/switch"; |
| 30 | import { Textarea } from "../../components/ui/textarea"; |
| 31 | import { page } from "../../lib/meta"; |
| 32 | import { bytes, count, otelEnv } from "../../lib/observability"; |
| 33 | import { observed, soft } from "../../lib/observability.server"; |
| 34 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; |
| 35 | |
| 36 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 37 | return page(args, { title: `Observability settings · ${params.owner}/${params.repo} · g1t` }); |
| 38 | } |
| 39 | |
| 40 | export 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. */ |
| 54 | function 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 | |
| 65 | export 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 | |
| 111 | export 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. */ |
| 334 | function 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 | |
| 388 | function 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 | } |