Chore/cron UI seconds 2 (#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 <vasilov.ivan@gmail.com>
This commit is contained in:
@@ -18,7 +18,6 @@ import {
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormLabel_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
RadioGroupStacked,
|
||||
RadioGroupStackedItem,
|
||||
@@ -33,13 +32,14 @@ import { Admonition } from 'ui-patterns'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
|
||||
import { CRONJOB_DEFINITIONS } from './CronJobs.constants'
|
||||
import {
|
||||
buildCronQuery,
|
||||
buildHttpRequestCommand,
|
||||
cronPattern,
|
||||
secondsPattern,
|
||||
parseCronJobCommand,
|
||||
secondsPattern,
|
||||
} from './CronJobs.utils'
|
||||
import { CronJobScheduleSection } from './CronJobScheduleSection'
|
||||
import { EdgeFunctionSection } from './EdgeFunctionSection'
|
||||
@@ -48,10 +48,10 @@ import { HTTPParameterFieldsSection } from './HttpParameterFieldsSection'
|
||||
import { HttpRequestSection } from './HttpRequestSection'
|
||||
import { SqlFunctionSection } from './SqlFunctionSection'
|
||||
import { SqlSnippetSection } from './SqlSnippetSection'
|
||||
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
|
||||
|
||||
export interface CreateCronJobSheetProps {
|
||||
selectedCronJob?: Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'>
|
||||
supportsSeconds: boolean
|
||||
isClosing: boolean
|
||||
setIsClosing: (v: boolean) => void
|
||||
onClose: () => void
|
||||
@@ -90,32 +90,45 @@ const sqlSnippetSchema = z.object({
|
||||
snippet: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Please provide a name for your cron job'),
|
||||
schedule: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((value) => {
|
||||
if (cronPattern.test(value)) {
|
||||
try {
|
||||
CronToString(value)
|
||||
const FormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, 'Please provide a name for your cron job'),
|
||||
supportsSeconds: z.boolean(),
|
||||
schedule: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((value) => {
|
||||
if (cronPattern.test(value)) {
|
||||
try {
|
||||
CronToString(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
} else if (secondsPattern.test(value)) {
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
} else if (secondsPattern.test(value)) {
|
||||
return true
|
||||
return false
|
||||
}, 'Invalid Cron format'),
|
||||
values: z.discriminatedUnion('type', [
|
||||
edgeFunctionSchema,
|
||||
httpRequestSchema,
|
||||
sqlFunctionSchema,
|
||||
sqlSnippetSchema,
|
||||
]),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!cronPattern.test(data.schedule)) {
|
||||
if (!(data.supportsSeconds && secondsPattern.test(data.schedule))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Seconds are supported only in pg_cron v1.5.0+. Please use a valid Cron format.',
|
||||
path: ['schedule'],
|
||||
})
|
||||
}
|
||||
return false
|
||||
}, 'The schedule needs to be in a valid Cron format or specify seconds like "x seconds".'),
|
||||
values: z.discriminatedUnion('type', [
|
||||
edgeFunctionSchema,
|
||||
httpRequestSchema,
|
||||
sqlFunctionSchema,
|
||||
sqlSnippetSchema,
|
||||
]),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type CreateCronJobForm = z.infer<typeof FormSchema>
|
||||
export type CronJobType = CreateCronJobForm['values']
|
||||
@@ -124,11 +137,14 @@ const FORM_ID = 'create-cron-job-sidepanel'
|
||||
|
||||
export const CreateCronJobSheet = ({
|
||||
selectedCronJob,
|
||||
supportsSeconds,
|
||||
isClosing,
|
||||
setIsClosing,
|
||||
onClose,
|
||||
}: CreateCronJobSheetProps) => {
|
||||
const { project } = useProjectContext()
|
||||
const isEditing = !!selectedCronJob?.jobname
|
||||
|
||||
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
|
||||
const { mutate: upsertCronJob, isLoading } = useDatabaseCronJobCreateMutation()
|
||||
|
||||
@@ -144,11 +160,11 @@ export const CreateCronJobSheet = ({
|
||||
defaultValues: {
|
||||
name: selectedCronJob?.jobname || '',
|
||||
schedule: selectedCronJob?.schedule || '*/5 * * * *',
|
||||
supportsSeconds,
|
||||
values: cronJobValues,
|
||||
},
|
||||
})
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const isEdited = form.formState.isDirty
|
||||
|
||||
// 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 = ({
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} disabled={isEditing} />
|
||||
</FormControl_Shadcn_>
|
||||
|
||||
<FormLabel_Shadcn_ className="text-foreground-lighter text-xs absolute top-0 right-0 ">
|
||||
<span className="text-foreground-lighter text-xs absolute top-0 right-0">
|
||||
Cron jobs cannot be renamed once created
|
||||
</FormLabel_Shadcn_>
|
||||
</span>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</SheetSection>
|
||||
<Separator />
|
||||
<CronJobScheduleSection form={form} />
|
||||
<CronJobScheduleSection form={form} supportsSeconds={supportsSeconds} />
|
||||
<Separator />
|
||||
<SheetSection>
|
||||
<FormField_Shadcn_
|
||||
|
||||
@@ -19,32 +19,25 @@ import {
|
||||
FormField_Shadcn_,
|
||||
FormItem_Shadcn_,
|
||||
FormLabel_Shadcn_,
|
||||
FormMessage_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
SheetSection,
|
||||
Switch,
|
||||
} from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { CreateCronJobForm } from './CreateCronJobSheet'
|
||||
import { getScheduleMessage, secondsPattern } from './CronJobs.utils'
|
||||
import CronSyntaxChart from './CronSyntaxChart'
|
||||
import { secondsPattern } from './CronJobs.utils'
|
||||
|
||||
interface CronJobScheduleSectionProps {
|
||||
form: UseFormReturn<CreateCronJobForm>
|
||||
supportsSeconds: boolean
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{ name: 'Every minute', expression: '* * * * *' },
|
||||
{ name: 'Every 5 minutes', expression: '*/5 * * * *' },
|
||||
{ name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' },
|
||||
{ name: 'Every night at midnight', expression: '0 0 * * *' },
|
||||
{ name: 'Every Monday at 2 AM', expression: '0 2 * * 1' },
|
||||
{ name: 'Every 30 seconds', expression: '30 seconds' },
|
||||
] as const
|
||||
|
||||
export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) => {
|
||||
export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobScheduleSectionProps) => {
|
||||
const { project } = useProjectContext()
|
||||
const initialValue = form.getValues('schedule')
|
||||
const { schedule } = form.watch()
|
||||
const schedule = form.watch('schedule')
|
||||
|
||||
const [presetValue, setPresetValue] = useState<string>(initialValue)
|
||||
const [inputValue, setInputValue] = useState(initialValue)
|
||||
@@ -52,6 +45,15 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
const [useNaturalLanguage, setUseNaturalLanguage] = useState(false)
|
||||
const [scheduleString, setScheduleString] = useState('')
|
||||
|
||||
const PRESETS = [
|
||||
...(supportsSeconds ? [{ name: 'Every 30 seconds', expression: '30 seconds' }] : []),
|
||||
{ name: 'Every minute', expression: '* * * * *' },
|
||||
{ name: 'Every 5 minutes', expression: '*/5 * * * *' },
|
||||
{ name: 'Every first of the month, at 00:00', expression: '0 0 1 * *' },
|
||||
{ name: 'Every night at midnight', expression: '0 0 * * *' },
|
||||
{ name: 'Every Monday at 2 AM', expression: '0 2 * * 1' },
|
||||
] as const
|
||||
|
||||
const { complete: generateCronSyntax, isLoading: isGeneratingCron } = useCompletion({
|
||||
api: `${BASE_PATH}/api/ai/sql/cron`,
|
||||
onResponse: async (response) => {
|
||||
@@ -102,10 +104,18 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
}
|
||||
|
||||
try {
|
||||
// Don't allow seconds-based schedules if seconds aren't supported
|
||||
if (!supportsSeconds && secondsPattern.test(schedule)) {
|
||||
setScheduleString('Invalid cron expression')
|
||||
return
|
||||
}
|
||||
|
||||
setScheduleString(CronToString(schedule))
|
||||
} catch (error) {
|
||||
setScheduleString('Invalid cron expression')
|
||||
console.error('Error converting cron expression to string:', error)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schedule])
|
||||
|
||||
return (
|
||||
@@ -116,10 +126,15 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem_Shadcn_ className="flex flex-col gap-1">
|
||||
<FormLabel_Shadcn_>Schedule</FormLabel_Shadcn_>
|
||||
<FormLabel_Shadcn_ className="text-foreground-lighter">
|
||||
{useNaturalLanguage ? 'Describe your schedule in words' : 'Enter a cron expression'}
|
||||
</FormLabel_Shadcn_>
|
||||
<div className="flex flex-row justify-between">
|
||||
<FormLabel_Shadcn_>Schedule</FormLabel_Shadcn_>
|
||||
<span className="text-foreground-lighter text-xs">
|
||||
{useNaturalLanguage
|
||||
? 'Describe your schedule in words'
|
||||
: 'Enter a cron expression'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FormControl_Shadcn_>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{useNaturalLanguage ? (
|
||||
@@ -146,6 +161,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<FormMessage_Shadcn_ />
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
@@ -170,6 +186,7 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
onClick={() => {
|
||||
setUseNaturalLanguage(false)
|
||||
form.setValue('schedule', preset.expression)
|
||||
form.trigger('schedule')
|
||||
setPresetValue(preset.expression)
|
||||
}}
|
||||
>
|
||||
@@ -218,20 +235,8 @@ export const CronJobScheduleSection = ({ form }: CronJobScheduleSectionProps) =>
|
||||
<span className="text-sm text-foreground-light flex items-center gap-2">
|
||||
{isGeneratingCron ? (
|
||||
<LoadingDots />
|
||||
) : scheduleString === '' ? ( // set a min length before showing invalid message
|
||||
'Enter a valid cron expression above'
|
||||
) : scheduleString.includes('Invalid cron expression') ? (
|
||||
'Invalid cron expression'
|
||||
) : (
|
||||
<>
|
||||
The cron will be run{' '}
|
||||
{secondsPattern.test(schedule)
|
||||
? 'every ' + schedule
|
||||
: scheduleString
|
||||
.split(' ')
|
||||
.map((s, i) => (i === 0 ? s.toLocaleLowerCase() : s))
|
||||
.join(' ') + '.'}
|
||||
</>
|
||||
getScheduleMessage(scheduleString, schedule)
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const buildHttpRequestCommand = (
|
||||
$$`
|
||||
}
|
||||
|
||||
export const DEFAULT_CRONJOB_COMMAND = {
|
||||
const DEFAULT_CRONJOB_COMMAND = {
|
||||
type: 'sql_snippet',
|
||||
snippet: '',
|
||||
} as const
|
||||
@@ -136,11 +136,33 @@ export function formatDate(dateString: string): string {
|
||||
return date.toLocaleString(undefined, options)
|
||||
}
|
||||
|
||||
// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
|
||||
export const secondsPattern = /^\d+\s+seconds$/
|
||||
export const cronPattern =
|
||||
/^(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)(\s+(\*|(\d+|\*\/\d+)|\d+\/\d+|\d+-\d+|\d+(,\d+)*)){4}$/
|
||||
|
||||
// detect seconds like "10 seconds" or normal cron syntax like "*/5 * * * *"
|
||||
export const secondsPattern = /^\d+\s+seconds$/
|
||||
|
||||
export function isSecondsFormat(schedule: string): boolean {
|
||||
return secondsPattern.test(schedule.trim())
|
||||
}
|
||||
|
||||
export function getScheduleMessage(scheduleString: string, schedule: string) {
|
||||
if (!scheduleString) {
|
||||
return 'Enter a valid cron expression above'
|
||||
}
|
||||
|
||||
if (secondsPattern.test(schedule)) {
|
||||
return `The cron will be run every ${schedule}`
|
||||
}
|
||||
|
||||
if (scheduleString.includes('Invalid cron expression')) {
|
||||
return scheduleString
|
||||
}
|
||||
|
||||
const readableSchedule = scheduleString
|
||||
.split(' ')
|
||||
.map((s, i) => (i === 0 ? s.toLowerCase() : s))
|
||||
.join(' ')
|
||||
|
||||
return `The cron will be run ${readableSchedule}.`
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { Button, Input, Sheet, SheetContent } from 'ui'
|
||||
import { CronJobCard } from '../CronJobs/CronJobCard'
|
||||
import DeleteCronJob from '../CronJobs/DeleteCronJob'
|
||||
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
|
||||
export const CronjobsTab = () => {
|
||||
const { project } = useProjectContext()
|
||||
@@ -26,6 +27,17 @@ export const CronjobsTab = () => {
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const { data: extensions } = useDatabaseExtensionsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
// check pg_cron version to see if it supports seconds
|
||||
const pgCronExtension = (extensions ?? []).find((ext) => ext.name === 'pg_cron')
|
||||
const installedVersion = pgCronExtension?.installed_version
|
||||
const supportsSeconds = installedVersion ? parseFloat(installedVersion) >= 1.5 : false
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="p-10">
|
||||
@@ -125,6 +137,7 @@ export const CronjobsTab = () => {
|
||||
<SheetContent size="default" tabIndex={undefined}>
|
||||
<CreateCronJobSheet
|
||||
selectedCronJob={createCronJobSheetShown}
|
||||
supportsSeconds={supportsSeconds}
|
||||
onClose={() => {
|
||||
setIsClosingCreateCronJobSheet(false)
|
||||
setCreateCronJobSheetShown(undefined)
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"config": "*",
|
||||
"configcat-js": "^7.0.0",
|
||||
"cronstrue": "^2.50.0",
|
||||
"cron-parser": "^4.9.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"dnd-core": "^16.0.1",
|
||||
"file-saver": "^2.0.5",
|
||||
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -1818,6 +1818,7 @@
|
||||
"common-tags": "^1.8.2",
|
||||
"config": "*",
|
||||
"configcat-js": "^7.0.0",
|
||||
"cron-parser": "^4.9.0",
|
||||
"cronstrue": "^2.50.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"dnd-core": "^16.0.1",
|
||||
@@ -20618,6 +20619,17 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||
"dependencies": {
|
||||
"luxon": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cronstrue": {
|
||||
"version": "2.50.0",
|
||||
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.50.0.tgz",
|
||||
@@ -29146,6 +29158,14 @@
|
||||
"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
|
||||
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"dev": true,
|
||||
|
||||
@@ -12,22 +12,22 @@ export async function generateCron(openai: OpenAI, prompt: string) {
|
||||
You are a cron syntax expert. Your purpose is to convert natural language time descriptions into valid cron expressions for pg_cron.
|
||||
|
||||
Rules for responses:
|
||||
- Output cron expressions in the 5-field format supported by pg_cron
|
||||
- For standard intervals (minutes and above), output cron expressions in the 5-field format supported by pg_cron
|
||||
- For second-based intervals, use the special pg_cron "x seconds" syntax
|
||||
- Do not provide any explanation of what the cron expression does
|
||||
- Format output as markdown with the cron expression in a code block
|
||||
- Do not ask for clarification if you need it. Just output the cron expression.
|
||||
|
||||
Example input: "Every Monday at 3am"
|
||||
Example output:
|
||||
This cron expression runs every Monday at 3:00:00 AM:
|
||||
\`\`\`
|
||||
0 3 * * 1
|
||||
\`\`\`
|
||||
|
||||
|
||||
This cron expression runs every minute:
|
||||
Example input: "Every 30 seconds"
|
||||
Example output:
|
||||
\`\`\`
|
||||
* * * * *
|
||||
30 seconds
|
||||
\`\`\`
|
||||
|
||||
Additional examples:
|
||||
@@ -36,14 +36,19 @@ export async function generateCron(openai: OpenAI, prompt: string) {
|
||||
- Every first of the month, at 00:00: \`0 0 1 * *\`
|
||||
- Every night at midnight: \`0 0 * * *\`
|
||||
- Every Monday at 2am: \`0 2 * * 1\`
|
||||
- Every 15 seconds: \`15 seconds\`
|
||||
- Every 45 seconds: \`45 seconds\`
|
||||
|
||||
Field order:
|
||||
Field order for standard cron:
|
||||
- minute (0-59)
|
||||
- hour (0-23)
|
||||
- day (1-31)
|
||||
- month (1-12)
|
||||
- weekday (0-6, Sunday=0)
|
||||
|
||||
Important: pg_cron uses "x seconds" for second-based intervals, not "x * * * *".
|
||||
If the user asks for seconds, do not use the 5-field format, instead use "x seconds".
|
||||
|
||||
Here is the user's prompt:
|
||||
${prompt}
|
||||
`,
|
||||
|
||||
Reference in New Issue
Block a user