RealtimeSettings.tsx590 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { useParams } from 'common'
4import Link from 'next/link'
5import { useState } from 'react'
6import { SubmitHandler, useForm } from 'react-hook-form'
7import { toast } from 'sonner'
8import {
9 Button,
10 Card,
11 CardContent,
12 CardFooter,
13 Form,
14 FormControl,
15 FormField,
16 FormInputGroupInput,
17 FormMessage,
18 InputGroup,
19 InputGroupAddon,
20 InputGroupText,
21 Switch,
22} from 'ui'
23import { Admonition } from 'ui-patterns'
24import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
25import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
26import * as z from 'zod'
27
28import AlertError from '@/components/ui/AlertError'
29import { ToggleSpendCapButton } from '@/components/ui/ToggleSpendCapButton'
30import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
31import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query'
32import { useMaxConnectionsQuery } from '@/data/database/max-connections-query'
33import { useRealtimeConfigurationUpdateMutation } from '@/data/realtime/realtime-config-mutation'
34import {
35 REALTIME_DEFAULT_CONFIG,
36 useRealtimeConfigurationQuery,
37} from '@/data/realtime/realtime-config-query'
38import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
39import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
40import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
41
42const formId = 'realtime-configuration-form'
43
44export const RealtimeSettings = () => {
45 const { ref: projectRef } = useParams()
46 const { data: project } = useSelectedProjectQuery()
47 const { data: organization, isSuccess: isSuccessOrganization } = useSelectedOrganizationQuery()
48 const { can: canUpdateConfig, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
49 PermissionAction.REALTIME_ADMIN_READ,
50 '*'
51 )
52
53 const [isConfirmNextModalOpen, setIsConfirmNextModalOpen] = useState(false)
54
55 const { data: maxConn } = useMaxConnectionsQuery({
56 projectRef: project?.ref,
57 connectionString: project?.connectionString,
58 })
59 const { data, error, isError } = useRealtimeConfigurationQuery({
60 projectRef,
61 })
62
63 const { data: policies, isSuccess: isSuccessPolicies } = useDatabasePoliciesQuery({
64 projectRef,
65 connectionString: project?.connectionString,
66 schema: 'realtime',
67 })
68
69 const isFreePlan = organization?.plan.id === 'free'
70 const isUsageBillingEnabled = organization?.usage_billing_enabled
71 const isRealtimeDisabled = data?.suspend ?? REALTIME_DEFAULT_CONFIG.suspend
72 // Check if RLS policies exist for realtime.messages table
73 const realtimeMessagesPolicies = policies?.filter(
74 (policy) => policy.schema === 'realtime' && policy.table === 'messages'
75 )
76 const hasRealtimeMessagesPolicies =
77 realtimeMessagesPolicies && realtimeMessagesPolicies.length > 0
78
79 const { mutate: updateRealtimeConfig, isPending: isUpdatingConfig } =
80 useRealtimeConfigurationUpdateMutation({
81 onSuccess: () => {
82 form.reset(form.getValues())
83 toast.success('Successfully updated realtime settings')
84 setIsConfirmNextModalOpen(false)
85 },
86 })
87
88 const FormSchema = z.discriminatedUnion('suspend', [
89 z.object({
90 suspend: z.literal(true),
91 connection_pool: z.coerce
92 .number()
93 .min(1)
94 .max(maxConn?.maxConnections ?? 100)
95 .optional(),
96 max_concurrent_users: z.coerce.number().min(1).max(50000).optional(),
97 max_events_per_second: z.coerce.number().min(1).max(10000).optional(),
98 max_presence_events_per_second: z.coerce.number().min(1).max(10000).optional(),
99 max_payload_size_in_kb: z.coerce.number().min(1).max(3000).optional(),
100 // [Joshen] These fields are temporarily hidden from the UI
101 // max_bytes_per_second: z.coerce.number().min(1).max(10000000).optional(),
102 // max_channels_per_client: z.coerce.number().min(1).max(10000).optional(),
103 // max_joins_per_second: z.coerce.number().min(1).max(5000).optional(),
104
105 allow_public: z.boolean().optional(),
106 }),
107 z.object({
108 suspend: z.literal(false),
109 connection_pool: z.coerce
110 .number()
111 .min(1)
112 .max(maxConn?.maxConnections ?? 100),
113 max_concurrent_users: z.coerce.number().min(1).max(50000),
114 max_events_per_second: z.coerce.number().min(1).max(10000),
115 max_presence_events_per_second: z.coerce.number().min(1).max(10000),
116 max_payload_size_in_kb: z.coerce.number().min(1).max(3000),
117 // [Joshen] These fields are temporarily hidden from the UI
118 // max_bytes_per_second: z.coerce.number().min(1).max(10000000),
119 // max_channels_per_client: z.coerce.number().min(1).max(10000),
120 // max_joins_per_second: z.coerce.number().min(1).max(5000),
121
122 allow_public: z.boolean(),
123 }),
124 ])
125
126 const form = useForm<z.infer<typeof FormSchema>>({
127 resolver: zodResolver(FormSchema as any),
128 defaultValues: {
129 ...REALTIME_DEFAULT_CONFIG,
130 allow_public: !REALTIME_DEFAULT_CONFIG.private_only,
131 },
132 values: {
133 ...(data ?? REALTIME_DEFAULT_CONFIG),
134 allow_public: !(data?.private_only ?? REALTIME_DEFAULT_CONFIG.private_only),
135 } as any,
136 })
137
138 const { allow_public, suspend } = form.watch()
139 const isSettingToPrivate = !data?.private_only && !allow_public
140 const isDisablingRealtime = !isRealtimeDisabled && suspend
141 const isEnablingRealtime = isRealtimeDisabled && !suspend
142
143 const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = (_data) => {
144 if (!projectRef) return console.error('Project ref is required')
145 setIsConfirmNextModalOpen(true)
146 }
147
148 const onConfirmSave = () => {
149 if (!projectRef) return console.error('Project ref is required')
150 const values = form.getValues()
151
152 // [Joshen] Casting to `Number` here as the values are being set as string when edited in the form
153 // and returned in form.getValues() - I might be missing some easy util function from RHF though
154 updateRealtimeConfig({
155 ref: projectRef,
156 private_only: !values.allow_public,
157 connection_pool: Number(
158 values.connection_pool ?? data?.connection_pool ?? REALTIME_DEFAULT_CONFIG.connection_pool
159 ),
160 max_concurrent_users: Number(
161 values.max_concurrent_users ??
162 data?.max_concurrent_users ??
163 REALTIME_DEFAULT_CONFIG.max_concurrent_users
164 ),
165 max_events_per_second: Number(
166 values.max_events_per_second ??
167 data?.max_events_per_second ??
168 REALTIME_DEFAULT_CONFIG.max_events_per_second
169 ),
170 max_presence_events_per_second: Number(
171 values.max_presence_events_per_second ??
172 data?.max_presence_events_per_second ??
173 REALTIME_DEFAULT_CONFIG.max_presence_events_per_second
174 ),
175 max_payload_size_in_kb: Number(
176 values.max_payload_size_in_kb ??
177 data?.max_payload_size_in_kb ??
178 REALTIME_DEFAULT_CONFIG.max_payload_size_in_kb
179 ),
180 suspend: values.suspend,
181 })
182 }
183
184 return (
185 <>
186 <Form {...form}>
187 <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
188 {isError ? (
189 <AlertError error={error} subject="Failed to retrieve realtime settings" />
190 ) : (
191 <Card>
192 <CardContent className="space-y-4">
193 <FormField
194 control={form.control}
195 name="suspend"
196 render={({ field }) => (
197 <>
198 <FormItemLayout
199 id="suspend"
200 layout="flex-row-reverse"
201 label="Enable Realtime service"
202 description="If disabled, no clients will be able to connect and new connections will be rejected"
203 >
204 <FormControl>
205 <Switch
206 id="suspend"
207 checked={!field.value}
208 onCheckedChange={(checked) => field.onChange(!checked)}
209 disabled={!canUpdateConfig}
210 />
211 </FormControl>
212 </FormItemLayout>
213 <FormMessage />
214 </>
215 )}
216 />
217 {(isRealtimeDisabled || isDisablingRealtime || isEnablingRealtime) && (
218 <Admonition
219 showIcon={false}
220 type={isDisablingRealtime || isEnablingRealtime ? 'warning' : 'default'}
221 >
222 <div className="flex items-center gap-x-2">
223 <div>
224 <h5 className="text-foreground mb-1">
225 {isDisablingRealtime
226 ? 'Realtime service will be disabled'
227 : isEnablingRealtime
228 ? 'Realtime service will be re-enabled'
229 : isRealtimeDisabled
230 ? 'Realtime service is disabled'
231 : null}
232 </h5>
233 <p className="text-foreground-light">
234 {isDisablingRealtime
235 ? 'Clients will no longer be able to connect to your project’s realtime service once saved'
236 : isEnablingRealtime
237 ? "Clients will be able to connect to your project's realtime service again once saved"
238 : isRealtimeDisabled
239 ? 'You will need to enable it to continue using Realtime'
240 : null}
241 </p>
242 </div>
243 </div>
244 </Admonition>
245 )}
246 </CardContent>
247
248 {!suspend && (
249 <>
250 <CardContent>
251 <FormField
252 control={form.control}
253 name="allow_public"
254 render={({ field }) => (
255 <>
256 <FormItemLayout
257 id="allow_public"
258 layout="flex-row-reverse"
259 label="Allow public access to channels"
260 description="If disabled, only private channels will be allowed"
261 >
262 <FormControl>
263 <Switch
264 id="allow_public"
265 checked={field.value}
266 onCheckedChange={field.onChange}
267 disabled={!canUpdateConfig}
268 />
269 </FormControl>
270 </FormItemLayout>
271
272 {isSuccessPolicies &&
273 !hasRealtimeMessagesPolicies &&
274 !allow_public &&
275 !isRealtimeDisabled && (
276 <Admonition
277 showIcon={false}
278 type="warning"
279 title="No Realtime RLS policies found"
280 description={
281 <>
282 <p className="prose max-w-full text-sm">
283 Private mode is {isSettingToPrivate ? 'being ' : ''}
284 enabled, but no RLS policies exists on the{' '}
285 <code className="text-code-inline">
286 realtime.messages
287 </code>{' '}
288 table. No messages will be received by users.
289 </p>
290
291 <Button asChild type="default" className="mt-2">
292 <Link href={`/project/${projectRef}/realtime/policies`}>
293 Create policy
294 </Link>
295 </Button>
296 </>
297 }
298 />
299 )}
300 </>
301 )}
302 />
303 </CardContent>
304 <CardContent>
305 <FormField
306 control={form.control}
307 name="connection_pool"
308 render={({ field }) => (
309 <>
310 <FormItemLayout
311 id="connection_pool"
312 layout="flex-row-reverse"
313 label="Database connection pool size"
314 description="Realtime Authorization uses this database pool to check client access"
315 >
316 <FormControl>
317 <InputGroup>
318 <FormInputGroupInput
319 {...field}
320 id="connection_pool"
321 type="number"
322 disabled={!canUpdateConfig}
323 value={field.value || ''}
324 />
325 <InputGroupAddon align="inline-end">
326 <InputGroupText>connections</InputGroupText>
327 </InputGroupAddon>
328 </InputGroup>
329 </FormControl>
330 </FormItemLayout>
331 {!!maxConn &&
332 field.value &&
333 field.value > maxConn.maxConnections * 0.5 && (
334 <Admonition
335 showIcon={false}
336 type="warning"
337 title={`Pool size is greater than 50% of the max connections (${maxConn.maxConnections}) on your database`}
338 description="This may result in instability and unreliability with your database connections."
339 />
340 )}
341 </>
342 )}
343 />
344 </CardContent>
345 <CardContent>
346 <FormField
347 control={form.control}
348 name="max_concurrent_users"
349 render={({ field }) => (
350 <FormItemLayout
351 id="max_concurrent_users"
352 layout="flex-row-reverse"
353 label="Max concurrent clients"
354 description="Sets maximum number of concurrent clients that can connect to your Realtime service"
355 >
356 <FormControl>
357 <InputGroup>
358 <FormInputGroupInput
359 {...field}
360 id="max_concurrent_users"
361 type="number"
362 disabled={!canUpdateConfig}
363 value={field.value || ''}
364 />
365 <InputGroupAddon align="inline-end">
366 <InputGroupText>clients</InputGroupText>
367 </InputGroupAddon>
368 </InputGroup>
369 </FormControl>
370 </FormItemLayout>
371 )}
372 />
373 </CardContent>
374 <CardContent className="space-y-2">
375 <FormField
376 control={form.control}
377 name="max_events_per_second"
378 render={({ field }) => (
379 <FormItemLayout
380 id="max_events_per_second"
381 layout="flex-row-reverse"
382 label="Max events per second"
383 description="Sets maximum number of events per second that can be sent to your Realtime service"
384 >
385 <FormControl>
386 <InputGroup>
387 <FormInputGroupInput
388 {...field}
389 id="max_events_per_second"
390 type="number"
391 disabled={!isUsageBillingEnabled || !canUpdateConfig}
392 value={field.value || ''}
393 />
394 <InputGroupAddon align="inline-end">
395 <InputGroupText>events/s</InputGroupText>
396 </InputGroupAddon>
397 </InputGroup>
398 </FormControl>
399 </FormItemLayout>
400 )}
401 />
402 {isSuccessOrganization && !isUsageBillingEnabled && (
403 <Admonition showIcon={false} type="default">
404 <div className="flex items-center gap-x-2">
405 <div>
406 <h5 className="text-foreground mb-1">
407 Spend cap needs to be disabled to configure this value
408 </h5>
409 <p className="text-foreground-light">
410 {isFreePlan
411 ? 'Upgrade to the Pro plan first to disable spend cap'
412 : 'You may adjust this setting in the organization billing settings'}
413 </p>
414 </div>
415 <div className="grow flex items-center justify-end">
416 {isFreePlan ? (
417 <UpgradePlanButton
418 source="realtimeSettings"
419 addon="spendCap"
420 featureProposition="configure the max events per second parameter of realtime settings"
421 />
422 ) : (
423 <ToggleSpendCapButton />
424 )}
425 </div>
426 </div>
427 </Admonition>
428 )}
429 </CardContent>
430 <CardContent className="space-y-2">
431 <FormField
432 control={form.control}
433 name="max_presence_events_per_second"
434 render={({ field }) => (
435 <FormItemLayout
436 id="max_presence_events_per_second"
437 layout="flex-row-reverse"
438 label="Max presence events per second"
439 description="Sets maximum number of presence events per second that can be sent to your Realtime service"
440 >
441 <FormControl>
442 <InputGroup>
443 <FormInputGroupInput
444 {...field}
445 id="max_presence_events_per_second"
446 type="number"
447 disabled={!isUsageBillingEnabled || !canUpdateConfig}
448 value={field.value || ''}
449 />
450 <InputGroupAddon align="inline-end">
451 <InputGroupText>events/s</InputGroupText>
452 </InputGroupAddon>
453 </InputGroup>
454 </FormControl>
455 </FormItemLayout>
456 )}
457 />
458 {isSuccessOrganization && !isUsageBillingEnabled && (
459 <Admonition showIcon={false} type="default">
460 <div className="flex items-center gap-x-2">
461 <div>
462 <h5 className="text-foreground mb-1">
463 Spend cap needs to be disabled to configure this value
464 </h5>
465 <p className="text-foreground-light">
466 {isFreePlan
467 ? 'Upgrade to the Pro plan first to disable spend cap'
468 : 'You may adjust this setting in the organization billing settings'}
469 </p>
470 </div>
471 <div className="grow flex items-center justify-end">
472 {isFreePlan ? (
473 <UpgradePlanButton
474 source="realtimeSettings"
475 addon="spendCap"
476 featureProposition="configure the max presence events per second parameter of realtime settings"
477 />
478 ) : (
479 <ToggleSpendCapButton />
480 )}
481 </div>
482 </div>
483 </Admonition>
484 )}
485 </CardContent>
486 <CardContent className="space-y-2">
487 <FormField
488 control={form.control}
489 name="max_payload_size_in_kb"
490 render={({ field }) => (
491 <FormItemLayout
492 id="max_payload_size_in_kb"
493 layout="flex-row-reverse"
494 label="Max payload size in KB"
495 description="Sets maximum number of payload size in KB that can be sent to your Realtime service"
496 >
497 <FormControl>
498 <InputGroup>
499 <FormInputGroupInput
500 {...field}
501 id="max_payload_size_in_kb"
502 type="number"
503 disabled={!isUsageBillingEnabled || !canUpdateConfig}
504 value={field.value || ''}
505 />
506 <InputGroupAddon align="inline-end">
507 <InputGroupText>KB</InputGroupText>
508 </InputGroupAddon>
509 </InputGroup>
510 </FormControl>
511 </FormItemLayout>
512 )}
513 />
514 {isSuccessOrganization && !isUsageBillingEnabled && (
515 <Admonition showIcon={false} type="default">
516 <div className="flex items-center gap-x-2">
517 <div>
518 <h5 className="text-foreground mb-1">
519 Spend cap needs to be disabled to configure this value
520 </h5>
521 <p className="text-foreground-light">
522 {isFreePlan
523 ? 'Upgrade to the Pro plan first to disable spend cap'
524 : 'You may adjust this setting in the organization billing settings'}
525 </p>
526 </div>
527 <div className="grow flex items-center justify-end">
528 {isFreePlan ? (
529 <UpgradePlanButton
530 addon="spendCap"
531 source="realtimeSettings"
532 featureProposition="configure the max payload size parameter of realtime settings"
533 />
534 ) : (
535 <ToggleSpendCapButton />
536 )}
537 </div>
538 </div>
539 </Admonition>
540 )}
541 </CardContent>
542 </>
543 )}
544
545 <CardFooter className="justify-between">
546 <div>
547 {isPermissionsLoaded && !canUpdateConfig && (
548 <p className="text-sm text-foreground-light">
549 You need additional permissions to update realtime settings
550 </p>
551 )}
552 </div>
553 <div className="flex items-center gap-x-2">
554 {form.formState.isDirty && (
555 <Button type="default" onClick={() => form.reset(data as any)}>
556 Cancel
557 </Button>
558 )}
559 <Button
560 type="primary"
561 htmlType="submit"
562 form={formId}
563 disabled={!canUpdateConfig || isUpdatingConfig || !form.formState.isDirty}
564 loading={isUpdatingConfig}
565 >
566 Save changes
567 </Button>
568 </div>
569 </CardFooter>
570 </Card>
571 )}
572 </form>
573 </Form>
574
575 <ConfirmationModal
576 visible={isConfirmNextModalOpen}
577 title="Confirm saving changes"
578 confirmLabel="Save changes"
579 loading={isUpdatingConfig}
580 onCancel={() => setIsConfirmNextModalOpen(false)}
581 onConfirm={() => onConfirmSave()}
582 >
583 <p className="text-sm text-foreground-light">
584 Saving the changes will disconnect all the clients connected to your project. Are you sure
585 you want to continue?
586 </p>
587 </ConfirmationModal>
588 </>
589 )
590}