import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import Link from 'next/link' import { useState } from 'react' import { SubmitHandler, useForm, useWatch } from 'react-hook-form' import { toast } from 'sonner' import { Button, Card, CardContent, CardFooter, Form, FormControl, FormField, FormInputGroupInput, FormMessage, InputGroup, InputGroupAddon, InputGroupText, Switch, } from 'ui' import { Admonition } from 'ui-patterns/Admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { SuspensionNotice } from '../Settings/SuspensionNotice' import { AlertError } from '@/components/ui/AlertError' import { ToggleSpendCapButton } from '@/components/ui/ToggleSpendCapButton' import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query' import { useMaxConnectionsQuery } from '@/data/database/max-connections-query' import { useRealtimeConfigurationUpdateMutation } from '@/data/realtime/realtime-config-mutation' import { REALTIME_DEFAULT_CONFIG, useRealtimeConfigurationQuery, } from '@/data/realtime/realtime-config-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' const formId = 'realtime-configuration-form' const REALTIME_SOFT_LIMITS = { max_concurrent_users: 300_000, max_events_per_second: 50_000, max_presence_events_per_second: 5_000, max_payload_size_in_kb: 3_000, } const MAX_POSTGRES_CHANGES_POOL = 20 export const RealtimeSettings = () => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() const { data: organization, isSuccess: isSuccessOrganization } = useSelectedOrganizationQuery() const { can: canUpdateConfig, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.REALTIME_ADMIN_READ, '*' ) const [isConfirmNextModalOpen, setIsConfirmNextModalOpen] = useState(false) const { data: maxConn } = useMaxConnectionsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) const { data, error, isError, isPending } = useRealtimeConfigurationQuery({ projectRef }) const { data: policies, isSuccess: isSuccessPolicies } = useDatabasePoliciesQuery({ projectRef, connectionString: project?.connectionString, schemas: ['realtime'], }) // Per-plan realtime ceilings come from the org's entitlements (plan + any overrides). // The effective limit is the lower of the entitlement and the soft cap, so an unlimited // plan is still bounded and self-hosted / loading falls back to the soft cap. const { getEntitlementMax: getMaxConcurrentUsers, isSuccess: isSuccessMaxConcurrentUsers } = useCheckEntitlements('realtime.max_concurrent_users') const { getEntitlementMax: getMaxEventsPerSecond, isSuccess: isSuccessMaxEventsPerSecond } = useCheckEntitlements('realtime.max_events_per_second') const { getEntitlementMax: getMaxPresenceEventsPerSecond, isSuccess: isSuccessMaxPresenceEventsPerSecond, } = useCheckEntitlements('realtime.max_presence_events_per_second') const { getEntitlementMax: getMaxPayloadSizeInKb, isSuccess: isSuccessMaxPayloadSizeInKb } = useCheckEntitlements('realtime.max_payload_size_in_kb') const isRealtimeEntitlementsLoaded = isSuccessMaxConcurrentUsers && isSuccessMaxEventsPerSecond && isSuccessMaxPresenceEventsPerSecond && isSuccessMaxPayloadSizeInKb const isLoading = isPending || !isRealtimeEntitlementsLoaded const maxConcurrentUsersLimit = Math.min( getMaxConcurrentUsers() ?? Infinity, REALTIME_SOFT_LIMITS.max_concurrent_users ) const maxEventsPerSecondLimit = Math.min( getMaxEventsPerSecond() ?? Infinity, REALTIME_SOFT_LIMITS.max_events_per_second ) const maxPresenceEventsPerSecondLimit = Math.min( getMaxPresenceEventsPerSecond() ?? Infinity, REALTIME_SOFT_LIMITS.max_presence_events_per_second ) const maxPayloadSizeInKbLimit = Math.min( getMaxPayloadSizeInKb() ?? Infinity, REALTIME_SOFT_LIMITS.max_payload_size_in_kb ) const isFreePlan = organization?.plan.id === 'free' const isUsageBillingEnabled = organization?.usage_billing_enabled const isRealtimeDisabled = data?.suspend ?? REALTIME_DEFAULT_CONFIG.suspend const isAdminSuspended = Boolean(data?.admin_suspended_at) // Check if RLS policies exist for realtime.messages table const realtimeMessagesPolicies = policies?.filter( (policy) => policy.schema === 'realtime' && policy.table === 'messages' ) const hasRealtimeMessagesPolicies = realtimeMessagesPolicies && realtimeMessagesPolicies.length > 0 const { mutate: updateRealtimeConfig, isPending: isUpdatingConfig } = useRealtimeConfigurationUpdateMutation({ onSuccess: () => { form.reset(form.getValues()) toast.success('Successfully updated realtime settings') setIsConfirmNextModalOpen(false) }, }) const FormSchema = z.discriminatedUnion('suspend', [ z.object({ suspend: z.literal(true), connection_pool: z.coerce .number() .min(1) .max(maxConn?.maxConnections ?? 100) .optional(), postgres_changes_pool: z.coerce.number().min(1).max(MAX_POSTGRES_CHANGES_POOL).optional(), max_concurrent_users: z.coerce .number() .min(1) .max( maxConcurrentUsersLimit, `Cannot exceed ${maxConcurrentUsersLimit.toLocaleString()} concurrent clients` ) .optional(), max_events_per_second: z.coerce .number() .min(1) .max( maxEventsPerSecondLimit, `Cannot exceed ${maxEventsPerSecondLimit.toLocaleString()} events per second` ) .optional(), max_presence_events_per_second: z.coerce .number() .min(1) .max( maxPresenceEventsPerSecondLimit, `Cannot exceed ${maxPresenceEventsPerSecondLimit.toLocaleString()} presence events per second` ) .optional(), max_payload_size_in_kb: z.coerce .number() .min(1) .max( maxPayloadSizeInKbLimit, `Cannot exceed ${maxPayloadSizeInKbLimit.toLocaleString()} KB` ) .optional(), // [Joshen] These fields are temporarily hidden from the UI // max_bytes_per_second: z.coerce.number().min(1).max(10000000).optional(), // max_channels_per_client: z.coerce.number().min(1).max(10000).optional(), // max_joins_per_second: z.coerce.number().min(1).max(5000).optional(), allow_public: z.boolean().optional(), }), z.object({ suspend: z.literal(false), connection_pool: z.coerce .number() .min(1) .max(maxConn?.maxConnections ?? 100), postgres_changes_pool: z.coerce.number().min(1).max(MAX_POSTGRES_CHANGES_POOL), max_concurrent_users: z.coerce .number() .min(1) .max( maxConcurrentUsersLimit, `Cannot exceed ${maxConcurrentUsersLimit.toLocaleString()} concurrent clients` ), max_events_per_second: z.coerce .number() .min(1) .max( maxEventsPerSecondLimit, `Cannot exceed ${maxEventsPerSecondLimit.toLocaleString()} events per second` ), max_presence_events_per_second: z.coerce .number() .min(1) .max( maxPresenceEventsPerSecondLimit, `Cannot exceed ${maxPresenceEventsPerSecondLimit.toLocaleString()} presence events per second` ), max_payload_size_in_kb: z.coerce .number() .min(1) .max( maxPayloadSizeInKbLimit, `Cannot exceed ${maxPayloadSizeInKbLimit.toLocaleString()} KB` ), // [Joshen] These fields are temporarily hidden from the UI // max_bytes_per_second: z.coerce.number().min(1).max(10000000), // max_channels_per_client: z.coerce.number().min(1).max(10000), // max_joins_per_second: z.coerce.number().min(1).max(5000), allow_public: z.boolean(), }), ]) const configValues = data ?? REALTIME_DEFAULT_CONFIG const sharedFormValues = { connection_pool: configValues.connection_pool ?? REALTIME_DEFAULT_CONFIG.connection_pool, postgres_changes_pool: configValues.postgres_changes_pool ?? REALTIME_DEFAULT_CONFIG.postgres_changes_pool, max_concurrent_users: configValues.max_concurrent_users ?? REALTIME_DEFAULT_CONFIG.max_concurrent_users, max_events_per_second: configValues.max_events_per_second ?? REALTIME_DEFAULT_CONFIG.max_events_per_second, max_presence_events_per_second: configValues.max_presence_events_per_second ?? REALTIME_DEFAULT_CONFIG.max_presence_events_per_second, max_payload_size_in_kb: configValues.max_payload_size_in_kb ?? REALTIME_DEFAULT_CONFIG.max_payload_size_in_kb, allow_public: !(configValues.private_only ?? REALTIME_DEFAULT_CONFIG.private_only), } const formValues: z.infer = { suspend: configValues.suspend ?? REALTIME_DEFAULT_CONFIG.suspend, ...sharedFormValues, } const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: { ...REALTIME_DEFAULT_CONFIG, allow_public: !REALTIME_DEFAULT_CONFIG.private_only, }, values: formValues, }) const [allow_public, suspend] = useWatch({ control: form.control, name: ['allow_public', 'suspend'], }) const isSettingToPrivate = !data?.private_only && !allow_public const isDisablingRealtime = !isRealtimeDisabled && suspend const isEnablingRealtime = isRealtimeDisabled && !suspend const onSubmit: SubmitHandler> = (_data) => { if (!projectRef) return console.error('Project ref is required') if (!isRealtimeEntitlementsLoaded) return setIsConfirmNextModalOpen(true) } const onConfirmSave = () => { if (!projectRef) return console.error('Project ref is required') if (!isRealtimeEntitlementsLoaded) return const values = form.getValues() // [Joshen] Casting to `Number` here as the values are being set as string when edited in the form // and returned in form.getValues() - I might be missing some easy util function from RHF though updateRealtimeConfig({ ref: projectRef, private_only: !values.allow_public, connection_pool: Number( values.connection_pool ?? data?.connection_pool ?? REALTIME_DEFAULT_CONFIG.connection_pool ), postgres_changes_pool: Number( values.postgres_changes_pool ?? data?.postgres_changes_pool ?? REALTIME_DEFAULT_CONFIG.postgres_changes_pool ), max_concurrent_users: Number( values.max_concurrent_users ?? data?.max_concurrent_users ?? REALTIME_DEFAULT_CONFIG.max_concurrent_users ), max_events_per_second: Number( values.max_events_per_second ?? data?.max_events_per_second ?? REALTIME_DEFAULT_CONFIG.max_events_per_second ), max_presence_events_per_second: Number( values.max_presence_events_per_second ?? data?.max_presence_events_per_second ?? REALTIME_DEFAULT_CONFIG.max_presence_events_per_second ), max_payload_size_in_kb: Number( values.max_payload_size_in_kb ?? data?.max_payload_size_in_kb ?? REALTIME_DEFAULT_CONFIG.max_payload_size_in_kb ), suspend: values.suspend, }) } if (isLoading) { return ( ) } return ( <> {isAdminSuspended && }
{isError ? ( ) : ( ( <> field.onChange(!checked)} disabled={!canUpdateConfig} /> )} /> {(isRealtimeDisabled || isDisablingRealtime || isEnablingRealtime) && ( )} {!suspend && ( <> ( <> {isSuccessPolicies && !hasRealtimeMessagesPolicies && !allow_public && !isRealtimeDisabled && ( Private mode is {isSettingToPrivate ? 'being ' : ''} enabled, but no RLS policies exist on the{' '} realtime.messages{' '} table. No messages will be received by users.

} actions={ } /> )} )} />
( <> connections {!!maxConn && field.value && field.value > maxConn.maxConnections * 0.5 && ( )} )} /> ( connections )} /> ( clients )} /> ( events/s )} /> {isSuccessOrganization && !isUsageBillingEnabled && (
Spend cap needs to be disabled to configure this value

{isFreePlan ? 'Upgrade to the Pro plan first to disable spend cap' : 'You may adjust this setting in the organization billing settings'}

{isFreePlan ? ( ) : ( )}
)}
( events/s )} /> {isSuccessOrganization && !isUsageBillingEnabled && (
Spend cap needs to be disabled to configure this value

{isFreePlan ? 'Upgrade to the Pro plan first to disable spend cap' : 'You may adjust this setting in the organization billing settings'}

{isFreePlan ? ( ) : ( )}
)}
( KB )} /> {isSuccessOrganization && !isUsageBillingEnabled && (
Spend cap needs to be disabled to configure this value

{isFreePlan ? 'Upgrade to the Pro plan first to disable spend cap' : 'You may adjust this setting in the organization billing settings'}

{isFreePlan ? ( ) : ( )}
)}
)}
{isPermissionsLoaded && !canUpdateConfig && (

You need additional permissions to update realtime settings

)}
{form.formState.isDirty && ( )}
)} setIsConfirmNextModalOpen(false)} onConfirm={() => onConfirmSave()} >

Saving the changes will disconnect all the clients connected to your project. Are you sure you want to continue?

) }