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:
Terry Sutton
2024-11-28 05:52:29 -03:30
committed by GitHub
parent aea64c9a07
commit b97a2636cb
7 changed files with 150 additions and 69 deletions

View File

@@ -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_

View File

@@ -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>
)}

View File

@@ -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}.`
}

View File

@@ -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)

View File

@@ -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
View File

@@ -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,

View File

@@ -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}
`,