RealtimeSettings.tsx 26 KB

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