CreateCronJobSheet.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. // @ts-nocheck
  2. import { zodResolver } from '@hookform/resolvers/zod'
  3. import { PermissionAction } from '@supabase/shared-types/out/constants'
  4. import { useWatch } from '@ui/components/shadcn/ui/form'
  5. import { useParams } from 'common'
  6. import { parseAsString, useQueryState } from 'nuqs'
  7. import { useEffect, useState } from 'react'
  8. import { SubmitHandler, useForm } from 'react-hook-form'
  9. import { toast } from 'sonner'
  10. import {
  11. Button,
  12. Form,
  13. FormControl,
  14. FormField,
  15. Input,
  16. RadioGroupStacked,
  17. RadioGroupStackedItem,
  18. Separator,
  19. Sheet,
  20. SheetContent,
  21. SheetFooter,
  22. SheetHeader,
  23. SheetSection,
  24. SheetTitle,
  25. WarningIcon,
  26. } from 'ui'
  27. import { Admonition } from 'ui-patterns/admonition'
  28. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  29. import { CRONJOB_DEFINITIONS } from '../CronJobs.constants'
  30. import { buildCronQuery, buildHttpRequestCommand, parseCronJobCommand } from '../CronJobs.utils'
  31. import { EdgeFunctionSection } from '../EdgeFunctionSection'
  32. import { HttpBodyFieldSection } from '../HttpBodyFieldSection'
  33. import { HTTPHeaderFieldsSection } from '../HttpHeaderFieldsSection'
  34. import { HttpRequestSection } from '../HttpRequestSection'
  35. import { SqlFunctionSection } from '../SqlFunctionSection'
  36. import { SqlSnippetSection } from '../SqlSnippetSection'
  37. import {
  38. FormSchema,
  39. type CreateCronJobForm,
  40. type CronJobType,
  41. } from './CreateCronJobSheet.constants'
  42. import { CronJobScheduleSection } from './CronJobScheduleSection'
  43. import { EnableExtensionModal } from '@/components/interfaces/Database/Extensions/EnableExtensionModal'
  44. import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
  45. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  46. import { getDatabaseCronJob } from '@/data/database-cron-jobs/database-cron-job-query'
  47. import { useDatabaseCronJobCreateMutation } from '@/data/database-cron-jobs/database-cron-jobs-create-mutation'
  48. import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query'
  49. import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
  50. import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
  51. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  52. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  53. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  54. import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
  55. import { isGreaterThanOrEqual } from '@/lib/semver'
  56. interface CreateCronJobSheetProps {
  57. open: boolean
  58. selectedCronJob?: Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'>
  59. onClose: () => void
  60. }
  61. const FORM_ID = 'create-cron-job-sidepanel'
  62. const buildCommand = (values: CronJobType) => {
  63. let command = ''
  64. if (values.type === 'edge_function') {
  65. command = buildHttpRequestCommand(
  66. values.method,
  67. values.edgeFunctionName,
  68. values.httpHeaders,
  69. values.httpBody,
  70. values.timeoutMs
  71. )
  72. } else if (values.type === 'http_request') {
  73. command = buildHttpRequestCommand(
  74. values.method,
  75. values.endpoint,
  76. values.httpHeaders,
  77. values.httpBody,
  78. values.timeoutMs
  79. )
  80. } else if (values.type === 'sql_function') {
  81. command = `SELECT ${values.schema}.${values.functionName}()`
  82. }
  83. return command
  84. }
  85. export const CreateCronJobSheet = ({ open, selectedCronJob, onClose }: CreateCronJobSheetProps) => {
  86. const { childId } = useParams()
  87. const { data: project } = useSelectedProjectQuery()
  88. const { data: org } = useSelectedOrganizationQuery()
  89. const [searchQuery] = useQueryState('search', parseAsString.withDefault(''))
  90. const [isLoadingGetCronJob, setIsLoadingGetCronJob] = useState(false)
  91. const jobId = Number(childId)
  92. const isEditing = !!selectedCronJob?.jobname
  93. const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
  94. const { data = [] } = useDatabaseExtensionsQuery({
  95. projectRef: project?.ref,
  96. connectionString: project?.connectionString,
  97. })
  98. const pgNetExtension = data.find((ext) => ext.name === 'pg_net')
  99. const pgNetExtensionInstalled = pgNetExtension?.installed_version != undefined
  100. const pgCronExtension = data.find((ext) => ext.name === 'pg_cron')
  101. const supportsSeconds = pgCronExtension?.installed_version
  102. ? isGreaterThanOrEqual(pgCronExtension.installed_version, '1.5')
  103. : false
  104. const { mutate: sendEvent } = useSendEventMutation()
  105. const { mutate: upsertCronJob, isPending: isUpserting } = useDatabaseCronJobCreateMutation()
  106. const isLoading = isLoadingGetCronJob || isUpserting
  107. const { can: canToggleExtensions } = useAsyncCheckPermissions(
  108. PermissionAction.TENANT_SQL_ADMIN_WRITE,
  109. 'extensions'
  110. )
  111. const cronJobValues = parseCronJobCommand(selectedCronJob?.command || '', project?.ref!)
  112. const defaultValues = {
  113. name: selectedCronJob?.jobname || '',
  114. schedule: selectedCronJob?.schedule || '*/5 * * * *',
  115. supportsSeconds,
  116. values: cronJobValues,
  117. }
  118. const form = useForm<CreateCronJobForm>({
  119. resolver: zodResolver(FormSchema as any),
  120. defaultValues,
  121. })
  122. const [
  123. cronType,
  124. endpoint,
  125. edgeFunctionName,
  126. method,
  127. httpHeaders,
  128. httpBody,
  129. timeoutMs,
  130. schema,
  131. functionName,
  132. ] = useWatch({
  133. control: form.control,
  134. name: [
  135. 'values.type',
  136. 'values.endpoint',
  137. 'values.edgeFunctionName',
  138. 'values.method',
  139. 'values.httpHeaders',
  140. 'values.httpBody',
  141. 'values.timeoutMs',
  142. 'values.schema',
  143. 'values.functionName',
  144. ],
  145. })
  146. const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
  147. checkIsDirty: () => form.formState.isDirty,
  148. onClose: () => onClose(),
  149. })
  150. const onSubmit: SubmitHandler<CreateCronJobForm> = async ({ name, schedule, values }) => {
  151. if (!project) return console.error('Project is required')
  152. if (!isEditing) {
  153. try {
  154. setIsLoadingGetCronJob(true)
  155. const checkExistingJob = await getDatabaseCronJob({
  156. projectRef: project.ref,
  157. connectionString: project.connectionString,
  158. name,
  159. })
  160. const nameExists = !!checkExistingJob
  161. if (nameExists) {
  162. return form.setError(
  163. 'name',
  164. {
  165. type: 'manual',
  166. message: 'A cron job with this name already exists',
  167. },
  168. { shouldFocus: true }
  169. )
  170. }
  171. } catch (error: any) {
  172. toast.error(`Failed to validate cron job name: ${error.message}`)
  173. return
  174. } finally {
  175. setIsLoadingGetCronJob(false)
  176. }
  177. }
  178. const query = buildCronQuery(name, schedule, values.snippet)
  179. upsertCronJob(
  180. {
  181. projectRef: project!.ref,
  182. connectionString: project?.connectionString,
  183. query,
  184. searchTerm: searchQuery,
  185. // [Joshen] Only need to invalidate a specific cron job if in the job's previous run tab
  186. identifier: !!jobId ? jobId : undefined,
  187. },
  188. {
  189. onSuccess: () => {
  190. if (isEditing) {
  191. toast.success(`Successfully updated cron job ${name}`)
  192. } else {
  193. toast.success(`Successfully created cron job ${name}`)
  194. }
  195. if (isEditing) {
  196. sendEvent({
  197. action: 'cron_job_updated',
  198. properties: {
  199. type: values.type,
  200. schedule: schedule,
  201. },
  202. groups: {
  203. project: project?.ref ?? 'Unknown',
  204. organization: org?.slug ?? 'Unknown',
  205. },
  206. })
  207. } else {
  208. sendEvent({
  209. action: 'cron_job_created',
  210. properties: {
  211. type: values.type,
  212. schedule: schedule,
  213. },
  214. groups: {
  215. project: project?.ref ?? 'Unknown',
  216. organization: org?.slug ?? 'Unknown',
  217. },
  218. })
  219. }
  220. onClose()
  221. },
  222. }
  223. )
  224. setIsLoadingGetCronJob(false)
  225. }
  226. // update the snippet field when the user changes the any values in the form
  227. useEffect(() => {
  228. const command = buildCommand({
  229. type: cronType,
  230. method,
  231. edgeFunctionName,
  232. timeoutMs,
  233. httpHeaders,
  234. httpBody,
  235. functionName,
  236. schema,
  237. endpoint,
  238. snippet: '',
  239. })
  240. if (command) {
  241. form.setValue('values.snippet', command)
  242. }
  243. // eslint-disable-next-line react-hooks/exhaustive-deps
  244. }, [
  245. cronType,
  246. edgeFunctionName,
  247. endpoint,
  248. method,
  249. // for some reason, the httpHeaders are not memoized and cause the useEffect to trigger even when the value is the same
  250. JSON.stringify(httpHeaders),
  251. httpBody,
  252. timeoutMs,
  253. schema,
  254. functionName,
  255. form,
  256. ])
  257. useEffect(() => {
  258. if (open && !!pgCronExtension) form.reset(defaultValues)
  259. // eslint-disable-next-line react-hooks/exhaustive-deps
  260. }, [open])
  261. return (
  262. <>
  263. <DiscardChangesConfirmationDialog {...modalProps} />
  264. <Sheet open={open} onOpenChange={handleOpenChange}>
  265. <SheetContent size="lg">
  266. <div className="flex flex-col h-full" tabIndex={-1}>
  267. <SheetHeader>
  268. <SheetTitle>
  269. {isEditing ? `Edit ${selectedCronJob.jobname}` : `Create a new cron job`}
  270. </SheetTitle>
  271. </SheetHeader>
  272. <div className="overflow-auto grow">
  273. <Form {...form}>
  274. <form
  275. id={FORM_ID}
  276. className="grow overflow-auto"
  277. onSubmit={form.handleSubmit(onSubmit)}
  278. >
  279. <SheetSection>
  280. <FormField
  281. control={form.control}
  282. name="name"
  283. render={({ field }) => (
  284. <FormItemLayout label="Name" layout="vertical" className="gap-1 relative">
  285. <FormControl>
  286. <Input {...field} disabled={isEditing} />
  287. </FormControl>
  288. <span className="text-foreground-lighter text-xs absolute top-0 right-0">
  289. Cron jobs cannot be renamed once created
  290. </span>
  291. </FormItemLayout>
  292. )}
  293. />
  294. </SheetSection>
  295. <Separator />
  296. <CronJobScheduleSection form={form} supportsSeconds={supportsSeconds} />
  297. <Separator />
  298. <SheetSection>
  299. <FormField
  300. control={form.control}
  301. name="values.type"
  302. render={({ field }) => (
  303. <FormItemLayout label="Type" layout="vertical" className="gap-1">
  304. <FormControl>
  305. <RadioGroupStacked
  306. id="function_type"
  307. name="function_type"
  308. value={field.value}
  309. disabled={field.disabled}
  310. onValueChange={(value) => field.onChange(value)}
  311. >
  312. {CRONJOB_DEFINITIONS.map((definition) => (
  313. <RadioGroupStackedItem
  314. key={definition.value}
  315. id={definition.value}
  316. value={definition.value}
  317. disabled={
  318. !pgNetExtensionInstalled &&
  319. (definition.value === 'http_request' ||
  320. definition.value === 'edge_function')
  321. }
  322. label=""
  323. showIndicator={false}
  324. >
  325. <div className="flex items-center gap-x-5">
  326. <div className="text-foreground">{definition.icon}</div>
  327. <div className="flex flex-col">
  328. <div className="flex gap-x-2">
  329. <p className="text-foreground">{definition.label}</p>
  330. </div>
  331. <p className="text-foreground-light">
  332. {definition.description}
  333. </p>
  334. </div>
  335. </div>
  336. {!pgNetExtensionInstalled &&
  337. (definition.value === 'http_request' ||
  338. definition.value === 'edge_function') ? (
  339. <div className="w-full flex gap-x-2 pl-11 py-2 items-center">
  340. <WarningIcon />
  341. <span className="text-xs">
  342. <code>pg_net</code> needs to be installed to use this type
  343. </span>
  344. </div>
  345. ) : null}
  346. </RadioGroupStackedItem>
  347. ))}
  348. </RadioGroupStacked>
  349. </FormControl>
  350. </FormItemLayout>
  351. )}
  352. />
  353. {!pgNetExtensionInstalled && (
  354. <Admonition
  355. type="note"
  356. // @ts-ignore
  357. title={
  358. <span>
  359. Enable <code className="text-code-inline w-min">pg_net</code> for HTTP
  360. requests or Edge Functions
  361. </span>
  362. }
  363. description={
  364. <div className="flex flex-col gap-y-2">
  365. <span>
  366. This will allow you to send HTTP requests or trigger an edge function
  367. within your cron jobs
  368. </span>
  369. <ButtonTooltip
  370. type="default"
  371. className="w-min"
  372. disabled={!canToggleExtensions}
  373. onClick={() => setShowEnableExtensionModal(true)}
  374. tooltip={{
  375. content: {
  376. side: 'bottom',
  377. text: !canToggleExtensions
  378. ? 'You need additional permissions to enable database extensions'
  379. : undefined,
  380. },
  381. }}
  382. >
  383. Install pg_net extension
  384. </ButtonTooltip>
  385. </div>
  386. }
  387. />
  388. )}
  389. </SheetSection>
  390. <Separator />
  391. {cronType === 'http_request' && (
  392. <>
  393. <HttpRequestSection form={form} />
  394. <Separator />
  395. <HTTPHeaderFieldsSection variant={cronType} />
  396. <Separator />
  397. <HttpBodyFieldSection form={form} />
  398. </>
  399. )}
  400. {cronType === 'edge_function' && (
  401. <>
  402. <EdgeFunctionSection form={form} />
  403. <Separator />
  404. <HTTPHeaderFieldsSection variant={cronType} />
  405. <Separator />
  406. <HttpBodyFieldSection form={form} />
  407. </>
  408. )}
  409. {cronType === 'sql_function' && <SqlFunctionSection form={form} />}
  410. {cronType === 'sql_snippet' && <SqlSnippetSection form={form} />}
  411. </form>
  412. </Form>
  413. </div>
  414. <SheetFooter>
  415. <Button
  416. size="tiny"
  417. type="default"
  418. htmlType="button"
  419. onClick={confirmOnClose}
  420. disabled={isLoading}
  421. >
  422. Cancel
  423. </Button>
  424. <Button
  425. size="tiny"
  426. type="primary"
  427. form={FORM_ID}
  428. htmlType="submit"
  429. disabled={isLoading}
  430. loading={isLoading}
  431. >
  432. {isEditing ? `Save cron job` : 'Create cron job'}
  433. </Button>
  434. </SheetFooter>
  435. </div>
  436. </SheetContent>
  437. </Sheet>
  438. {pgNetExtension && (
  439. <EnableExtensionModal
  440. visible={showEnableExtensionModal}
  441. extension={pgNetExtension}
  442. onCancel={() => setShowEnableExtensionModal(false)}
  443. />
  444. )}
  445. </>
  446. )
  447. }