LogDrainDestinationSheetForm.tsx1052 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { IS_PLATFORM, useFlag, useParams } from 'common'
3import Link from 'next/link'
4import { ReactNode, useEffect, useMemo } from 'react'
5import { useForm } from 'react-hook-form'
6import { toast } from 'sonner'
7import {
8 Button,
9 cn,
10 Form,
11 FormControl,
12 FormField,
13 FormItem,
14 FormLabel,
15 Input,
16 RadioGroupCard,
17 RadioGroupCardItem,
18 Select,
19 SelectContent,
20 SelectGroup,
21 SelectItem,
22 SelectLabel,
23 SelectTrigger,
24 SelectValue,
25 Sheet,
26 SheetContent,
27 SheetFooter,
28 SheetHeader,
29 SheetSection,
30 SheetTitle,
31 Switch,
32 TextArea,
33} from 'ui'
34import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
35import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
36import { InfoTooltip } from 'ui-patterns/info-tooltip'
37import { z } from 'zod'
38
39import {
40 DATADOG_REGIONS,
41 LAST9_REGIONS,
42 LOG_DRAIN_TYPES,
43 LogDrainType,
44 OTLP_PROTOCOLS,
45} from './LogDrains.constants'
46import {
47 getDefaultHeadersByType,
48 getHeadersSectionDescription as getHeadersDescription,
49 headerRecordToRows,
50 headerRowsToRecord,
51 logDrainHeaderEntriesSchema,
52 type LogDrainHeaderRow,
53} from './LogDrains.utils'
54import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer'
55import { LogDrainData, useLogDrainsQuery } from '@/data/log-drains/log-drains-query'
56import { DOCS_URL } from '@/lib/constants'
57import { useTrack } from '@/lib/telemetry/track'
58import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
59
60const FORM_ID = 'log-drain-destination-form'
61
62const headerRecordSchema = z.record(z.string(), z.string())
63
64const webhookFields = {
65 type: z.literal('webhook'),
66 url: httpEndpointUrlSchema({
67 requiredMessage: 'Endpoint URL is required',
68 invalidMessage: 'Endpoint URL must be a valid URL',
69 prefixMessage: 'Endpoint URL must start with http:// or https://',
70 }),
71 http: z.enum(['http1', 'http2']),
72 gzip: z.boolean(),
73}
74
75const webhookFormSchema = z.object({
76 ...webhookFields,
77 headerEntries: logDrainHeaderEntriesSchema.optional(),
78})
79
80const webhookSubmitSchema = z.object({
81 ...webhookFields,
82 headers: headerRecordSchema.optional(),
83})
84
85const datadogSchema = z.object({
86 type: z.literal('datadog'),
87 api_key: z.string().min(1, { message: 'API key is required' }),
88 region: z.string().min(1, { message: 'Region is required' }),
89})
90
91const lokiFields = {
92 type: z.literal('loki'),
93 url: httpEndpointUrlSchema({
94 requiredMessage: 'Loki URL is required',
95 invalidMessage: 'Loki URL must be a valid URL',
96 prefixMessage: 'Loki URL must start with http:// or https://',
97 }),
98 username: z.string().optional(),
99 password: z.string().optional(),
100}
101
102const lokiFormSchema = z.object({
103 ...lokiFields,
104 headerEntries: logDrainHeaderEntriesSchema.optional(),
105})
106
107const lokiSubmitSchema = z.object({
108 ...lokiFields,
109 headers: headerRecordSchema,
110})
111
112const elasticSchema = z.object({
113 type: z.literal('elastic'),
114})
115
116const postgresSchema = z.object({
117 type: z.literal('postgres'),
118})
119
120const bigquerySchema = z.object({
121 type: z.literal('bigquery'),
122})
123
124const clickhouseSchema = z.object({
125 type: z.literal('clickhouse'),
126})
127
128const s3Schema = z.object({
129 type: z.literal('s3'),
130 s3_bucket: z.string().min(1, { message: 'Bucket name is required' }),
131 storage_region: z.string().min(1, { message: 'Region is required' }),
132 access_key_id: z.string().min(1, { message: 'Access Key ID is required' }),
133 secret_access_key: z.string().min(1, { message: 'Secret Access Key is required' }),
134 batch_timeout: z.coerce
135 .number()
136 .int({ message: 'Batch timeout must be an integer' })
137 .min(1, { message: 'Batch timeout must be a positive integer' }),
138})
139
140const sentrySchema = z.object({
141 type: z.literal('sentry'),
142 dsn: z
143 .string()
144 .min(1, { message: 'Sentry DSN is required' })
145 .refine((dsn) => dsn.startsWith('https://'), 'Sentry DSN must start with https://'),
146})
147
148const axiomSchema = z.object({
149 type: z.literal('axiom'),
150 api_token: z.string().min(1, { message: 'API token is required' }),
151 dataset_name: z.string().min(1, { message: 'Dataset name is required' }),
152})
153
154const last9Schema = z.object({
155 type: z.literal('last9'),
156 region: z.string().min(1, { message: 'Region is required' }),
157 username: z.string().min(1, { message: 'Username is required' }),
158 password: z.string().min(1, { message: 'Password is required' }),
159})
160
161const otlpFields = {
162 type: z.literal('otlp'),
163 endpoint: httpEndpointUrlSchema({
164 requiredMessage: 'OTLP endpoint is required',
165 invalidMessage: 'OTLP endpoint must be a valid URL',
166 prefixMessage: 'OTLP endpoint must start with http:// or https://',
167 }),
168 protocol: z.string().optional().default('http/protobuf'),
169 gzip: z.boolean().optional().default(true),
170}
171
172const otlpFormSchema = z.object({
173 ...otlpFields,
174 headerEntries: logDrainHeaderEntriesSchema.optional(),
175})
176
177const otlpSubmitSchema = z.object({
178 ...otlpFields,
179 headers: headerRecordSchema.optional(),
180})
181
182const syslogSchema = z.object({
183 type: z.literal('syslog'),
184 host: z.string().min(1, { message: 'Host is required' }),
185 port: z.coerce
186 .number()
187 .int({ message: 'Port must be an integer' })
188 .min(0, { message: 'Port must be between 0 and 65535' })
189 .max(65535, { message: 'Port must be between 0 and 65535' }),
190 tls: z.boolean().optional().default(false),
191 structured_data: z.string().optional(),
192 cipher_key: z.string().optional(),
193 ca_cert: z.string().optional(),
194 client_cert: z.string().optional(),
195 client_key: z.string().optional(),
196})
197
198const formUnion = z.discriminatedUnion('type', [
199 webhookFormSchema,
200 datadogSchema,
201 lokiFormSchema,
202 // [Joshen] To fix API types, not supported in the UI
203 elasticSchema,
204 postgresSchema,
205 bigquerySchema,
206 clickhouseSchema,
207 s3Schema,
208 sentrySchema,
209 axiomSchema,
210 last9Schema,
211 otlpFormSchema,
212 syslogSchema,
213])
214
215const submitUnion = z.discriminatedUnion('type', [
216 webhookSubmitSchema,
217 datadogSchema,
218 lokiSubmitSchema,
219 elasticSchema,
220 postgresSchema,
221 bigquerySchema,
222 clickhouseSchema,
223 s3Schema,
224 sentrySchema,
225 axiomSchema,
226 last9Schema,
227 otlpSubmitSchema,
228 syslogSchema,
229])
230
231const formSchema = z
232 .object({
233 name: z.string().min(1, {
234 message: 'Destination name is required',
235 }),
236 description: z.string().optional(),
237 })
238 .and(formUnion)
239 .superRefine((data, ctx) => {
240 if (data.type !== 'syslog') return
241 if (data.client_cert && !data.client_key) {
242 ctx.addIssue({
243 code: z.ZodIssueCode.custom,
244 message: 'Client key is required when a client certificate is provided',
245 path: ['client_key'],
246 })
247 }
248 if (data.client_key && !data.client_cert) {
249 ctx.addIssue({
250 code: z.ZodIssueCode.custom,
251 message: 'Client certificate is required when a client key is provided',
252 path: ['client_cert'],
253 })
254 }
255 })
256
257const submitSchema = z
258 .object({
259 name: z.string().min(1, {
260 message: 'Destination name is required',
261 }),
262 description: z.string().optional(),
263 })
264 .and(submitUnion)
265
266type LogDrainDestinationFormValues = z.infer<typeof formSchema>
267type LogDrainDestinationSubmitValues = z.infer<typeof submitSchema>
268
269const HEADER_ENABLED_TYPES = ['webhook', 'loki', 'otlp'] as const
270
271function toSubmitValues(values: LogDrainDestinationFormValues): LogDrainDestinationSubmitValues {
272 if (!HEADER_ENABLED_TYPES.includes(values.type as (typeof HEADER_ENABLED_TYPES)[number])) {
273 return submitSchema.parse(values)
274 }
275
276 const { headerEntries = [], ...rest } = values as LogDrainDestinationFormValues & {
277 headerEntries?: LogDrainHeaderRow[]
278 }
279 const headers = headerRowsToRecord(headerEntries)
280 const transformedValues =
281 rest.type === 'loki'
282 ? { ...rest, headers }
283 : Object.keys(headers).length > 0
284 ? { ...rest, headers }
285 : rest
286
287 return submitSchema.parse(transformedValues)
288}
289
290function LogDrainFormItem({
291 value,
292 label,
293 description,
294 formControl,
295 placeholder,
296 type,
297}: {
298 value: string
299 label: string
300 formControl: any
301 placeholder?: string
302 description?: ReactNode
303 type?: string
304}) {
305 return (
306 <FormField
307 name={value}
308 control={formControl}
309 render={({ field }) => (
310 <FormItemLayout layout="horizontal" label={label} description={description || ''}>
311 <FormControl>
312 <Input type={type || 'text'} placeholder={placeholder} {...field} />
313 </FormControl>
314 </FormItemLayout>
315 )}
316 />
317 )
318}
319
320type DefaultValues = { type: LogDrainType } & Partial<LogDrainData>
321
322export function LogDrainDestinationSheetForm({
323 open,
324 onOpenChange,
325 defaultValues,
326 onSubmit,
327 isLoading,
328 mode,
329}: {
330 open: boolean
331 onOpenChange: (v: boolean) => void
332 defaultValues?: DefaultValues
333 isLoading?: boolean
334 onSubmit: (values: LogDrainDestinationSubmitValues) => void
335 mode: 'create' | 'update'
336}) {
337 // NOTE(kamil): This used to be `any` for a long long time, but after moving to Zod,
338 // it produces a correct union type of all possible configs. Unfortunately, this type was not designed correctly
339 // and it does not include `type` inside the config itself, so it's not trivial to create `discriminatedUnion`
340 // out of it, therefore for an ease of use now, we bail to `any` until the better time come.
341 const defaultType = defaultValues?.type || 'webhook'
342 const defaultHeaderEntries = useMemo(() => {
343 const config = (defaultValues?.config || {}) as any
344 const type = defaultValues?.type || 'webhook'
345 return headerRecordToRows(
346 mode === 'create' ? getDefaultHeadersByType(type) : config?.headers || {}
347 )
348 }, [defaultValues, mode])
349
350 const sentryEnabled = useFlag('SentryLogDrain')
351 const s3Enabled = useFlag('S3logdrain')
352 const axiomEnabled = useFlag('axiomLogDrain')
353 const otlpEnabled = useFlag('otlpLogDrain')
354 const last9Enabled = useFlag('Last9LogDrain')
355 const syslogEnabled = useFlag('syslogLogDrain')
356
357 const { ref } = useParams()
358 const { data: logDrains } = useLogDrainsQuery({
359 ref,
360 })
361
362 const track = useTrack()
363
364 const formValues = useMemo(() => {
365 const config = (defaultValues?.config || {}) as any
366 const type = defaultValues?.type || 'webhook'
367 return {
368 name: defaultValues?.name || '',
369 description: defaultValues?.description || '',
370 type,
371 http: config?.http || 'http2',
372 gzip: mode === 'create' ? true : config?.gzip || false,
373 headerEntries: defaultHeaderEntries,
374 url: config?.url || '',
375 api_key: config?.api_key || '',
376 region: config?.region || '',
377 username: config?.username || '',
378 password: config?.password || '',
379 dsn: config?.dsn || '',
380 s3_bucket: config?.s3_bucket || '',
381 storage_region: config?.storage_region || '',
382 access_key_id: config?.access_key_id || '',
383 secret_access_key: config?.secret_access_key || '',
384 batch_timeout: config?.batch_timeout ?? 3000,
385 dataset_name: config?.dataset_name || '',
386 api_token: config?.api_token || '',
387 endpoint: config?.endpoint || '',
388 protocol: config?.protocol || 'http/protobuf',
389 host: config?.host || '',
390 port: (config?.port ?? '') as number,
391 tls: config?.tls ?? false,
392 structured_data: config?.structured_data || '',
393 cipher_key: config?.cipher_key || '',
394 ca_cert: config?.ca_cert || '',
395 client_cert: config?.client_cert || '',
396 client_key: config?.client_key || '',
397 }
398 }, [defaultValues, mode, defaultHeaderEntries])
399
400 const form = useForm<LogDrainDestinationFormValues>({
401 resolver: zodResolver(formSchema as any),
402 values: formValues,
403 })
404
405 const type = form.watch('type')
406 const tls = form.watch('tls')
407
408 useEffect(() => {
409 if (mode === 'create' && !open) {
410 form.reset()
411 }
412 }, [mode, open, form])
413
414 useEffect(() => {
415 if (!open || mode !== 'create') return
416
417 form.setValue('headerEntries', headerRecordToRows(getDefaultHeadersByType(type)))
418 form.clearErrors('headerEntries')
419 }, [form, mode, open, type])
420
421 return (
422 <Sheet open={open} onOpenChange={onOpenChange}>
423 <SheetContent
424 tabIndex={undefined}
425 showClose={false}
426 size="lg"
427 className="overflow-y-auto flex flex-col"
428 >
429 <SheetHeader>
430 <SheetTitle>Add destination</SheetTitle>
431 </SheetHeader>
432 <SheetSection className="px-0! pb-0!">
433 <Form {...form}>
434 <form
435 id={FORM_ID}
436 onSubmit={(e) => {
437 e.preventDefault()
438
439 // Temp check to make sure the name is unique
440 const logDrainName = form.getValues('name')
441 const logDrainExists =
442 !!logDrains?.length && logDrains?.find((drain) => drain.name === logDrainName)
443 if (logDrainExists && mode === 'create') {
444 toast.error('Log drain name already exists')
445 return
446 }
447
448 form.handleSubmit((values) => onSubmit(toSubmitValues(values)))(e)
449 track('log_drain_save_button_clicked', {
450 destination: form.getValues('type'),
451 })
452 }}
453 >
454 <div className="space-y-8 px-content">
455 <LogDrainFormItem
456 value="name"
457 placeholder="My Destination"
458 label="Name"
459 formControl={form.control}
460 />
461 <LogDrainFormItem
462 value="description"
463 placeholder="Optional description"
464 label="Description"
465 formControl={form.control}
466 />
467 {mode === 'create' && (
468 <FormItemLayout
469 layout="horizontal"
470 label="Type"
471 description={LOG_DRAIN_TYPES.find((t) => t.value === type)?.description || ''}
472 >
473 <Select
474 defaultValue={defaultType}
475 value={form.getValues('type')}
476 onValueChange={(v: LogDrainType) => form.setValue('type', v)}
477 >
478 <SelectTrigger>
479 {LOG_DRAIN_TYPES.find((t) => t.value === type)?.name}
480 </SelectTrigger>
481 <SelectContent>
482 {LOG_DRAIN_TYPES.filter((t) => {
483 if (t.value === 'sentry') return sentryEnabled
484 if (t.value === 's3') return s3Enabled
485 if (t.value === 'axiom') return axiomEnabled
486 if (t.value === 'otlp') return otlpEnabled
487 if (t.value === 'last9') return last9Enabled
488 if (t.value === 'syslog') return syslogEnabled
489 return true
490 }).map((type) => (
491 <SelectItem
492 value={type.value}
493 key={type.value}
494 id={type.value}
495 className="text-left"
496 >
497 {type.name}
498 </SelectItem>
499 ))}
500 </SelectContent>
501 </Select>
502 </FormItemLayout>
503 )}
504 </div>
505
506 <div className="space-y-8 mt-4">
507 {type === 'webhook' && (
508 <>
509 <div className="px-content space-y-8">
510 <LogDrainFormItem
511 value="url"
512 label="Endpoint URL"
513 formControl={form.control}
514 placeholder="https://example.com/log-drain"
515 />
516 <FormField
517 control={form.control}
518 name="http"
519 render={({ field }) => (
520 <FormItemLayout layout="horizontal" label="HTTP Version">
521 <FormControl>
522 <RadioGroupCard
523 className="flex gap-2"
524 onValueChange={field.onChange}
525 value={field.value}
526 >
527 <FormItem asChild>
528 <FormControl>
529 <RadioGroupCardItem value="http1" label="HTTP/1" />
530 </FormControl>
531 </FormItem>
532 <FormItem asChild>
533 <FormControl>
534 <RadioGroupCardItem value="http2" label="HTTP/2" />
535 </FormControl>
536 </FormItem>
537 </RadioGroupCard>
538 </FormControl>
539 </FormItemLayout>
540 )}
541 />
542 </div>
543
544 <FormField
545 control={form.control}
546 name="gzip"
547 render={({ field }) => (
548 <FormItem className="space-y-2 px-4">
549 <div className="flex gap-2 items-center">
550 <FormControl>
551 <Switch checked={field.value} onCheckedChange={field.onChange} />
552 </FormControl>
553 <FormLabel className="text-base">Gzip</FormLabel>
554 <InfoTooltip align="start">
555 Gzip compresses logs before sending it to the destination.
556 </InfoTooltip>
557 </div>
558 </FormItem>
559 )}
560 />
561 </>
562 )}
563 {type === 'datadog' && (
564 <div className="grid gap-4 px-content">
565 <LogDrainFormItem
566 type="password"
567 value="api_key"
568 label="API Key"
569 formControl={form.control}
570 description={
571 <>
572 The API Key obtained from the Datadog dashboard{' '}
573 <a
574 target="_blank"
575 rel="noopener noreferrer"
576 className="text-sm underline transition hover:text-foreground"
577 href="https://app.datadoghq.com/organization-settings/api-keys"
578 >
579 here
580 </a>
581 </>
582 }
583 />
584 <FormField
585 name="region"
586 control={form.control}
587 render={({ field }) => (
588 <FormItemLayout
589 layout="horizontal"
590 label={'Region'}
591 description={
592 <p>
593 The Datadog region to send logs to. Read more about Datadog regions{' '}
594 <a
595 target="_blank"
596 rel="noopener noreferrer"
597 className="underline hover:text-foreground transition"
598 href="https://docs.datadoghq.com/getting_started/site/#access-the-datadog-site"
599 >
600 here
601 </a>
602 .
603 </p>
604 }
605 >
606 <FormControl>
607 <Select value={field.value} onValueChange={field.onChange}>
608 <SelectTrigger className="col-span-3">
609 <SelectValue placeholder="Select a region" />
610 </SelectTrigger>
611 <SelectContent>
612 <SelectGroup>
613 <SelectLabel>Region</SelectLabel>
614 {DATADOG_REGIONS.map((reg) => (
615 <SelectItem key={reg.value} value={reg.value}>
616 {reg.label}
617 </SelectItem>
618 ))}
619 </SelectGroup>
620 </SelectContent>
621 </Select>
622 </FormControl>
623 </FormItemLayout>
624 )}
625 />
626 </div>
627 )}
628 {type === 'loki' && (
629 <div className="grid gap-4 px-content">
630 <LogDrainFormItem
631 type="url"
632 value="url"
633 placeholder="https://my-logs-endpoint.grafana.net/loki/api/v1/push"
634 label="Loki URL"
635 formControl={form.control}
636 description="The Loki HTTP(S) endpoint to send events."
637 />
638 <LogDrainFormItem
639 value="username"
640 label="Username"
641 placeholder="123456789"
642 formControl={form.control}
643 />
644 <LogDrainFormItem
645 type="password"
646 value="password"
647 label="Password"
648 placeholder="glc_ABCD1234567890"
649 formControl={form.control}
650 />
651 </div>
652 )}
653 {type === 'sentry' && (
654 <div className="grid gap-4 px-content">
655 <LogDrainFormItem
656 type="text"
657 value="dsn"
658 label="DSN"
659 placeholder="https://<project_id>@o<organization_id>.ingest.sentry.io/<project_id>"
660 formControl={form.control}
661 description={
662 <>
663 The DSN obtained from the Sentry dashboard. Read more about DSNs{' '}
664 <a
665 target="_blank"
666 rel="noopener noreferrer"
667 className="text-sm underline transition hover:text-foreground"
668 href="https://docs.sentry.io/concepts/key-terms/dsn-explainer/"
669 >
670 here
671 </a>
672 .
673 </>
674 }
675 />
676 </div>
677 )}
678 {type === 's3' && (
679 <div className="grid gap-4 px-content">
680 <LogDrainFormItem
681 value="s3_bucket"
682 label="S3 Bucket"
683 placeholder="my-log-bucket"
684 formControl={form.control}
685 description="The name of an existing S3 bucket."
686 />
687 <LogDrainFormItem
688 value="storage_region"
689 label="Region"
690 placeholder="us-east-1"
691 formControl={form.control}
692 description="AWS region where the bucket is located."
693 />
694 <LogDrainFormItem
695 value="access_key_id"
696 label="Access Key ID"
697 placeholder="AKIA..."
698 formControl={form.control}
699 />
700 <LogDrainFormItem
701 type="password"
702 value="secret_access_key"
703 label="Secret Access Key"
704 placeholder="••••••••••••••••"
705 formControl={form.control}
706 />
707 <LogDrainFormItem
708 type="number"
709 value="batch_timeout"
710 label="Batch Timeout (ms)"
711 placeholder="3000"
712 formControl={form.control}
713 description="Recommended 2000–5000ms."
714 />
715 <p className="text-xs text-foreground-lighter">
716 Ensure the account tied to the Access Key ID can write to the specified
717 bucket.
718 </p>
719 </div>
720 )}
721 {type === 'axiom' && (
722 <div className="grid gap-4 px-content">
723 <LogDrainFormItem
724 type="text"
725 value="dataset_name"
726 label="Dataset name"
727 placeholder="dataset"
728 formControl={form.control}
729 description="Name of the dataset in Axiom where the logs will be sent."
730 />
731 <LogDrainFormItem
732 type="text"
733 value="api_token"
734 label="API Token"
735 placeholder="xaat-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
736 formControl={form.control}
737 description="Token allowing ingest access to the specified dataset"
738 />
739 </div>
740 )}
741 {type === 'otlp' && (
742 <>
743 <div className="grid gap-4 px-content">
744 <LogDrainFormItem
745 type="url"
746 value="endpoint"
747 label="OTLP Endpoint"
748 placeholder="https://otlp.example.com:4318/v1/logs"
749 formControl={form.control}
750 description="The HTTP endpoint for OTLP log ingestion (typically ends with /v1/logs)"
751 />
752 <FormField
753 name="protocol"
754 control={form.control}
755 render={({ field }) => (
756 <FormItemLayout
757 layout="horizontal"
758 label="Protocol"
759 description="Only HTTP with Protocol Buffers is currently supported"
760 >
761 <FormControl>
762 <Select value={field.value} onValueChange={field.onChange}>
763 <SelectTrigger className="col-span-3">
764 <SelectValue placeholder="Select protocol" />
765 </SelectTrigger>
766 <SelectContent>
767 <SelectGroup>
768 <SelectLabel>Protocol</SelectLabel>
769 {OTLP_PROTOCOLS.map((proto) => (
770 <SelectItem key={proto.value} value={proto.value}>
771 {proto.label}
772 </SelectItem>
773 ))}
774 </SelectGroup>
775 </SelectContent>
776 </Select>
777 </FormControl>
778 </FormItemLayout>
779 )}
780 />
781 </div>
782
783 <FormField
784 control={form.control}
785 name="gzip"
786 render={({ field }) => (
787 <FormItem className="space-y-2 px-4">
788 <div className="flex gap-2 items-center">
789 <FormControl>
790 <Switch checked={field.value} onCheckedChange={field.onChange} />
791 </FormControl>
792 <FormLabel className="text-base">Gzip Compression</FormLabel>
793 <InfoTooltip align="start">
794 Enable gzip compression for log data sent to the OTLP endpoint.
795 </InfoTooltip>
796 </div>
797 </FormItem>
798 )}
799 />
800 </>
801 )}
802 {type === 'last9' && (
803 <div className="grid gap-4 px-content">
804 <FormField
805 name="region"
806 control={form.control}
807 render={({ field }) => (
808 <FormItemLayout
809 layout="horizontal"
810 label={'Region'}
811 description={
812 <p>
813 The Last9 region to send logs to. Credentials can be obtained from the
814 Last9 OTEL integration panel.
815 </p>
816 }
817 >
818 <FormControl>
819 <Select value={field.value} onValueChange={field.onChange}>
820 <SelectTrigger className="col-span-3">
821 <SelectValue placeholder="Select a region" />
822 </SelectTrigger>
823 <SelectContent>
824 <SelectGroup>
825 <SelectLabel>Region</SelectLabel>
826 {LAST9_REGIONS.map((reg) => (
827 <SelectItem key={reg.value} value={reg.value}>
828 {reg.label}
829 </SelectItem>
830 ))}
831 </SelectGroup>
832 </SelectContent>
833 </Select>
834 </FormControl>
835 </FormItemLayout>
836 )}
837 />
838 <LogDrainFormItem
839 type="text"
840 value="username"
841 label="Username"
842 placeholder="username"
843 formControl={form.control}
844 description="Username for authentication from Last9 OTEL integration."
845 />
846 <LogDrainFormItem
847 type="password"
848 value="password"
849 label="Password"
850 placeholder="••••••••••••••••"
851 formControl={form.control}
852 description="Password for authentication from Last9 OTEL integration."
853 />
854 </div>
855 )}
856 {type === 'syslog' && (
857 <>
858 <div className="grid gap-4 px-content">
859 <LogDrainFormItem
860 value="host"
861 label="Host"
862 placeholder="logs.example.com"
863 formControl={form.control}
864 description="Hostname or IP address of the syslog receiver."
865 />
866 <LogDrainFormItem
867 type="number"
868 value="port"
869 label="Port"
870 placeholder="514"
871 formControl={form.control}
872 description="Port of the syslog receiver (0–65535)."
873 />
874 <LogDrainFormItem
875 value="structured_data"
876 label="Structured Data"
877 placeholder='[exampleSDID@32473 iut="3" eventSource="Application"]'
878 formControl={form.control}
879 description="Static RFC 5424 Structured Data included in every log frame."
880 />
881 <LogDrainFormItem
882 type="password"
883 value="cipher_key"
884 label="Cipher Key"
885 placeholder="••••••••••••••••"
886 formControl={form.control}
887 description="Base64-encoded 32-byte key for AES-256-GCM encryption of the log body."
888 />
889 </div>
890
891 <FormField
892 control={form.control}
893 name="tls"
894 render={({ field }) => (
895 <FormItem className="space-y-2 px-4">
896 <div className="flex gap-2 items-center">
897 <FormControl>
898 <Switch checked={field.value} onCheckedChange={field.onChange} />
899 </FormControl>
900 <FormLabel className="text-base">TLS</FormLabel>
901 <InfoTooltip align="start">
902 Connect via SSL/TLS instead of plain TCP.
903 </InfoTooltip>
904 </div>
905 </FormItem>
906 )}
907 />
908
909 {tls && (
910 <div className="grid gap-4 px-content">
911 <FormField
912 name="ca_cert"
913 control={form.control}
914 render={({ field }) => (
915 <FormItemLayout
916 layout="horizontal"
917 label="CA Certificate"
918 description="PEM encoded CA certificate for verifying the server. Falls back to the system CA bundle if omitted."
919 >
920 <FormControl>
921 <TextArea
922 className="font-mono text-xs"
923 placeholder="-----BEGIN CERTIFICATE-----"
924 rows={4}
925 {...field}
926 />
927 </FormControl>
928 </FormItemLayout>
929 )}
930 />
931 <FormField
932 name="client_cert"
933 control={form.control}
934 render={({ field }) => (
935 <FormItemLayout
936 layout="horizontal"
937 label="Client Certificate"
938 description="PEM encoded client certificate for mTLS."
939 >
940 <FormControl>
941 <TextArea
942 className="font-mono text-xs"
943 placeholder="-----BEGIN CERTIFICATE-----"
944 rows={4}
945 {...field}
946 />
947 </FormControl>
948 </FormItemLayout>
949 )}
950 />
951 <FormField
952 name="client_key"
953 control={form.control}
954 render={({ field }) => (
955 <FormItemLayout
956 layout="horizontal"
957 label="Client Key"
958 description="PEM encoded client private key for mTLS. Required when a client certificate is provided."
959 >
960 <FormControl>
961 <TextArea
962 className="font-mono text-xs"
963 placeholder="-----BEGIN PRIVATE KEY-----"
964 rows={4}
965 {...field}
966 />
967 </FormControl>
968 </FormItemLayout>
969 )}
970 />
971 </div>
972 )}
973 </>
974 )}
975 {HEADER_ENABLED_TYPES.includes(type as (typeof HEADER_ENABLED_TYPES)[number]) && (
976 <div className="px-content">
977 <FormField
978 control={form.control}
979 name="headerEntries"
980 render={({ fieldState }) => (
981 <FormItemLayout
982 layout="horizontal"
983 label="Custom Headers"
984 description={getHeadersDescription(type)}
985 hideMessage={!fieldState.error?.message}
986 >
987 <KeyValueFieldArray
988 control={form.control}
989 name="headerEntries"
990 keyFieldName="key"
991 valueFieldName="value"
992 createEmptyRow={() => ({ key: '', value: '' })}
993 keyPlaceholder="Header name"
994 valuePlaceholder="Header value"
995 addLabel="Add a new header"
996 removeLabel="Remove header"
997 />
998 </FormItemLayout>
999 )}
1000 />
1001 </div>
1002 )}
1003 </div>
1004 </form>
1005 </Form>
1006 </SheetSection>
1007
1008 <div className="mt-auto">
1009 <SheetSection
1010 className={cn(
1011 `border-t bg-background-alternative-200 mt-auto py-1.5 ${!IS_PLATFORM && 'hidden'}`
1012 )}
1013 >
1014 <ul className="text-right text-foreground-light divide-y divide-dashed text-sm">
1015 <li className="flex items-center justify-between gap-2 py-2" translate="no">
1016 <span className="text-foreground-lighter">Additional drain cost</span>
1017 <span className="text-foreground">$60 per month</span>
1018 </li>
1019 <li className="flex items-center justify-between gap-2 py-2" translate="no">
1020 <span className="text-foreground-lighter">Per million events</span>
1021 <span>+$0.20</span>
1022 </li>
1023 <li className="flex items-center justify-between gap-2 py-2" translate="no">
1024 <span className="text-foreground-lighter">Per GB egress</span>
1025 <span>+$0.09</span>
1026 </li>
1027 </ul>
1028 </SheetSection>
1029
1030 <SheetFooter className="p-content mt-0! justify-between! flex-row! w-full items-center">
1031 <div className="flex flex-col gap-0.5">
1032 <span className="text-sm text-foreground-light">
1033 <span>See full pricing breakdown</span>{' '}
1034 <Link
1035 href={`${DOCS_URL}/guides/platform/manage-your-usage/log-drains`}
1036 target="_blank"
1037 className="text-foreground underline underline-offset-2 decoration-foreground-muted hover:decoration-foreground transition-all"
1038 >
1039 here
1040 </Link>
1041 </span>
1042 <TaxDisclaimer />
1043 </div>
1044 <Button form={FORM_ID} loading={isLoading} htmlType="submit" type="primary">
1045 Save destination
1046 </Button>
1047 </SheetFooter>
1048 </div>
1049 </SheetContent>
1050 </Sheet>
1051 )
1052}