Files
supabase/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx
Gildas Garcia 6cb08304d9 Fix: RadioGroupCard accessibility issues (#48527)
## Problem

The `<RadioGroupCard>` component has an accessibility issue: duplicate
ids on the items.
Besides, its usage in the Design System app has additional issue: no
label on the group itself when used outside a react-hook-form.
Finally, the form example wrap each item in a `FormField` and
`FormControl` which is unnecessary and causes another accessibility
issue as all items are then injected the same `id` prop.

## Solution

- Fix the duplicate ids issue
- Fix all design system example
- Fix the only wrong usage we have in studio

No visual changes

## Notes

When used outside a form, I added aria-label attributes on the group and
they are announced by Mac Voice Over.
However, when used in a form, our components adds a label with the
correct for attribute but it seems that Mac Voice Over does not announce
it.

Not sure about how this should be handled.

## How to test

On
https://design-system-git-fix-radio-group-card-a11y-supabase.vercel.app/design-system/docs/components/radio-group-card,
with Voice Over enabled:
- tab to the first radio group, it should announce the value and the
label of group itself, _Size_
- tab to the second, same but label is _Theme_

On
https://design-system-git-fix-radio-group-card-a11y-supabase.vercel.app/design-system/docs/components/radio-group-card#form
(Form example):
- select any option and submit
- check the correct option is submitted

On
https://studio-staging-git-fix-radio-group-card-a11y-supabase.vercel.app:
- Go to your organization settings, Audit Log Drains, open your devtool
network tab
- Create a new custom endpoint and select the HTTP version
- Check in the network tab that the correct http version is passed

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Accessibility**
* Improved labeling for radio card groups, including size, spacing,
theme, and webhook version selections.
* Radio options now use stable or automatically generated identifiers
with reliable label associations.

* **Bug Fixes**
* Simplified radio option structure in forms for more consistent
behavior.
* Improved ID handling across radio card, stacked, and large radio
options.
* Updated the themed radio card example to use the dark theme by
default.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-31 16:14:09 +02:00

1030 lines
39 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { zodResolver } from '@hookform/resolvers/zod'
import { IS_PLATFORM } from 'common'
import Link from 'next/link'
import { ReactNode, useEffect, useMemo, useRef } from 'react'
import { useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
cn,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
Input,
RadioGroupCard,
RadioGroupCardItem,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
Switch,
TextArea,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { z } from 'zod'
import {
DATADOG_REGIONS,
LAST9_REGIONS,
LOG_DRAIN_TYPES,
LogDrainType,
OTLP_PROTOCOLS,
} from './LogDrains.constants'
import {
getDefaultHeadersByType,
getHeadersSectionDescription as getHeadersDescription,
headerRecordToRows,
headerRowsToRecord,
logDrainHeaderEntriesSchema,
type LogDrainHeaderRow,
} from './LogDrains.utils'
import { useEnabledLogDrainTypes } from './useEnabledLogDrainTypes'
import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer'
import { Shortcut } from '@/components/ui/Shortcut'
import { LogDrainData } from '@/data/log-drains/log-drains-query'
import { DOCS_URL } from '@/lib/constants'
import { httpEndpointUrlSchema } from '@/lib/validation/http-url'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
const FORM_ID = 'log-drain-destination-form'
const headerRecordSchema = z.record(z.string(), z.string())
const webhookFields = {
type: z.literal('webhook'),
url: httpEndpointUrlSchema({
requiredMessage: 'Endpoint URL is required',
invalidMessage: 'Endpoint URL must be a valid URL',
prefixMessage: 'Endpoint URL must start with http:// or https://',
}),
http: z.enum(['http1', 'http2']),
gzip: z.boolean(),
}
const webhookFormSchema = z.object({
...webhookFields,
headerEntries: logDrainHeaderEntriesSchema.optional(),
})
const webhookSubmitSchema = z.object({
...webhookFields,
headers: headerRecordSchema.optional(),
})
const datadogSchema = z.object({
type: z.literal('datadog'),
api_key: z.string().min(1, { message: 'API key is required' }),
region: z.string().min(1, { message: 'Region is required' }),
})
const lokiFields = {
type: z.literal('loki'),
url: httpEndpointUrlSchema({
requiredMessage: 'Loki URL is required',
invalidMessage: 'Loki URL must be a valid URL',
prefixMessage: 'Loki URL must start with http:// or https://',
}),
username: z.string().optional(),
password: z.string().optional(),
}
const lokiFormSchema = z.object({
...lokiFields,
headerEntries: logDrainHeaderEntriesSchema.optional(),
})
const lokiSubmitSchema = z.object({
...lokiFields,
headers: headerRecordSchema,
})
const postgresSchema = z.object({
type: z.literal('postgres'),
})
const bigquerySchema = z.object({
type: z.literal('bigquery'),
})
const clickhouseSchema = z.object({
type: z.literal('clickhouse'),
})
const s3Schema = z.object({
type: z.literal('s3'),
s3_bucket: z.string().min(1, { message: 'Bucket name is required' }),
storage_region: z.string().min(1, { message: 'Region is required' }),
access_key_id: z.string().min(1, { message: 'Access Key ID is required' }),
secret_access_key: z.string().min(1, { message: 'Secret Access Key is required' }),
batch_timeout: z.coerce
.number()
.int({ message: 'Batch timeout must be an integer' })
.min(1, { message: 'Batch timeout must be a positive integer' }),
})
const sentrySchema = z.object({
type: z.literal('sentry'),
dsn: z
.string()
.min(1, { message: 'Sentry DSN is required' })
.refine((dsn) => dsn.startsWith('https://'), 'Sentry DSN must start with https://'),
})
const axiomSchema = z.object({
type: z.literal('axiom'),
api_token: z.string().min(1, { message: 'API token is required' }),
dataset_name: z.string().min(1, { message: 'Dataset name is required' }),
})
const last9Schema = z.object({
type: z.literal('last9'),
region: z.string().min(1, { message: 'Region is required' }),
username: z.string().min(1, { message: 'Username is required' }),
password: z.string().min(1, { message: 'Password is required' }),
})
const otlpFields = {
type: z.literal('otlp'),
endpoint: httpEndpointUrlSchema({
requiredMessage: 'OTLP endpoint is required',
invalidMessage: 'OTLP endpoint must be a valid URL',
prefixMessage: 'OTLP endpoint must start with http:// or https://',
}),
protocol: z.string().optional().default('http/protobuf'),
gzip: z.boolean().optional().default(true),
}
const otlpFormSchema = z.object({
...otlpFields,
headerEntries: logDrainHeaderEntriesSchema.optional(),
})
const otlpSubmitSchema = z.object({
...otlpFields,
headers: headerRecordSchema.optional(),
})
const syslogSchema = z.object({
type: z.literal('syslog'),
host: z.string().min(1, { message: 'Host is required' }),
port: z.coerce
.number()
.int({ message: 'Port must be an integer' })
.min(0, { message: 'Port must be between 0 and 65535' })
.max(65535, { message: 'Port must be between 0 and 65535' }),
tls: z.boolean().optional().default(false),
structured_data: z.string().optional(),
cipher_key: z.string().optional(),
ca_cert: z.string().optional(),
client_cert: z.string().optional(),
client_key: z.string().optional(),
})
const formUnion = z.discriminatedUnion('type', [
webhookFormSchema,
datadogSchema,
lokiFormSchema,
postgresSchema,
bigquerySchema,
clickhouseSchema,
s3Schema,
sentrySchema,
axiomSchema,
last9Schema,
otlpFormSchema,
syslogSchema,
])
const submitUnion = z.discriminatedUnion('type', [
webhookSubmitSchema,
datadogSchema,
lokiSubmitSchema,
postgresSchema,
bigquerySchema,
clickhouseSchema,
s3Schema,
sentrySchema,
axiomSchema,
last9Schema,
otlpSubmitSchema,
syslogSchema,
])
const formSchema = z
.object({
name: z.string().min(1, {
message: 'Destination name is required',
}),
description: z.string().optional(),
})
.and(formUnion)
.superRefine((data, ctx) => {
if (data.type !== 'syslog') return
if (data.client_cert && !data.client_key) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Client key is required when a client certificate is provided',
path: ['client_key'],
})
}
if (data.client_key && !data.client_cert) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Client certificate is required when a client key is provided',
path: ['client_cert'],
})
}
})
const submitSchema = z
.object({
name: z.string().min(1, {
message: 'Destination name is required',
}),
description: z.string().optional(),
})
.and(submitUnion)
type LogDrainDestinationFormValues = z.infer<typeof formSchema>
type LogDrainDestinationSubmitValues = z.infer<typeof submitSchema>
const HEADER_ENABLED_TYPES = ['webhook', 'loki', 'otlp'] as const
export function toSubmitValues(
values: LogDrainDestinationFormValues
): LogDrainDestinationSubmitValues {
if (!HEADER_ENABLED_TYPES.includes(values.type as (typeof HEADER_ENABLED_TYPES)[number])) {
return submitSchema.parse(values)
}
const { headerEntries = [], ...rest } = values as LogDrainDestinationFormValues & {
headerEntries?: LogDrainHeaderRow[]
}
const headers = headerRowsToRecord(headerEntries)
const transformedValues =
rest.type === 'loki'
? { ...rest, headers }
: Object.keys(headers).length > 0
? { ...rest, headers }
: rest
return submitSchema.parse(transformedValues)
}
function LogDrainFormItem({
value,
label,
description,
formControl,
placeholder,
type,
}: {
value: string
label: string
formControl: any
placeholder?: string
description?: ReactNode
type?: string
}) {
return (
<FormField
name={value}
control={formControl}
render={({ field }) => (
<FormItemLayout layout="horizontal" label={label} description={description || ''}>
<FormControl>
<Input type={type || 'text'} placeholder={placeholder} {...field} />
</FormControl>
</FormItemLayout>
)}
/>
)
}
type DefaultValues = { type: LogDrainType } & Partial<LogDrainData>
export function LogDrainDestinationSheetForm({
open,
onOpenChange,
defaultValues,
onSubmit,
isLoading,
mode,
existingDrainNames = [],
onSaveClick,
}: {
open: boolean
onOpenChange: (v: boolean) => void
defaultValues?: DefaultValues
isLoading?: boolean
onSubmit: (values: LogDrainDestinationSubmitValues) => void
mode: 'create' | 'update'
existingDrainNames?: string[]
onSaveClick?: (type: LogDrainType) => void
}) {
// NOTE(kamil): This used to be `any` for a long long time, but after moving to Zod,
// it produces a correct union type of all possible configs. Unfortunately, this type was not designed correctly
// and it does not include `type` inside the config itself, so it's not trivial to create `discriminatedUnion`
// out of it, therefore for an ease of use now, we bail to `any` until the better time come.
const defaultType = defaultValues?.type || 'webhook'
const defaultHeaderEntries = useMemo(() => {
const config = (defaultValues?.config || {}) as any
const type = defaultValues?.type || 'webhook'
return headerRecordToRows(
mode === 'create' ? getDefaultHeadersByType(type) : config?.headers || {}
)
}, [defaultValues, mode])
const enabledLogDrainTypes = useEnabledLogDrainTypes()
const formRef = useRef<HTMLFormElement>(null)
const formValues = useMemo(() => {
const config = (defaultValues?.config || {}) as any
const type = defaultValues?.type || 'webhook'
return {
name: defaultValues?.name || '',
description: defaultValues?.description || '',
type,
http: config?.http || 'http2',
gzip: mode === 'create' ? true : config?.gzip || false,
headerEntries: defaultHeaderEntries,
url: config?.url || '',
api_key: config?.api_key || '',
region: config?.region || '',
username: config?.username || '',
password: config?.password || '',
dsn: config?.dsn || '',
s3_bucket: config?.s3_bucket || '',
storage_region: config?.storage_region || '',
access_key_id: config?.access_key_id || '',
secret_access_key: config?.secret_access_key || '',
batch_timeout: config?.batch_timeout ?? 3000,
dataset_name: config?.dataset_name || '',
api_token: config?.api_token || '',
endpoint: config?.endpoint || '',
protocol: config?.protocol || 'http/protobuf',
host: config?.host || '',
port: (config?.port ?? '') as number,
tls: config?.tls ?? false,
structured_data: config?.structured_data || '',
cipher_key: config?.cipher_key || '',
ca_cert: config?.ca_cert || '',
client_cert: config?.client_cert || '',
client_key: config?.client_key || '',
}
}, [defaultValues, mode, defaultHeaderEntries])
const form = useForm<LogDrainDestinationFormValues>({
resolver: zodResolver(formSchema),
values: formValues,
})
const type = useWatch({ control: form.control, name: 'type' })
const tls = useWatch({ control: form.control, name: 'tls' })
useEffect(() => {
if (mode === 'create' && !open) {
form.reset()
}
}, [mode, open, form])
useEffect(() => {
if (!open || mode !== 'create') return
form.setValue('headerEntries', headerRecordToRows(getDefaultHeadersByType(type)))
form.clearErrors('headerEntries')
}, [form, mode, open, type])
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent showClose={false} size="lg" className="overflow-y-auto flex flex-col">
<SheetHeader>
<SheetTitle>Add destination</SheetTitle>
</SheetHeader>
<SheetSection className="px-0! pb-0!">
<Form {...form}>
<form
ref={formRef}
id={FORM_ID}
onSubmit={(e) => {
e.preventDefault()
// Temp check to make sure the name is unique
const logDrainName = form.getValues('name')
const logDrainExists = existingDrainNames.includes(logDrainName)
if (logDrainExists && mode === 'create') {
toast.error('Log drain name already exists')
return
}
form.handleSubmit((values) => {
onSubmit(toSubmitValues(values))
onSaveClick?.(values.type)
})(e)
}}
>
<div className="space-y-8 px-content">
<LogDrainFormItem
value="name"
placeholder="My Destination"
label="Name"
formControl={form.control}
/>
<LogDrainFormItem
value="description"
placeholder="Optional description"
label="Description"
formControl={form.control}
/>
{mode === 'create' && (
<FormItemLayout
layout="horizontal"
label="Type"
description={LOG_DRAIN_TYPES.find((t) => t.value === type)?.description || ''}
>
<Select
defaultValue={defaultType}
value={form.getValues('type')}
onValueChange={(v: LogDrainType) => form.setValue('type', v)}
>
<SelectTrigger>
{LOG_DRAIN_TYPES.find((t) => t.value === type)?.name}
</SelectTrigger>
<SelectContent>
{enabledLogDrainTypes.map((type) => (
<SelectItem
value={type.value}
key={type.value}
id={type.value}
className="text-left"
>
{type.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
)}
</div>
<div className="space-y-8 mt-4">
{type === 'webhook' && (
<>
<div className="px-content space-y-8">
<LogDrainFormItem
value="url"
label="Endpoint URL"
formControl={form.control}
placeholder="https://example.com/log-drain"
/>
<FormField
control={form.control}
name="http"
render={({ field }) => (
<FormItemLayout layout="horizontal" label="HTTP Version">
<FormControl>
<RadioGroupCard
className="flex gap-2"
onValueChange={field.onChange}
value={field.value}
>
<RadioGroupCardItem value="http1" label="HTTP/1" />
<RadioGroupCardItem value="http2" label="HTTP/2" />
</RadioGroupCard>
</FormControl>
</FormItemLayout>
)}
/>
</div>
<FormField
control={form.control}
name="gzip"
render={({ field }) => (
<FormItem className="space-y-2 px-4">
<div className="flex gap-2 items-center">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="text-base">Gzip</FormLabel>
<InfoTooltip align="start">
Gzip compresses logs before sending it to the destination.
</InfoTooltip>
</div>
</FormItem>
)}
/>
</>
)}
{type === 'datadog' && (
<div className="grid gap-4 px-content">
<LogDrainFormItem
type="password"
value="api_key"
label="API Key"
formControl={form.control}
description={
<>
The API Key obtained from the Datadog dashboard{' '}
<a
target="_blank"
rel="noopener noreferrer"
className="text-sm underline transition hover:text-foreground"
href="https://app.datadoghq.com/organization-settings/api-keys"
>
here
</a>
</>
}
/>
<FormField
name="region"
control={form.control}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label={'Region'}
description={
<p>
The Datadog region to send logs to. Read more about Datadog regions{' '}
<a
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition"
href="https://docs.datadoghq.com/getting_started/site/#access-the-datadog-site"
>
here
</a>
.
</p>
}
>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select a region" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Region</SelectLabel>
{DATADOG_REGIONS.map((reg) => (
<SelectItem key={reg.value} value={reg.value}>
{reg.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
</div>
)}
{type === 'loki' && (
<div className="grid gap-4 px-content">
<LogDrainFormItem
type="url"
value="url"
placeholder="https://my-logs-endpoint.grafana.net/loki/api/v1/push"
label="Loki URL"
formControl={form.control}
description="The Loki HTTP(S) endpoint to send events."
/>
<LogDrainFormItem
value="username"
label="Username"
placeholder="123456789"
formControl={form.control}
/>
<LogDrainFormItem
type="password"
value="password"
label="Password"
placeholder="glc_ABCD1234567890"
formControl={form.control}
/>
</div>
)}
{type === 'sentry' && (
<div className="grid gap-4 px-content">
<LogDrainFormItem
type="text"
value="dsn"
label="DSN"
placeholder="https://<project_id>@o<organization_id>.ingest.sentry.io/<project_id>"
formControl={form.control}
description={
<>
The DSN obtained from the Sentry dashboard. Read more about DSNs{' '}
<a
target="_blank"
rel="noopener noreferrer"
className="text-sm underline transition hover:text-foreground"
href="https://docs.sentry.io/concepts/key-terms/dsn-explainer/"
>
here
</a>
.
</>
}
/>
</div>
)}
{type === 's3' && (
<div className="grid gap-4 px-content">
<LogDrainFormItem
value="s3_bucket"
label="S3 Bucket"
placeholder="my-log-bucket"
formControl={form.control}
description="The name of an existing S3 bucket."
/>
<LogDrainFormItem
value="storage_region"
label="Region"
placeholder="us-east-1"
formControl={form.control}
description="AWS region where the bucket is located."
/>
<LogDrainFormItem
value="access_key_id"
label="Access Key ID"
placeholder="AKIA..."
formControl={form.control}
/>
<LogDrainFormItem
type="password"
value="secret_access_key"
label="Secret Access Key"
placeholder="••••••••••••••••"
formControl={form.control}
/>
<LogDrainFormItem
type="number"
value="batch_timeout"
label="Batch Timeout (ms)"
placeholder="3000"
formControl={form.control}
description="Recommended 2000–5000ms."
/>
<p className="text-xs text-foreground-lighter">
Ensure the account tied to the Access Key ID can write to the specified
bucket.
</p>
</div>
)}
{type === 'axiom' && (
<div className="grid gap-4 px-content">
<LogDrainFormItem
type="text"
value="dataset_name"
label="Dataset name"
placeholder="dataset"
formControl={form.control}
description="Name of the dataset in Axiom where the logs will be sent."
/>
<LogDrainFormItem
type="text"
value="api_token"
label="API Token"
placeholder="xaat-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
formControl={form.control}
description="Token allowing ingest access to the specified dataset"
/>
</div>
)}
{type === 'otlp' && (
<>
<div className="grid gap-4 px-content">
<LogDrainFormItem
type="url"
value="endpoint"
label="OTLP Endpoint"
placeholder="https://otlp.example.com:4318/v1/logs"
formControl={form.control}
description="The HTTP endpoint for OTLP log ingestion (typically ends with /v1/logs)"
/>
<FormField
name="protocol"
control={form.control}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Protocol"
description="Only HTTP with Protocol Buffers is currently supported"
>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select protocol" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Protocol</SelectLabel>
{OTLP_PROTOCOLS.map((proto) => (
<SelectItem key={proto.value} value={proto.value}>
{proto.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
</div>
<FormField
control={form.control}
name="gzip"
render={({ field }) => (
<FormItem className="space-y-2 px-4">
<div className="flex gap-2 items-center">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="text-base">Gzip Compression</FormLabel>
<InfoTooltip align="start">
Enable gzip compression for log data sent to the OTLP endpoint.
</InfoTooltip>
</div>
</FormItem>
)}
/>
</>
)}
{type === 'last9' && (
<div className="grid gap-4 px-content">
<FormField
name="region"
control={form.control}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label={'Region'}
description={
<p>
The Last9 region to send logs to. Credentials can be obtained from the
Last9 OTEL integration panel.
</p>
}
>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select a region" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Region</SelectLabel>
{LAST9_REGIONS.map((reg) => (
<SelectItem key={reg.value} value={reg.value}>
{reg.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItemLayout>
)}
/>
<LogDrainFormItem
type="text"
value="username"
label="Username"
placeholder="username"
formControl={form.control}
description="Username for authentication from Last9 OTEL integration."
/>
<LogDrainFormItem
type="password"
value="password"
label="Password"
placeholder="••••••••••••••••"
formControl={form.control}
description="Password for authentication from Last9 OTEL integration."
/>
</div>
)}
{type === 'syslog' && (
<>
<div className="grid gap-4 px-content">
<LogDrainFormItem
value="host"
label="Host"
placeholder="logs.example.com"
formControl={form.control}
description="Hostname or IP address of the syslog receiver."
/>
<LogDrainFormItem
type="number"
value="port"
label="Port"
placeholder="514"
formControl={form.control}
description="Port of the syslog receiver (0–65535)."
/>
<LogDrainFormItem
value="structured_data"
label="Structured Data"
placeholder='[exampleSDID@32473 iut="3" eventSource="Application"]'
formControl={form.control}
description="Static RFC 5424 Structured Data included in every log frame."
/>
<LogDrainFormItem
type="password"
value="cipher_key"
label="Cipher Key"
placeholder="••••••••••••••••"
formControl={form.control}
description="Base64-encoded 32-byte key for AES-256-GCM encryption of the log body."
/>
</div>
<FormField
control={form.control}
name="tls"
render={({ field }) => (
<FormItem className="space-y-2 px-4">
<div className="flex gap-2 items-center">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="text-base">TLS</FormLabel>
<InfoTooltip align="start">
Connect via SSL/TLS instead of plain TCP.
</InfoTooltip>
</div>
</FormItem>
)}
/>
{tls && (
<div className="grid gap-4 px-content">
<FormField
name="ca_cert"
control={form.control}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="CA Certificate"
description="PEM encoded CA certificate for verifying the server. Falls back to the system CA bundle if omitted."
>
<FormControl>
<TextArea
className="font-mono text-xs"
placeholder="-----BEGIN CERTIFICATE-----"
rows={4}
{...field}
/>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
name="client_cert"
control={form.control}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Client Certificate"
description="PEM encoded client certificate for mTLS."
>
<FormControl>
<TextArea
className="font-mono text-xs"
placeholder="-----BEGIN CERTIFICATE-----"
rows={4}
{...field}
/>
</FormControl>
</FormItemLayout>
)}
/>
<FormField
name="client_key"
control={form.control}
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Client Key"
description="PEM encoded client private key for mTLS. Required when a client certificate is provided."
>
<FormControl>
<TextArea
className="font-mono text-xs"
placeholder="-----BEGIN PRIVATE KEY-----"
rows={4}
{...field}
/>
</FormControl>
</FormItemLayout>
)}
/>
</div>
)}
</>
)}
{HEADER_ENABLED_TYPES.includes(type as (typeof HEADER_ENABLED_TYPES)[number]) && (
<div className="px-content">
<FormField
control={form.control}
name="headerEntries"
render={({ fieldState }) => (
<FormItemLayout
layout="horizontal"
label="Custom Headers"
description={getHeadersDescription(type)}
hideMessage={!fieldState.error?.message}
>
<KeyValueFieldArray
control={form.control}
name="headerEntries"
keyFieldName="key"
valueFieldName="value"
createEmptyRow={() => ({ key: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add a new header"
removeLabel="Remove header"
/>
</FormItemLayout>
)}
/>
</div>
)}
</div>
</form>
</Form>
</SheetSection>
<div className="mt-auto">
<SheetSection
className={cn(
`border-t bg-background-alternative-200 mt-auto py-1.5 ${!IS_PLATFORM && 'hidden'}`
)}
>
<ul className="text-right text-foreground-light divide-y divide-dashed text-sm">
<li className="flex items-center justify-between gap-2 py-2" translate="no">
<span className="text-foreground-lighter">Additional drain cost</span>
<span className="text-foreground">$60 per month</span>
</li>
<li className="flex items-center justify-between gap-2 py-2" translate="no">
<span className="text-foreground-lighter">Per million events</span>
<span>+$0.20</span>
</li>
<li className="flex items-center justify-between gap-2 py-2" translate="no">
<span className="text-foreground-lighter">Per GB egress</span>
<span>+$0.09</span>
</li>
</ul>
</SheetSection>
<SheetFooter className="p-content mt-0! justify-between! flex-row! w-full items-center">
<div className="flex flex-col gap-0.5">
<span className="text-sm text-foreground-light">
<span>See full pricing breakdown</span>{' '}
<Link
href={`${DOCS_URL}/guides/platform/manage-your-usage/log-drains`}
target="_blank"
className="text-foreground underline underline-offset-2 decoration-foreground-muted hover:decoration-foreground transition-all"
>
here
</Link>
</span>
<TaxDisclaimer />
</div>
<Shortcut
id={SHORTCUT_IDS.LOG_DRAINS_SAVE_DESTINATION}
onTrigger={() => formRef.current?.requestSubmit()}
options={{ enabled: open && !isLoading }}
side="top"
>
<Button form={FORM_ID} loading={isLoading} type="submit" variant="primary">
Save destination
</Button>
</Shortcut>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
)
}