fix: Cron schedule (#30546)

* Simplify the schedule section for crons.

* Make the errorBoundary more robust to handle non-Error errors.

* Remove unused variable.

* Smaller fixes to the schedule section.

* Simplify the scheduleString usage.

* Revalidate when setting the value from the cron AI.

* Minor fixes.

* Reset the error state when setting the schedule value.

* Fix finding cron overview.md

* Small refactors

* SMOL

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Ivan VasilovandJoshen Lim authored and GitHub committed 2024-12-04 12:39:10 +01:00
1 parent f19410c30d
commit 5d4f217291
6 files changed
+149 -156

No files matched your search

@@ -7,12 +7,15 @@ import { toast } from 'sonner'
import z from 'zod'
import { urlRegex } from 'components/interfaces/Auth/Auth.constants'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useDatabaseCronJobCreateMutation } from 'data/database-cron-jobs/database-cron-jobs-create-mutation'
import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-query'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { TELEMETRY_EVENTS, TELEMETRY_VALUES } from 'lib/constants/telemetry'
import {
Button,
Form_Shadcn_,
@@ -28,13 +31,9 @@ import {
SheetTitle,
WarningIcon,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { Admonition } from 'ui-patterns/admonition'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { TELEMETRY_EVENTS, TELEMETRY_VALUES } from 'lib/constants/telemetry'
import { CRONJOB_DEFINITIONS } from './CronJobs.constants'
import {
buildCronQuery,
@@ -26,7 +26,7 @@ import {
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { CreateCronJobForm } from './CreateCronJobSheet'
import { getScheduleMessage, secondsPattern } from './CronJobs.utils'
import { formatScheduleString, getScheduleMessage, secondsPattern } from './CronJobs.utils'
import CronSyntaxChart from './CronSyntaxChart'
interface CronJobScheduleSectionProps {
@@ -36,14 +36,10 @@ interface CronJobScheduleSectionProps {
export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobScheduleSectionProps) => {
const { project } = useProjectContext()
const initialValue = form.getValues('schedule')
const schedule = form.watch('schedule')
const [presetValue, setPresetValue] = useState<string>(initialValue)
const [inputValue, setInputValue] = useState(initialValue)
const [inputValue, setInputValue] = useState('')
const [debouncedValue] = useDebounce(inputValue, 750)
const [useNaturalLanguage, setUseNaturalLanguage] = useState(false)
const [scheduleString, setScheduleString] = useState('')
const PRESETS = [
...(supportsSeconds ? [{ name: 'Every 30 seconds', expression: '30 seconds' }] : []),
@@ -54,15 +50,21 @@ export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobSchedul
{ name: 'Every Monday at 2 AM', expression: '0 2 * * 1' },
] as const
const { complete: generateCronSyntax, isLoading: isGeneratingCron } = useCompletion({
const {
complete: generateCronSyntax,
isLoading: isGeneratingCron,
stop,
} = useCompletion({
api: `${BASE_PATH}/api/ai/sql/cron`,
onResponse: async (response) => {
if (response.ok) {
// remove quotes from the cron expression
const expression = (await response.text()).trim().replace(/^"|"$/g, '')
form.setValue('schedule', expression)
setPresetValue(expression)
setScheduleString(CronToString(expression))
form.setValue('schedule', expression, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
})
}
},
onError: (error) => {
@@ -76,47 +78,15 @@ export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobSchedul
})
useEffect(() => {
if (!useNaturalLanguage || !debouncedValue) return
generateCronSyntax(debouncedValue)
if (useNaturalLanguage && debouncedValue) {
generateCronSyntax(debouncedValue)
return () => stop()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedValue, useNaturalLanguage])
useEffect(() => {
if (!inputValue || inputValue.length < 5) return // set a min length before showing invalid message
// update the cronstrue string when the input value changes
try {
setScheduleString(CronToString(inputValue))
form.setValue('schedule', inputValue)
} catch (error) {
console.error('Error converting cron expression to string:', error)
}
}, [form, inputValue])
useEffect(() => {
if (useNaturalLanguage) return
setPresetValue(schedule)
if (!schedule) {
setScheduleString('')
return
}
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])
const schedule = form.watch('schedule')
const scheduleString = formatScheduleString(schedule)
return (
<SheetSection>
@@ -136,108 +106,99 @@ export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobSchedul
</div>
<FormControl_Shadcn_>
<div className="flex flex-col gap-y-2">
{useNaturalLanguage ? (
<Input
value={inputValue}
placeholder="E.g. every 5 minutes"
className={cn(!useNaturalLanguage && 'hidden')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
}
}}
onChange={(e) => setInputValue(e.target.value)}
/>
) : (
<Input_Shadcn_
{...field}
autoComplete="off"
placeholder="* * * * *"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
}
}}
/>
)}
<FormMessage_Shadcn_ />
<div className="flex items-center gap-2 mt-2">
<Switch
checked={useNaturalLanguage}
onCheckedChange={() => {
setUseNaturalLanguage(!useNaturalLanguage)
setInputValue('')
setPresetValue('')
setScheduleString('')
form.setValue('schedule', '')
}}
/>
<p className="text-sm text-foreground-light">Use natural language</p>
</div>
<div className="mt-2">
<ul className="flex gap-2 flex-wrap mt-2">
{PRESETS.map((preset) => (
<li key={preset.name}>
<Button
type="outline"
onClick={() => {
setUseNaturalLanguage(false)
form.setValue('schedule', preset.expression)
form.trigger('schedule')
setPresetValue(preset.expression)
}}
>
{preset.name}
</Button>
</li>
))}
</ul>
</div>
<Accordion_Shadcn_ type="single" collapsible className="mt-2 pb-2">
<AccordionItem_Shadcn_ value="item-1" className="border-none">
<AccordionTrigger_Shadcn_ className="text-xs text-foreground-light font-normal gap-2 justify-start py-1 ">
View syntax chart
</AccordionTrigger_Shadcn_>
<AccordionContent_Shadcn_ asChild className="!pb-0">
<CronSyntaxChart />
</AccordionContent_Shadcn_>
</AccordionItem_Shadcn_>
</Accordion_Shadcn_>
</div>
{useNaturalLanguage ? (
<Input
value={inputValue}
placeholder="E.g. every 5 minutes"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
}
}}
onChange={(e) => setInputValue(e.target.value)}
/>
) : (
<Input_Shadcn_
{...field}
autoComplete="off"
placeholder="* * * * *"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
}
}}
/>
)}
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
<div className="flex flex-col gap-y-4 mt-3 mb-2">
<div className="flex items-center gap-2">
<Switch
checked={useNaturalLanguage}
onCheckedChange={() => {
setUseNaturalLanguage(!useNaturalLanguage)
setInputValue('')
}}
/>
<p className="text-sm text-foreground-light">Use natural language</p>
</div>
<ul className="flex gap-2 flex-wrap mt-2">
{PRESETS.map((preset) => (
<li key={preset.name}>
<Button
type="outline"
onClick={() => {
if (useNaturalLanguage) {
setUseNaturalLanguage(false)
}
form.setValue('schedule', preset.expression, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
})
}}
>
{preset.name}
</Button>
</li>
))}
</ul>
<Accordion_Shadcn_ type="single" collapsible>
<AccordionItem_Shadcn_ value="item-1" className="border-none">
<AccordionTrigger_Shadcn_ className="text-xs text-foreground-light font-normal gap-2 justify-start py-1 ">
View syntax chart
</AccordionTrigger_Shadcn_>
<AccordionContent_Shadcn_ asChild className="!pb-0">
<CronSyntaxChart />
</AccordionContent_Shadcn_>
</AccordionItem_Shadcn_>
</Accordion_Shadcn_>
</div>
<div className="bg-surface-100 p-4 rounded grid gap-y-4 border">
<h4 className="text-sm text-foreground">
Schedule {timezone ? `(${timezone})` : ''}
</h4>
{scheduleString ? (
<span
className={cn(
'text-xl font-mono',
isGeneratingCron ? 'animate-pulse text-foreground-lighter' : 'text-foreground'
)}
>
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * *'}
</span>
) : (
<span className="text-xl font-mono text-foreground-lighter">
{isGeneratingCron ? <CronSyntaxLoader /> : presetValue || '* * * * *'}
</span>
)}
<span
className={cn(
'text-xl font-mono',
scheduleString
? isGeneratingCron
? 'animate-pulse text-foreground-lighter'
: 'text-foreground'
: 'text-foreground-lighter'
)}
>
{isGeneratingCron ? <CronSyntaxLoader /> : schedule || '* * * * * *'}
</span>
{!inputValue && !isGeneratingCron && !scheduleString ? (
<span className="text-sm text-foreground-light">
Describe your schedule above
</span>
) : (
<span className="text-sm text-foreground-light flex items-center gap-2">
{isGeneratingCron ? (
<LoadingDots />
) : (
getScheduleMessage(scheduleString, schedule)
)}
{isGeneratingCron ? <LoadingDots /> : getScheduleMessage(scheduleString)}
</span>
)}
</div>
@@ -1,3 +1,5 @@
import { toString as CronToString } from 'cronstrue'
import { CronJobType } from './CreateCronJobSheet'
import { HTTPHeader } from './CronJobs.constants'
@@ -75,6 +77,7 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => {
edgeFunctionName: url,
httpHeaders: headersObjs,
httpBody: body,
// @ts-ignore
timeoutMs: +matches[5] ?? 1000,
}
}
@@ -85,6 +88,7 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => {
endpoint: url,
httpHeaders: headersObjs,
httpBody: body,
// @ts-ignore
timeoutMs: +matches[5] ?? 1000,
}
}
@@ -153,13 +157,14 @@ export function isSecondsFormat(schedule: string): boolean {
return secondsPattern.test(schedule.trim())
}
export function getScheduleMessage(scheduleString: string, schedule: string) {
export function getScheduleMessage(scheduleString: string) {
if (!scheduleString) {
return 'Enter a valid cron expression above'
}
if (secondsPattern.test(schedule)) {
return `The cron will be run every ${schedule}`
// if the schedule is in seconds format, scheduleString is same as the schedule
if (secondsPattern.test(scheduleString)) {
return `The cron will run every ${scheduleString}`
}
if (scheduleString.includes('Invalid cron expression')) {
@@ -171,5 +176,17 @@ export function getScheduleMessage(scheduleString: string, schedule: string) {
.map((s, i) => (i === 0 ? s.toLowerCase() : s))
.join(' ')
return `The cron will be run ${readableSchedule}.`
return `The cron will run ${readableSchedule}.`
}
export const formatScheduleString = (value: string) => {
try {
if (secondsPattern.test(value)) {
return value
} else {
return CronToString(value)
}
} catch (error) {
return ''
}
}
@@ -1,10 +1,10 @@
import { Clock5, Layers, Timer, Vault, Webhook } from 'lucide-react'
import dynamic from 'next/dynamic'
import Image from 'next/image'
import { ComponentType, ReactNode } from 'react'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { BASE_PATH } from 'lib/constants'
import dynamic from 'next/dynamic'
import { cn } from 'ui'
import { WRAPPERS } from '../Wrappers/Wrappers.constants'
import { WrapperMeta } from '../Wrappers/Wrappers.types'
@@ -120,7 +120,7 @@ const supabaseIntegrations: IntegrationDefinition[] = [
description: 'Schedule and automate tasks to run maintenance routines at specified intervals.',
docsUrl: 'https://github.com/citusdata/pg_cron',
author: {
name: 'pg_cron',
name: 'Citus Data',
websiteUrl: 'https://github.com/citusdata/pg_cron',
},
navigation: [
@@ -1,15 +1,31 @@
import { isError } from 'lodash'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import type { FallbackProps } from 'react-error-boundary'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui'
import { WarningIcon } from 'ui'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
WarningIcon,
} from 'ui'
// More correct version of FallbackProps from react-error-boundary
export type FallbackProps = {
error: unknown
resetErrorBoundary: (...args: any[]) => void
}
export const ErrorBoundaryState = ({ error, resetErrorBoundary }: FallbackProps) => {
const router = useRouter()
const message = `Path name: ${router.pathname}\n\n${error.stack}`
const isRemoveChildError = error.message.includes("Failed to execute 'removeChild' on 'Node'")
const checkIsError = isError(error)
const errorMessage = checkIsError ? error.message : ''
const urlMessage = checkIsError ? `Path name: ${router.pathname}\n\n${error?.stack}` : ''
const isRemoveChildError = checkIsError
? errorMessage.includes("Failed to execute 'removeChild' on 'Node'")
: false
return (
<div className="w-screen h-screen flex items-center justify-center flex-col gap-y-3">
@@ -18,7 +34,7 @@ export const ErrorBoundaryState = ({ error, resetErrorBoundary }: FallbackProps)
Application error: a client-side exception has occurred (see browser console for more
information)
</p>
<p className="text-sm text-foreground-light">Error: {error.message}</p>
<p className="text-sm text-foreground-light">Error: {errorMessage}</p>
</div>
{isRemoveChildError && (
@@ -48,7 +64,7 @@ export const ErrorBoundaryState = ({ error, resetErrorBoundary }: FallbackProps)
<div className="flex items-center justify-center gap-x-2">
<Button asChild type="default" icon={<ExternalLink />}>
<Link
href={`/support/new?category=dashboard_bug&subject=Client%20side%20exception%20occurred%20on%20dashboard&message=${encodeURI(message)}`}
href={`/support/new?category=dashboard_bug&subject=Client%20side%20exception%20occurred%20on%20dashboard&message=${encodeURI(urlMessage)}`}
target="_blank"
>
Report to support