From 62e2bcf2cae794fac593ebfab8a08406fcd3b7df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Thu, 28 Nov 2024 15:09:23 +0800 Subject: [PATCH 1/3] chore: automatically mark default payment method [GEN-12003] (#30707) When customers go through the upgrade flow via the plan upgrade modal, we want to automatically confirm any newly added payment method as default payment method. We've seen some issues on Orb that customers would upgrade without a default payment method. This change should help mitigate it further. --- .../Billing/Payment/AddNewPaymentMethodModal.tsx | 3 +++ .../interfaces/Billing/Payment/AddPaymentMethodForm.tsx | 8 +++++++- .../Subscription/PaymentMethodSelection.tsx | 1 + .../BillingSettings/Subscription/Subscription.tsx | 4 ++-- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx b/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx index 8d4f50aa036ab..7c75bfae4e76b 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx @@ -18,6 +18,7 @@ interface AddNewPaymentMethodModalProps { onCancel: () => void onConfirm: () => void showSetDefaultCheckbox?: boolean + autoMarkAsDefaultPaymentMethod?: boolean } const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) @@ -28,6 +29,7 @@ const AddNewPaymentMethodModal = ({ onCancel, onConfirm, showSetDefaultCheckbox, + autoMarkAsDefaultPaymentMethod, }: AddNewPaymentMethodModalProps) => { const { resolvedTheme } = useTheme() const [intent, setIntent] = useState() @@ -140,6 +142,7 @@ const AddNewPaymentMethodModal = ({ onCancel={onLocalCancel} onConfirm={onLocalConfirm} showSetDefaultCheckbox={showSetDefaultCheckbox} + autoMarkAsDefaultPaymentMethod={autoMarkAsDefaultPaymentMethod} /> diff --git a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx index 770d8ebbc103a..cc4f888b7604e 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx @@ -12,6 +12,7 @@ interface AddPaymentMethodFormProps { onCancel: () => void onConfirm: () => void showSetDefaultCheckbox?: boolean + autoMarkAsDefaultPaymentMethod?: boolean } // Stripe docs recommend to use the new SetupIntent flow over @@ -23,6 +24,7 @@ const AddPaymentMethodForm = ({ onCancel, onConfirm, showSetDefaultCheckbox = false, + autoMarkAsDefaultPaymentMethod = false, }: AddPaymentMethodFormProps) => { const stripe = useStripe() const elements = useElements() @@ -59,7 +61,11 @@ const AddPaymentMethodForm = ({ setIsSaving(false) toast.error(error?.message ?? ' Failed to save card details') } else { - if (isDefault && selectedOrganization && typeof setupIntent?.payment_method === 'string') { + if ( + (isDefault || autoMarkAsDefaultPaymentMethod) && + selectedOrganization && + typeof setupIntent?.payment_method === 'string' + ) { try { await markAsDefault({ slug: selectedOrganization.slug, diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx index 710c98f85232e..f5ba6e1ec0673 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx @@ -150,6 +150,7 @@ const PaymentMethodSelection = ({ visible={showAddNewPaymentMethodModal} returnUrl={`${getURL()}/org/${selectedOrganization?.slug}/billing?panel=subscriptionPlan`} onCancel={() => setShowAddNewPaymentMethodModal(false)} + autoMarkAsDefaultPaymentMethod={true} onConfirm={async () => { setShowAddNewPaymentMethodModal(false) toast.success('Successfully added new payment method') diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx index 57d3b730ba17f..8c779f5579f39 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx @@ -147,9 +147,9 @@ const Subscription = () => { title="This organization is limited by the included usage" >
- Projects may become unresponsive when this organization exceeds its + Projects may become unresponsive when this organization exceeds its{' '} included usage quota. To scale - seamlessly and pay for over-usage, $ + seamlessly and pay for over-usage,{' '} {currentPlan?.id === 'free' ? 'upgrade to a paid plan.' : 'you can disable Spend Cap under the Cost Control settings.'} From aea64c9a07e88b1d28e28f16d3d8a39ab44c0868 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 28 Nov 2024 18:37:54 +1000 Subject: [PATCH 2/3] Assistant refinements (#30685) * empty state refinements * specify js conversion prompt * placeholder state * update dot color * Lint * ADjust padding * Lint imports * adjust prompt * revert padding * wrap line prop in code block * fix margin * use sql icon * margin * add chart prompt * Add prevent default behaviour --------- Co-authored-by: Joshen Lim --- .../layouts/ProjectLayout/ProjectLayout.tsx | 6 +- .../ui/AIAssistantPanel/AIAssistant.tsx | 45 +- .../ui/AIAssistantPanel/AIOnboarding.tsx | 424 +++++++++--------- .../ui/AIAssistantPanel/SqlSnippet.tsx | 18 +- apps/studio/components/ui/DotGrid.tsx | 73 +++ apps/studio/pages/api/ai/sql/generate-v3.ts | 3 +- .../ui/src/components/CodeBlock/CodeBlock.tsx | 4 +- .../ui/src/components/TreeView/TreeView.tsx | 2 +- 8 files changed, 338 insertions(+), 237 deletions(-) create mode 100644 apps/studio/components/ui/DotGrid.tsx diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index acfd0705ee736..601fd07558a88 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -114,7 +114,11 @@ const ProjectLayout = forwardRef { const handler = (e: KeyboardEvent) => { - if (e.metaKey && e.code === 'KeyI') setAiAssistantPanel({ open: !open }) + if (e.metaKey && e.code === 'KeyI') { + setAiAssistantPanel({ open: !open }) + e.preventDefault() + e.stopPropagation() + } } if (isAssistantV2Enabled) window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index 050323869e2b1..81f71e9bd35ad 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -1,5 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { motion } from 'framer-motion' +import { AnimatePresence, motion } from 'framer-motion' import { last } from 'lodash' import { FileText } from 'lucide-react' import { memo, useEffect, useMemo, useRef, useState } from 'react' @@ -7,6 +7,7 @@ import { toast } from 'sonner' import type { Message as MessageType } from 'ai/react' import { useChat } from 'ai/react' +import { useParams, useSearchParamsShallow } from 'common/hooks' import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' import { Markdown } from 'components/interfaces/Markdown' import OptInToOpenAIToggle from 'components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle' @@ -25,7 +26,9 @@ import { useFlag } from 'hooks/ui/useFlag' import { BASE_PATH, IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants' import { TELEMETRY_EVENTS, TELEMETRY_VALUES } from 'lib/constants/telemetry' import uuidv4 from 'lib/uuid' +import { useRouter } from 'next/router' import { useAppStateSnapshot } from 'state/app-state' +import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { AiIconAnimation, Button, @@ -40,10 +43,6 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import AIOnboarding from './AIOnboarding' import CollapsibleCodeBlock from './CollapsibleCodeBlock' import { Message } from './Message' -import { useParams } from 'common/hooks' -import { useSearchParamsShallow } from 'common/hooks' -import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' -import { useRouter } from 'next/router' const MemoizedMessage = memo( ({ message, isLoading }: { message: MessageType; isLoading: boolean }) => { @@ -235,14 +234,18 @@ export const AIAssistant = ({ // Add useEffect to set up scroll listener useEffect(() => { - const container = scrollContainerRef.current - if (container) { - container.addEventListener('scroll', handleScroll) - // Initial check - handleScroll() - } + // Use a small delay to ensure container is mounted and has content + const timeoutId = setTimeout(() => { + const container = scrollContainerRef.current + if (container) { + container.addEventListener('scroll', handleScroll) + handleScroll() + } + }, 100) return () => { + clearTimeout(timeoutId) + const container = scrollContainerRef.current if (container) { container.removeEventListener('scroll', handleScroll) } @@ -507,12 +510,18 @@ export const AIAssistant = ({
)} - - {showFade && ( -
-
-
- )} + + {showFade && ( + +
+ + )} +
{sqlSnippets && sqlSnippets.length > 0 && ( @@ -565,7 +574,7 @@ export const AIAssistant = ({ ? 'Reply to the assistant...' : (sqlSnippets ?? [])?.length > 0 ? 'Ask a question or make a change...' - : 'How can we help you today?' + : 'Chat to Postgres...' } value={value} onValueChange={(e) => setValue(e.target.value)} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx b/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx index 5f505e24f9b6b..0d330085b60fe 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx @@ -1,6 +1,13 @@ import { motion } from 'framer-motion' +import { FileText, MessageCircleMore, WandSparkles } from 'lucide-react' + +import DotGrid from 'components/ui/DotGrid' import { Button } from 'ui' -import { WandSparkles, FileText, MessageCircle, MessageCircleMore } from 'lucide-react' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleContent, + InnerSideMenuCollapsibleTrigger, +} from 'ui-patterns/InnerSideMenu' interface AIOnboardingProps { setMessages: (messages: any[]) => void @@ -13,233 +20,226 @@ export default function AIOnboarding({ setMessages, onSendMessage }: AIOnboardin } return ( -
-
- -
- - - -
+
+
+
- -

How can I help you today?

-
- - + -

Tables

-
- +

How can I assist you?

+

+ I can help you build and manage your database by writing SQL or supabase-js, set up + policies, functions or triggers, and query your data - ask me anything. +

+ + + + + + +
+ - + - -
- + +
+ + +
- -

RLS Policies

-
- + + + + +
+ - + - -
- + +
+ + +
- -

Functions

-
- + + + + +
+ - + - -
- + +
+ + + - -

Triggers

-
- + + + + +
+ - + - -
- -
+ +
+ + + + +
) } diff --git a/apps/studio/components/ui/AIAssistantPanel/SqlSnippet.tsx b/apps/studio/components/ui/AIAssistantPanel/SqlSnippet.tsx index de91f7eab3072..5cbde110e8bb2 100644 --- a/apps/studio/components/ui/AIAssistantPanel/SqlSnippet.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/SqlSnippet.tsx @@ -1,4 +1,4 @@ -import { Code, DatabaseIcon, Edit, Play } from 'lucide-react' +import { Code, Edit, Play } from 'lucide-react' import { useRouter } from 'next/router' import { useCallback, useEffect, useState } from 'react' import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts' @@ -21,6 +21,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + SQL_ICON, cn, } from 'ui' import { Admonition } from 'ui-patterns' @@ -211,7 +212,17 @@ export const SqlCard = ({ ) : ( <> - +

{title}

{!readOnly && ( @@ -313,11 +324,12 @@ export const SqlCard = ({ {showCode && ( code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap [&>code]:block [&>code>span]:text-foreground' + '[&>code]:m-0 [&>code>span]:text-foreground' )} /> )} diff --git a/apps/studio/components/ui/DotGrid.tsx b/apps/studio/components/ui/DotGrid.tsx new file mode 100644 index 0000000000000..906206b9e414e --- /dev/null +++ b/apps/studio/components/ui/DotGrid.tsx @@ -0,0 +1,73 @@ +import { motion } from 'framer-motion' + +interface DotGridProps { + rows: number + columns: number + count: number +} + +const DotGrid = ({ rows, columns, count }: DotGridProps) => { + const container = { + hidden: { opacity: 1 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.05, + delayChildren: 0.05, + }, + }, + } + + const item = { + hidden: { opacity: 0 }, + visible: { opacity: 0.5 }, + } + + const highlightedVariants = { + visible: { + opacity: [1, 0.5, 1], + transition: { + repeat: Infinity, + duration: 0.5, + repeatDelay: 1.5, + ease: 'easeInOut', + }, + }, + } + + return ( +
+ + {Array.from({ length: rows * columns }).map((_, index) => { + const isHighlighted = index < count + return ( + + ) + })} + +
+ ) +} + +export default DotGrid diff --git a/apps/studio/pages/api/ai/sql/generate-v3.ts b/apps/studio/pages/api/ai/sql/generate-v3.ts index 343cde4a462da..917c3d07bf51e 100644 --- a/apps/studio/pages/api/ai/sql/generate-v3.ts +++ b/apps/studio/pages/api/ai/sql/generate-v3.ts @@ -77,6 +77,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { - Output as markdown - Always include code snippets if available - If a code snippet is SQL, the first line of the snippet should always be -- props: {"title": "Query title", "isChart": "true", "xAxis": "columnName", "yAxis": "columnName"} + - Only set chart to true if the query makes sense as a chart - Explain what the snippet does in a sentence or two before showing it - Use vector(384) data type for any embedding/vector related query - When debugging, retrieve sql schema details to ensure sql is correct @@ -106,7 +107,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { Please make sure that all queries are valid Postgres SQL queries # You convert sql to supabase-js client code - Use the convertSqlToSupabaseJs tool to convert select sql to supabase-js client code. If conversion isn't supported, build a postgres function instead and suggest using supabase-js to call it via "const { data, error } = await supabase.rpc('echo', { say: '👋'})" + Use the convertSqlToSupabaseJs tool to convert select sql to supabase-js client code. Only provide js code snippets if explicitly asked. If conversion isn't supported, build a postgres function instead and suggest using supabase-js to call it via "const { data, error } = await supabase.rpc('echo', { say: '👋'})" Follow these instructions: - First look at the list of provided schemas and if needed, get more information about a schema. You will almost always need to retrieve information about the public schema before answering a question. If the question is about users, also retrieve the auth schema. diff --git a/packages/ui/src/components/CodeBlock/CodeBlock.tsx b/packages/ui/src/components/CodeBlock/CodeBlock.tsx index 0aeaedc241061..2013320d5bafc 100644 --- a/packages/ui/src/components/CodeBlock/CodeBlock.tsx +++ b/packages/ui/src/components/CodeBlock/CodeBlock.tsx @@ -52,6 +52,7 @@ export interface CodeBlockProps { children?: string renderer?: SyntaxHighlighterProps['renderer'] focusable?: boolean + wrapLines?: boolean } /** @@ -84,6 +85,7 @@ export const CodeBlock = ({ children, hideCopy = false, hideLineNumbers = false, + wrapLines = true, renderer, focusable = true, }: CodeBlockProps) => { @@ -150,7 +152,7 @@ export const CodeBlock = ({ {/* @ts-ignore */} Date: Thu, 28 Nov 2024 05:52:29 -0330 Subject: [PATCH 3/3] 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 --- .../CronJobs/CreateCronJobSheet.tsx | 77 +++++++++++-------- .../CronJobs/CronJobScheduleSection.tsx | 63 ++++++++------- .../Integrations/CronJobs/CronJobs.utils.ts | 28 ++++++- .../Integrations/CronJobs/CronJobsTab.tsx | 13 ++++ apps/studio/package.json | 1 + package-lock.json | 20 +++++ packages/ai-commands/src/sql/cron.ts | 17 ++-- 7 files changed, 150 insertions(+), 69 deletions(-) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx index c3130c51d6ffb..e477d0c146c5a 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx @@ -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 + 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 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 = ({ - - + Cron jobs cannot be renamed once created - + )} /> - + + 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(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 ( - Schedule - - {useNaturalLanguage ? 'Describe your schedule in words' : 'Enter a cron expression'} - +
+ Schedule + + {useNaturalLanguage + ? 'Describe your schedule in words' + : 'Enter a cron expression'} + +
+
{useNaturalLanguage ? ( @@ -146,6 +161,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) => {isGeneratingCron ? ( - ) : 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) )} )} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts index 8ad110fa62cfc..4ebe44ebe89bf 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts @@ -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}.` +} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx index 8136e14a377ea..2c103c430251e 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx @@ -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 (
@@ -125,6 +137,7 @@ export const CronjobsTab = () => { { setIsClosingCreateCronJobSheet(false) setCreateCronJobSheetShown(undefined) diff --git a/apps/studio/package.json b/apps/studio/package.json index 44208f8399ff1..62e41e283474e 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -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", diff --git a/package-lock.json b/package-lock.json index 05a306abd373b..0dfcff6546960 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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, diff --git a/packages/ai-commands/src/sql/cron.ts b/packages/ai-commands/src/sql/cron.ts index 27df01ec0fd47..26f756e8c1dc6 100644 --- a/packages/ai-commands/src/sql/cron.ts +++ b/packages/ai-commands/src/sql/cron.ts @@ -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} `,