| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590 |
- 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 } 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'
- import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
- import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
- import * as z from 'zod'
- 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 { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- const formId = 'realtime-configuration-form'
- 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 } = useRealtimeConfigurationQuery({
- projectRef,
- })
- const { data: policies, isSuccess: isSuccessPolicies } = useDatabasePoliciesQuery({
- projectRef,
- connectionString: project?.connectionString,
- schema: 'realtime',
- })
- const isFreePlan = organization?.plan.id === 'free'
- const isUsageBillingEnabled = organization?.usage_billing_enabled
- const isRealtimeDisabled = data?.suspend ?? REALTIME_DEFAULT_CONFIG.suspend
- // 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(),
- max_concurrent_users: z.coerce.number().min(1).max(50000).optional(),
- max_events_per_second: z.coerce.number().min(1).max(10000).optional(),
- max_presence_events_per_second: z.coerce.number().min(1).max(10000).optional(),
- max_payload_size_in_kb: z.coerce.number().min(1).max(3000).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),
- max_concurrent_users: z.coerce.number().min(1).max(50000),
- max_events_per_second: z.coerce.number().min(1).max(10000),
- max_presence_events_per_second: z.coerce.number().min(1).max(10000),
- max_payload_size_in_kb: z.coerce.number().min(1).max(3000),
- // [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 form = useForm<z.infer<typeof FormSchema>>({
- resolver: zodResolver(FormSchema as any),
- defaultValues: {
- ...REALTIME_DEFAULT_CONFIG,
- allow_public: !REALTIME_DEFAULT_CONFIG.private_only,
- },
- values: {
- ...(data ?? REALTIME_DEFAULT_CONFIG),
- allow_public: !(data?.private_only ?? REALTIME_DEFAULT_CONFIG.private_only),
- } as any,
- })
- const { allow_public, suspend } = form.watch()
- const isSettingToPrivate = !data?.private_only && !allow_public
- const isDisablingRealtime = !isRealtimeDisabled && suspend
- const isEnablingRealtime = isRealtimeDisabled && !suspend
- const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = (_data) => {
- if (!projectRef) return console.error('Project ref is required')
- setIsConfirmNextModalOpen(true)
- }
- const onConfirmSave = () => {
- if (!projectRef) return console.error('Project ref is required')
- 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
- ),
- 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,
- })
- }
- return (
- <>
- <Form {...form}>
- <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
- {isError ? (
- <AlertError error={error} subject="Failed to retrieve realtime settings" />
- ) : (
- <Card>
- <CardContent className="space-y-4">
- <FormField
- control={form.control}
- name="suspend"
- render={({ field }) => (
- <>
- <FormItemLayout
- id="suspend"
- layout="flex-row-reverse"
- label="Enable Realtime service"
- description="If disabled, no clients will be able to connect and new connections will be rejected"
- >
- <FormControl>
- <Switch
- id="suspend"
- checked={!field.value}
- onCheckedChange={(checked) => field.onChange(!checked)}
- disabled={!canUpdateConfig}
- />
- </FormControl>
- </FormItemLayout>
- <FormMessage />
- </>
- )}
- />
- {(isRealtimeDisabled || isDisablingRealtime || isEnablingRealtime) && (
- <Admonition
- showIcon={false}
- type={isDisablingRealtime || isEnablingRealtime ? 'warning' : 'default'}
- >
- <div className="flex items-center gap-x-2">
- <div>
- <h5 className="text-foreground mb-1">
- {isDisablingRealtime
- ? 'Realtime service will be disabled'
- : isEnablingRealtime
- ? 'Realtime service will be re-enabled'
- : isRealtimeDisabled
- ? 'Realtime service is disabled'
- : null}
- </h5>
- <p className="text-foreground-light">
- {isDisablingRealtime
- ? 'Clients will no longer be able to connect to your project’s realtime service once saved'
- : isEnablingRealtime
- ? "Clients will be able to connect to your project's realtime service again once saved"
- : isRealtimeDisabled
- ? 'You will need to enable it to continue using Realtime'
- : null}
- </p>
- </div>
- </div>
- </Admonition>
- )}
- </CardContent>
- {!suspend && (
- <>
- <CardContent>
- <FormField
- control={form.control}
- name="allow_public"
- render={({ field }) => (
- <>
- <FormItemLayout
- id="allow_public"
- layout="flex-row-reverse"
- label="Allow public access to channels"
- description="If disabled, only private channels will be allowed"
- >
- <FormControl>
- <Switch
- id="allow_public"
- checked={field.value}
- onCheckedChange={field.onChange}
- disabled={!canUpdateConfig}
- />
- </FormControl>
- </FormItemLayout>
- {isSuccessPolicies &&
- !hasRealtimeMessagesPolicies &&
- !allow_public &&
- !isRealtimeDisabled && (
- <Admonition
- showIcon={false}
- type="warning"
- title="No Realtime RLS policies found"
- description={
- <>
- <p className="prose max-w-full text-sm">
- Private mode is {isSettingToPrivate ? 'being ' : ''}
- enabled, but no RLS policies exists on the{' '}
- <code className="text-code-inline">
- realtime.messages
- </code>{' '}
- table. No messages will be received by users.
- </p>
- <Button asChild type="default" className="mt-2">
- <Link href={`/project/${projectRef}/realtime/policies`}>
- Create policy
- </Link>
- </Button>
- </>
- }
- />
- )}
- </>
- )}
- />
- </CardContent>
- <CardContent>
- <FormField
- control={form.control}
- name="connection_pool"
- render={({ field }) => (
- <>
- <FormItemLayout
- id="connection_pool"
- layout="flex-row-reverse"
- label="Database connection pool size"
- description="Realtime Authorization uses this database pool to check client access"
- >
- <FormControl>
- <InputGroup>
- <FormInputGroupInput
- {...field}
- id="connection_pool"
- type="number"
- disabled={!canUpdateConfig}
- value={field.value || ''}
- />
- <InputGroupAddon align="inline-end">
- <InputGroupText>connections</InputGroupText>
- </InputGroupAddon>
- </InputGroup>
- </FormControl>
- </FormItemLayout>
- {!!maxConn &&
- field.value &&
- field.value > maxConn.maxConnections * 0.5 && (
- <Admonition
- showIcon={false}
- type="warning"
- title={`Pool size is greater than 50% of the max connections (${maxConn.maxConnections}) on your database`}
- description="This may result in instability and unreliability with your database connections."
- />
- )}
- </>
- )}
- />
- </CardContent>
- <CardContent>
- <FormField
- control={form.control}
- name="max_concurrent_users"
- render={({ field }) => (
- <FormItemLayout
- id="max_concurrent_users"
- layout="flex-row-reverse"
- label="Max concurrent clients"
- description="Sets maximum number of concurrent clients that can connect to your Realtime service"
- >
- <FormControl>
- <InputGroup>
- <FormInputGroupInput
- {...field}
- id="max_concurrent_users"
- type="number"
- disabled={!canUpdateConfig}
- value={field.value || ''}
- />
- <InputGroupAddon align="inline-end">
- <InputGroupText>clients</InputGroupText>
- </InputGroupAddon>
- </InputGroup>
- </FormControl>
- </FormItemLayout>
- )}
- />
- </CardContent>
- <CardContent className="space-y-2">
- <FormField
- control={form.control}
- name="max_events_per_second"
- render={({ field }) => (
- <FormItemLayout
- id="max_events_per_second"
- layout="flex-row-reverse"
- label="Max events per second"
- description="Sets maximum number of events per second that can be sent to your Realtime service"
- >
- <FormControl>
- <InputGroup>
- <FormInputGroupInput
- {...field}
- id="max_events_per_second"
- type="number"
- disabled={!isUsageBillingEnabled || !canUpdateConfig}
- value={field.value || ''}
- />
- <InputGroupAddon align="inline-end">
- <InputGroupText>events/s</InputGroupText>
- </InputGroupAddon>
- </InputGroup>
- </FormControl>
- </FormItemLayout>
- )}
- />
- {isSuccessOrganization && !isUsageBillingEnabled && (
- <Admonition showIcon={false} type="default">
- <div className="flex items-center gap-x-2">
- <div>
- <h5 className="text-foreground mb-1">
- Spend cap needs to be disabled to configure this value
- </h5>
- <p className="text-foreground-light">
- {isFreePlan
- ? 'Upgrade to the Pro plan first to disable spend cap'
- : 'You may adjust this setting in the organization billing settings'}
- </p>
- </div>
- <div className="grow flex items-center justify-end">
- {isFreePlan ? (
- <UpgradePlanButton
- source="realtimeSettings"
- addon="spendCap"
- featureProposition="configure the max events per second parameter of realtime settings"
- />
- ) : (
- <ToggleSpendCapButton />
- )}
- </div>
- </div>
- </Admonition>
- )}
- </CardContent>
- <CardContent className="space-y-2">
- <FormField
- control={form.control}
- name="max_presence_events_per_second"
- render={({ field }) => (
- <FormItemLayout
- id="max_presence_events_per_second"
- layout="flex-row-reverse"
- label="Max presence events per second"
- description="Sets maximum number of presence events per second that can be sent to your Realtime service"
- >
- <FormControl>
- <InputGroup>
- <FormInputGroupInput
- {...field}
- id="max_presence_events_per_second"
- type="number"
- disabled={!isUsageBillingEnabled || !canUpdateConfig}
- value={field.value || ''}
- />
- <InputGroupAddon align="inline-end">
- <InputGroupText>events/s</InputGroupText>
- </InputGroupAddon>
- </InputGroup>
- </FormControl>
- </FormItemLayout>
- )}
- />
- {isSuccessOrganization && !isUsageBillingEnabled && (
- <Admonition showIcon={false} type="default">
- <div className="flex items-center gap-x-2">
- <div>
- <h5 className="text-foreground mb-1">
- Spend cap needs to be disabled to configure this value
- </h5>
- <p className="text-foreground-light">
- {isFreePlan
- ? 'Upgrade to the Pro plan first to disable spend cap'
- : 'You may adjust this setting in the organization billing settings'}
- </p>
- </div>
- <div className="grow flex items-center justify-end">
- {isFreePlan ? (
- <UpgradePlanButton
- source="realtimeSettings"
- addon="spendCap"
- featureProposition="configure the max presence events per second parameter of realtime settings"
- />
- ) : (
- <ToggleSpendCapButton />
- )}
- </div>
- </div>
- </Admonition>
- )}
- </CardContent>
- <CardContent className="space-y-2">
- <FormField
- control={form.control}
- name="max_payload_size_in_kb"
- render={({ field }) => (
- <FormItemLayout
- id="max_payload_size_in_kb"
- layout="flex-row-reverse"
- label="Max payload size in KB"
- description="Sets maximum number of payload size in KB that can be sent to your Realtime service"
- >
- <FormControl>
- <InputGroup>
- <FormInputGroupInput
- {...field}
- id="max_payload_size_in_kb"
- type="number"
- disabled={!isUsageBillingEnabled || !canUpdateConfig}
- value={field.value || ''}
- />
- <InputGroupAddon align="inline-end">
- <InputGroupText>KB</InputGroupText>
- </InputGroupAddon>
- </InputGroup>
- </FormControl>
- </FormItemLayout>
- )}
- />
- {isSuccessOrganization && !isUsageBillingEnabled && (
- <Admonition showIcon={false} type="default">
- <div className="flex items-center gap-x-2">
- <div>
- <h5 className="text-foreground mb-1">
- Spend cap needs to be disabled to configure this value
- </h5>
- <p className="text-foreground-light">
- {isFreePlan
- ? 'Upgrade to the Pro plan first to disable spend cap'
- : 'You may adjust this setting in the organization billing settings'}
- </p>
- </div>
- <div className="grow flex items-center justify-end">
- {isFreePlan ? (
- <UpgradePlanButton
- addon="spendCap"
- source="realtimeSettings"
- featureProposition="configure the max payload size parameter of realtime settings"
- />
- ) : (
- <ToggleSpendCapButton />
- )}
- </div>
- </div>
- </Admonition>
- )}
- </CardContent>
- </>
- )}
- <CardFooter className="justify-between">
- <div>
- {isPermissionsLoaded && !canUpdateConfig && (
- <p className="text-sm text-foreground-light">
- You need additional permissions to update realtime settings
- </p>
- )}
- </div>
- <div className="flex items-center gap-x-2">
- {form.formState.isDirty && (
- <Button type="default" onClick={() => form.reset(data as any)}>
- Cancel
- </Button>
- )}
- <Button
- type="primary"
- htmlType="submit"
- form={formId}
- disabled={!canUpdateConfig || isUpdatingConfig || !form.formState.isDirty}
- loading={isUpdatingConfig}
- >
- Save changes
- </Button>
- </div>
- </CardFooter>
- </Card>
- )}
- </form>
- </Form>
- <ConfirmationModal
- visible={isConfirmNextModalOpen}
- title="Confirm saving changes"
- confirmLabel="Save changes"
- loading={isUpdatingConfig}
- onCancel={() => setIsConfirmNextModalOpen(false)}
- onConfirm={() => onConfirmSave()}
- >
- <p className="text-sm text-foreground-light">
- Saving the changes will disconnect all the clients connected to your project. Are you sure
- you want to continue?
- </p>
- </ConfirmationModal>
- </>
- )
- }
|