mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
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,
|
||||
|
||||
+99
-138
@@ -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
|
||||
|
||||
File renamed without changes.
Reference in new issue
Block a user