Skip to content

Commit b97a263

Browse files
saltcodivasilov
andauthored
Chore/cron UI seconds 2 (supabase#30673)
* Check if pg_cron supports seconds * Add cron parser * Lock file * Update prompt to handle seconds * Various fixes for the createCronJobSheet. * Remove extra code. --------- Co-authored-by: Ivan Vasilov <[email protected]>
1 parent aea64c9 commit b97a263

File tree

7 files changed

+150
-69
lines changed

7 files changed

+150
-69
lines changed

apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {
1818
Form_Shadcn_,
1919
FormControl_Shadcn_,
2020
FormField_Shadcn_,
21-
FormLabel_Shadcn_,
2221
Input_Shadcn_,
2322
RadioGroupStacked,
2423
RadioGroupStackedItem,
@@ -33,13 +32,14 @@ import { Admonition } from 'ui-patterns'
3332
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
3433
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
3534

35+
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
3636
import { CRONJOB_DEFINITIONS } from './CronJobs.constants'
3737
import {
3838
buildCronQuery,
3939
buildHttpRequestCommand,
4040
cronPattern,
41-
secondsPattern,
4241
parseCronJobCommand,
42+
secondsPattern,
4343
} from './CronJobs.utils'
4444
import { CronJobScheduleSection } from './CronJobScheduleSection'
4545
import { EdgeFunctionSection } from './EdgeFunctionSection'
@@ -48,10 +48,10 @@ import { HTTPParameterFieldsSection } from './HttpParameterFieldsSection'
4848
import { HttpRequestSection } from './HttpRequestSection'
4949
import { SqlFunctionSection } from './SqlFunctionSection'
5050
import { SqlSnippetSection } from './SqlSnippetSection'
51-
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
5251

5352
export interface CreateCronJobSheetProps {
5453
selectedCronJob?: Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'>
54+
supportsSeconds: boolean
5555
isClosing: boolean
5656
setIsClosing: (v: boolean) => void
5757
onClose: () => void
@@ -90,32 +90,45 @@ const sqlSnippetSchema = z.object({
9090
snippet: z.string().trim().min(1),
9191
})
9292

93-
const FormSchema = z.object({
94-
name: z.string().trim().min(1, 'Please provide a name for your cron job'),
95-
schedule: z
96-
.string()
97-
.trim()
98-
.min(1)
99-
.refine((value) => {
100-
if (cronPattern.test(value)) {
101-
try {
102-
CronToString(value)
93+
const FormSchema = z
94+
.object({
95+
name: z.string().trim().min(1, 'Please provide a name for your cron job'),
96+
supportsSeconds: z.boolean(),
97+
schedule: z
98+
.string()
99+
.trim()
100+
.min(1)
101+
.refine((value) => {
102+
if (cronPattern.test(value)) {
103+
try {
104+
CronToString(value)
105+
return true
106+
} catch {
107+
return false
108+
}
109+
} else if (secondsPattern.test(value)) {
103110
return true
104-
} catch {
105-
return false
106111
}
107-
} else if (secondsPattern.test(value)) {
108-
return true
112+
return false
113+
}, 'Invalid Cron format'),
114+
values: z.discriminatedUnion('type', [
115+
edgeFunctionSchema,
116+
httpRequestSchema,
117+
sqlFunctionSchema,
118+
sqlSnippetSchema,
119+
]),
120+
})
121+
.superRefine((data, ctx) => {
122+
if (!cronPattern.test(data.schedule)) {
123+
if (!(data.supportsSeconds && secondsPattern.test(data.schedule))) {
124+
ctx.addIssue({
125+
code: z.ZodIssueCode.custom,
126+
message: 'Seconds are supported only in pg_cron v1.5.0+. Please use a valid Cron format.',
127+
path: ['schedule'],
128+
})
109129
}
110-
return false
111-
}, 'The schedule needs to be in a valid Cron format or specify seconds like "x seconds".'),
112-
values: z.discriminatedUnion('type', [
113-
edgeFunctionSchema,
114-
httpRequestSchema,
115-
sqlFunctionSchema,
116-
sqlSnippetSchema,
117-
]),
118-
})
130+
}
131+
})
119132

120133
export type CreateCronJobForm = z.infer<typeof FormSchema>
121134
export type CronJobType = CreateCronJobForm['values']
@@ -124,11 +137,14 @@ const FORM_ID = 'create-cron-job-sidepanel'
124137

125138
export const CreateCronJobSheet = ({
126139
selectedCronJob,
140+
supportsSeconds,
127141
isClosing,
128142
setIsClosing,
129143
onClose,
130144
}: CreateCronJobSheetProps) => {
145+
const { project } = useProjectContext()
131146
const isEditing = !!selectedCronJob?.jobname
147+
132148
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
133149
const { mutate: upsertCronJob, isLoading } = useDatabaseCronJobCreateMutation()
134150

@@ -144,11 +160,11 @@ export const CreateCronJobSheet = ({
144160
defaultValues: {
145161
name: selectedCronJob?.jobname || '',
146162
schedule: selectedCronJob?.schedule || '*/5 * * * *',
163+
supportsSeconds,
147164
values: cronJobValues,
148165
},
149166
})
150167

151-
const { project } = useProjectContext()
152168
const isEdited = form.formState.isDirty
153169

154170
// if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet
@@ -244,16 +260,15 @@ export const CreateCronJobSheet = ({
244260
<FormControl_Shadcn_>
245261
<Input_Shadcn_ {...field} disabled={isEditing} />
246262
</FormControl_Shadcn_>
247-
248-
<FormLabel_Shadcn_ className="text-foreground-lighter text-xs absolute top-0 right-0 ">
263+
<span className="text-foreground-lighter text-xs absolute top-0 right-0">
249264
Cron jobs cannot be renamed once created
250-
</FormLabel_Shadcn_>
265+
</span>
251266
</FormItemLayout>
252267
)}
253268
/>
254269
</SheetSection>
255270
<Separator />
256-
<CronJobScheduleSection form={form} />
271+
<CronJobScheduleSection form={form} supportsSeconds={supportsSeconds} />
257272
<Separator />
258273
<SheetSection>
259274
<FormField_Shadcn_

apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,39 +19,41 @@ import {
1919
FormField_Shadcn_,
2020
FormItem_Shadcn_,
2121
FormLabel_Shadcn_,
22+
FormMessage_Shadcn_,
2223
Input_Shadcn_,
2324
SheetSection,
2425
Switch,
2526
} from 'ui'
2627
import { Input } from 'ui-patterns/DataInputs/Input'
2728
import { CreateCronJobForm } from './CreateCronJobSheet'
29+
import { getScheduleMessage, secondsPattern } from './CronJobs.utils'
2830
import CronSyntaxChart from './CronSyntaxChart'
29-
import { secondsPattern } from './CronJobs.utils'
3031

3132
interface CronJobScheduleSectionProps {
3233
form: UseFormReturn<CreateCronJobForm>
34+
supportsSeconds: boolean
3335
}
3436

35-
const PRESETS = [
36-
{ name: 'Every minute', expression: '* * * * *' },
37-
{ name: 'Every 5 minutes', expression: '*/5 * * * *' },
38-
{ name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' },
39-
{ name: 'Every night at midnight', expression: '0 0 * * *' },
40-
{ name: 'Every Monday at 2 AM', expression: '0 2 * * 1' },
41-
{ name: 'Every 30 seconds', expression: '30 seconds' },
42-
] as const
43-
44-
export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => {
37+
export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobScheduleSectionProps) => {
4538
const { project } = useProjectContext()
4639
const initialValue = form.getValues('schedule')
47-
const { schedule } = form.watch()
40+
const schedule = form.watch('schedule')
4841

4942
const [presetValue, setPresetValue] = useState<string>(initialValue)
5043
const [inputValue, setInputValue] = useState(initialValue)
5144
const [debouncedValue] = useDebounce(inputValue, 750)
5245
const [useNaturalLanguage, setUseNaturalLanguage] = useState(false)
5346
const [scheduleString, setScheduleString] = useState('')
5447

48+
const PRESETS = [
49+
...(supportsSeconds ? [{ name: 'Every 30 seconds', expression: '30 seconds' }] : []),
50+
{ name: 'Every minute', expression: '* * * * *' },
51+
{ name: 'Every 5 minutes', expression: '*/5 * * * *' },
52+
{ name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' },
53+
{ name: 'Every night at midnight', expression: '0 0 * * *' },
54+
{ name: 'Every Monday at 2 AM', expression: '0 2 * * 1' },
55+
] as const
56+
5557
const { complete: generateCronSyntax, isLoading: isGeneratingCron } = useCompletion({
5658
api: `${BASE_PATH}/api/ai/sql/cron`,
5759
onResponse: async (response) => {
@@ -102,10 +104,18 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
102104
}
103105

104106
try {
107+
// Don't allow seconds-based schedules if seconds aren't supported
108+
if (!supportsSeconds && secondsPattern.test(schedule)) {
109+
setScheduleString('Invalid cron expression')
110+
return
111+
}
112+
105113
setScheduleString(CronToString(schedule))
106114
} catch (error) {
115+
setScheduleString('Invalid cron expression')
107116
console.error('Error converting cron expression to string:', error)
108117
}
118+
// eslint-disable-next-line react-hooks/exhaustive-deps
109119
}, [schedule])
110120

111121
return (
@@ -116,10 +126,15 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
116126
render={({ field }) => {
117127
return (
118128
<FormItem_Shadcn_ className="flex flex-col gap-1">
119-
<FormLabel_Shadcn_>Schedule</FormLabel_Shadcn_>
120-
<FormLabel_Shadcn_ className="text-foreground-lighter">
121-
{useNaturalLanguage ? 'Describe your schedule in words' : 'Enter a cron expression'}
122-
</FormLabel_Shadcn_>
129+
<div className="flex flex-row justify-between">
130+
<FormLabel_Shadcn_>Schedule</FormLabel_Shadcn_>
131+
<span className="text-foreground-lighter text-xs">
132+
{useNaturalLanguage
133+
? 'Describe your schedule in words'
134+
: 'Enter a cron expression'}
135+
</span>
136+
</div>
137+
123138
<FormControl_Shadcn_>
124139
<div className="flex flex-col gap-y-2">
125140
{useNaturalLanguage ? (
@@ -146,6 +161,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
146161
}}
147162
/>
148163
)}
164+
<FormMessage_Shadcn_ />
149165

150166
<div className="flex items-center gap-2 mt-2">
151167
<Switch
@@ -170,6 +186,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
170186
onClick={() => {
171187
setUseNaturalLanguage(false)
172188
form.setValue('schedule', preset.expression)
189+
form.trigger('schedule')
173190
setPresetValue(preset.expression)
174191
}}
175192
>
@@ -218,20 +235,8 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
218235
<span className="text-sm text-foreground-light flex items-center gap-2">
219236
{isGeneratingCron ? (
220237
<LoadingDots />
221-
) : scheduleString === '' ? ( // set a min length before showing invalid message
222-
'Enter a valid cron expression above'
223-
) : scheduleString.includes('Invalid cron expression') ? (
224-
'Invalid cron expression'
225238
) : (
226-
<>
227-
The cron will be run{' '}
228-
{secondsPattern.test(schedule)
229-
? 'every ' + schedule
230-
: scheduleString
231-
.split(' ')
232-
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
233-
.join(' ') + '.'}
234-
</>
239+
getScheduleMessage(scheduleString, schedule)
235240
)}
236241
</span>
237242
)}

apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const buildHttpRequestCommand = (
2929
$$`
3030
}
3131

32-
export const DEFAULT_CRONJOB_COMMAND = {
32+
const DEFAULT_CRONJOB_COMMAND = {
3333
type: 'sql_snippet',
3434
snippet: '',
3535
} as const
@@ -136,11 +136,33 @@ export function formatDate(dateString: string): string {
136136
return date.toLocaleString(undefined, options)
137137
}
138138

139-
// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
140-
export const secondsPattern = /^\d+\s+seconds$/
141139
export const cronPattern =
142140
/^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/
143141

142+
// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
143+
export const secondsPattern = /^\d+\s+seconds$/
144+
144145
export function isSecondsFormat(schedule: string): boolean {
145146
return secondsPattern.test(schedule.trim())
146147
}
148+
149+
export function getScheduleMessage(scheduleString: string, schedule: string) {
150+
if (!scheduleString) {
151+
return 'Enter a valid cron expression above'
152+
}
153+
154+
if (secondsPattern.test(schedule)) {
155+
return `The cron will be run every ${schedule}`
156+
}
157+
158+
if (scheduleString.includes('Invalid cron expression')) {
159+
return scheduleString
160+
}
161+
162+
const readableSchedule = scheduleString
163+
.split(' ')
164+
.map((s, i) => (i === 0 ? s.toLowerCase() : s))
165+
.join(' ')
166+
167+
return `The cron will be run ${readableSchedule}.`
168+
}

apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { parseAsString, useQueryState } from 'nuqs'
99
import { Button, Input, Sheet, SheetContent } from 'ui'
1010
import { CronJobCard } from '../CronJobs/CronJobCard'
1111
import DeleteCronJob from '../CronJobs/DeleteCronJob'
12+
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
1213

1314
export const CronjobsTab = () => {
1415
const { project } = useProjectContext()
@@ -26,6 +27,17 @@ export const CronjobsTab = () => {
2627
projectRef: project?.ref,
2728
connectionString: project?.connectionString,
2829
})
30+
31+
const { data: extensions } = useDatabaseExtensionsQuery({
32+
projectRef: project?.ref,
33+
connectionString: project?.connectionString,
34+
})
35+
36+
// check pg_cron version to see if it supports seconds
37+
const pgCronExtension = (extensions ?? []).find((ext) => ext.name === 'pg_cron')
38+
const installedVersion = pgCronExtension?.installed_version
39+
const supportsSeconds = installedVersion ? parseFloat(installedVersion) >= 1.5 : false
40+
2941
if (isLoading)
3042
return (
3143
<div className="p-10">
@@ -125,6 +137,7 @@ export const CronjobsTab = () => {
125137
<SheetContent size="default" tabIndex={undefined}>
126138
<CreateCronJobSheet
127139
selectedCronJob={createCronJobSheetShown}
140+
supportsSeconds={supportsSeconds}
128141
onClose={() => {
129142
setIsClosingCreateCronJobSheet(false)
130143
setCreateCronJobSheetShown(undefined)

apps/studio/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"config": "*",
6060
"configcat-js": "^7.0.0",
6161
"cronstrue": "^2.50.0",
62+
"cron-parser": "^4.9.0",
6263
"dayjs": "^1.11.10",
6364
"dnd-core": "^16.0.1",
6465
"file-saver": "^2.0.5",

0 commit comments

Comments
 (0)