| 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 } from "../../lib/observability.server"; |
| 34 | import { Unavailable } from "../../components/boundary"; |
| 35 | import { isFailed, part } from "../../lib/partial"; |
| 36 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; |
| 37 | |
| 38 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 39 | return page(args, { title: `Observability settings · ${params.owner}/${params.repo} · g1t` }); |
| 40 | } |
| 41 | |
| 42 | export 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. */ |
| 59 | function 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 | |
| 70 | export 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 | |
| 116 | export 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. */ |
| 360 | function 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 | |
| 419 | function 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 | } |