Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ const PolicyTableRowHeader = ({
},
}}
>
<AiIconAnimation className="scale-75 [&>div>div]:border-black dark:[&>div>div]:border-white" />
<AiIconAnimation size={16} />
</ButtonTooltip>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,25 +78,7 @@ const BranchManagement = () => {
isLoading: isLoadingBranches,
isError: isErrorBranches,
isSuccess: isSuccessBranches,
} = useBranchesQuery(
{ projectRef },
{
refetchInterval(data) {
if (
data?.some(
(branch) =>
branch.status === 'CREATING_PROJECT' ||
branch.status === 'RUNNING_MIGRATIONS' ||
branch.status === 'MIGRATIONS_FAILED'
)
) {
return 1000 * 3 // 3 seconds
}

return false
},
}
)
} = useBranchesQuery({ projectRef })
const [[mainBranch], previewBranchesUnsorted] = partition(branches, (branch) => branch.is_default)
const previewBranches = previewBranchesUnsorted.sort((a, b) =>
new Date(a.updated_at) < new Date(b.updated_at) ? 1 : -1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,7 @@ const FunctionsList = ({
type="default"
disabled={!canCreateFunctions}
className="px-1 pointer-events-auto"
icon={
<AiIconAnimation className="scale-75 [&>div>div]:border-black dark:[&>div>div]:border-white" />
}
icon={<AiIconAnimation size={16} />}
onClick={() =>
setAiAssistantPanel({
open: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,7 @@ const TriggersList = ({
type="default"
disabled={!canCreateTriggers}
className="px-1 pointer-events-auto"
icon={
<AiIconAnimation className="scale-75 [&>div>div]:border-black dark:[&>div>div]:border-white" />
}
icon={<AiIconAnimation size={16} />}
onClick={() =>
setAiAssistantPanel({
open: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ import {
} from './CronJobs.utils'
import { CronJobScheduleSection } from './CronJobScheduleSection'
import { EdgeFunctionSection } from './EdgeFunctionSection'
import { HttpBodyFieldSection } from './HttpBodyFieldSection'
import { HTTPHeaderFieldsSection } from './HttpHeaderFieldsSection'
import { HTTPParameterFieldsSection } from './HttpParameterFieldsSection'
import { HttpRequestSection } from './HttpRequestSection'
import { SqlFunctionSection } from './SqlFunctionSection'
import { SqlSnippetSection } from './SqlSnippetSection'
Expand All @@ -63,7 +63,7 @@ const edgeFunctionSchema = z.object({
edgeFunctionName: z.string().trim().min(1, 'Please select one of the listed Edge Functions'),
timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000),
httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })),
httpParameters: z.array(z.object({ name: z.string(), value: z.string() })),
httpBody: z.string().trim(),
})

const httpRequestSchema = z.object({
Expand All @@ -77,7 +77,7 @@ const httpRequestSchema = z.object({
.refine((value) => value.startsWith('http'), 'Please include HTTP/HTTPs to your URL'),
timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000),
httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })),
httpParameters: z.array(z.object({ name: z.string(), value: z.string() })),
httpBody: z.string().trim(),
})

const sqlFunctionSchema = z.object({
Expand Down Expand Up @@ -187,15 +187,15 @@ export const CreateCronJobSheet = ({
values.method,
values.edgeFunctionName,
values.httpHeaders,
values.httpParameters,
values.httpBody,
values.timeoutMs
)
} else if (values.type === 'http_request') {
command = buildHttpRequestCommand(
values.method,
values.endpoint,
values.httpHeaders,
values.httpParameters,
values.httpBody,
values.timeoutMs
)
} else if (values.type === 'sql_function') {
Expand Down Expand Up @@ -367,7 +367,7 @@ export const CreateCronJobSheet = ({
<Separator />
<HTTPHeaderFieldsSection variant={cronType} />
<Separator />
<HTTPParameterFieldsSection variant={cronType} />
<HttpBodyFieldSection form={form} />
</>
)}
{cronType === 'edge_function' && (
Expand All @@ -376,7 +376,7 @@ export const CreateCronJobSheet = ({
<Separator />
<HTTPHeaderFieldsSection variant={cronType} />
<Separator />
<HTTPParameterFieldsSection variant={cronType} />
<HttpBodyFieldSection form={form} />
</>
)}
{cronType === 'sql_function' && <SqlFunctionSection form={form} />}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { toString as CronToString } from 'cronstrue'
import { Clock, Loader2, MoreVertical } from 'lucide-react'
import { Clock, History, Loader2, MoreVertical } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'

import { useParams } from 'common'
import { SQLCodeBlock } from 'components/interfaces/Auth/ThirdPartyAuthForm/SqlCodeBlock'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-query'
import { useDatabaseCronJobToggleMutation } from 'data/database-cron-jobs/database-cron-jobs-toggle-mutation'
import { useState } from 'react'
import {
Button,
DropdownMenu,
Expand Down Expand Up @@ -68,6 +68,11 @@ export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCard
checked={job.active}
onCheckedChange={() => showToggleConfirmationModal(true)}
/>
<Button type="default" icon={<History />}>
<Link href={`/project/${ref}/integrations/cron-jobs/cron-jobs/${job.jobname}`}>
History
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" icon={<MoreVertical />} className="px-1.5" />
Expand All @@ -76,11 +81,6 @@ export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCard
<DropdownMenuItem onClick={() => onEditCronJob(job)}>
Edit cron job
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/project/${ref}/integrations/cron-jobs/cron-jobs/${job.jobname}`}>
View previous runs
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDeleteCronJob(job)}>
Delete cron job
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,88 @@
import { it } from 'vitest'
import { parseCronJobCommand, secondsPattern, cronPattern } from './CronJobs.utils'
import { cronPattern, parseCronJobCommand, secondsPattern } from './CronJobs.utils'

describe('parseCronJobCommand', () => {
it('should return a default object when the command is null', () => {
expect(parseCronJobCommand('')).toStrictEqual({ snippet: '', type: 'sql_snippet' })
expect(parseCronJobCommand('')).toMatchObject({ snippet: '', type: 'sql_snippet' })
})

it('should return a default object when the command is random', () => {
const command = 'some random text'
expect(parseCronJobCommand(command)).toStrictEqual({
expect(parseCronJobCommand(command)).toMatchObject({
snippet: 'some random text',
type: 'sql_snippet',
})
})

it('should return a sql function command when the command is CALL auth.jwt ()', () => {
const command = 'CALL auth.jwt ()'
expect(parseCronJobCommand(command)).toStrictEqual({
expect(parseCronJobCommand(command)).toMatchObject({
type: 'sql_function',
schema: 'auth',
functionName: 'jwt',
})
})

it('should return a edge function config when the command posts to supabase.co', () => {
const command = `select net.http_post( url:='https://_.supabase.co/functions/v1/_', headers:=jsonb_build_object(), body:=jsonb_build_object(), timeout_milliseconds:=5000 );`
expect(parseCronJobCommand(command)).toStrictEqual({
const command = `select net.http_post( url:='https://_.supabase.co/functions/v1/_', headers:=jsonb_build_object(), body:='', timeout_milliseconds:=5000 );`
expect(parseCronJobCommand(command)).toMatchObject({
edgeFunctionName: 'https://_.supabase.co/functions/v1/_',
method: 'POST',
httpHeaders: [],
httpParameters: [],
httpBody: '',
timeoutMs: 5000,
type: 'edge_function',
})
})

it('should return a HTTP request config with POST method, empty headers and a body as string', () => {
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object(), body:='hello', timeout_milliseconds:=5000 );`
expect(parseCronJobCommand(command)).toMatchObject({
endpoint: 'https://example.com/api/endpoint',
method: 'POST',
httpHeaders: [],
httpBody: 'hello',
timeoutMs: 5000,
type: 'http_request',
})
})

it('should return a HTTP request config with POST method, some headers and empty body', () => {
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object('headerche', '2'), body:='', timeout_milliseconds:=1000 );`
expect(parseCronJobCommand(command)).toMatchObject({
endpoint: 'https://example.com/api/endpoint',
method: 'POST',
httpHeaders: [{ name: 'headerche', value: '2' }],
httpBody: '',
timeoutMs: 1000,
type: 'http_request',
})
})

it('should return a HTTP request config with GET method and empty body', () => {
const command = `select net.http_get( url:='https://example.com/api/endpoint', headers:=jsonb_build_object(), timeout_milliseconds:=5000 );`
expect(parseCronJobCommand(command)).toMatchObject({
endpoint: 'https://example.com/api/endpoint',
method: 'GET',
httpHeaders: [],
httpBody: '',
timeoutMs: 5000,
type: 'http_request',
})
})

it('should return a HTTP request config with POST method and a body as JSON object', () => {
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object(), body:='{"key": "value"}', timeout_milliseconds:=5000 );`
expect(parseCronJobCommand(command)).toMatchObject({
endpoint: 'https://example.com/api/endpoint',
method: 'POST',
httpHeaders: [],
httpBody: '{"key": "value"}',
timeoutMs: 5000,
type: 'http_request',
})
})

// Array of test cases for secondsPattern
const secondsPatternTests = [
{ description: '10 seconds', command: '10 seconds' },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CronJobType } from './CreateCronJobSheet'
import { HTTPHeader, HTTPParameter } from './CronJobs.constants'
import { HTTPHeader } from './CronJobs.constants'

export const buildCronQuery = (name: string, schedule: string, command: string) => {
return `select cron.schedule('${name}','${schedule}',${command});`
Expand All @@ -9,7 +9,7 @@ export const buildHttpRequestCommand = (
method: 'GET' | 'POST',
url: string,
headers: HTTPHeader[],
body: HTTPParameter[],
body: string,
timeout: number
) => {
return `$$
Expand All @@ -20,10 +20,7 @@ export const buildHttpRequestCommand = (
.filter((v) => v.name && v.value)
.map((v) => `'${v.name}', '${v.value}'`)
.join(', ')}),
body:=jsonb_build_object(${body
.filter((v) => v.name && v.value)
.map((v) => `'${v.name}', '${v.value}'`)
.join(', ')}),
${method === 'POST' ? `body:='${body}',` : ''}
timeout_milliseconds:=${timeout}
);
$$`
Expand All @@ -32,6 +29,10 @@ export const buildHttpRequestCommand = (
const DEFAULT_CRONJOB_COMMAND = {
type: 'sql_snippet',
snippet: '',
// add default values for the other command types. Even though they don't exist in sql_snippet, they'll still work as default values.
method: 'POST',
timeoutMs: 1000,
httpBody: '',
} as const

export const parseCronJobCommand = (originalCommand: string): CronJobType => {
Expand All @@ -40,12 +41,21 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => {
.replaceAll(/\n/g, ' ')
.replaceAll(/\s+/g, ' ')
.trim()

if (command.toLocaleLowerCase().startsWith('select net.')) {
const matches =
let matches =
command.match(
/select net\.([^']+)\(\s*url:='([^']+)',\s*headers:=jsonb_build_object\(([^)]*)\),\s*body:=jsonb_build_object\(([^]*)\s*\),\s*timeout_milliseconds:=(\d+) \)/i
/select net\.([^']+)\(\s*url:='([^']+)',\s*headers:=jsonb_build_object\(([^)]*)\),(?:\s*body:='(.*)',)?\s*timeout_milliseconds:=(\d+) \)/i
) || []

// if the match has been unsuccesful, the cron may be created with the previous encoding/parsing.
if (matches.length === 0) {
matches =
command.match(
/select net\.([^']+)\(\s*url:='([^']+)',\s*headers:=jsonb_build_object\(([^)]*)\),\s*body:=jsonb_build_object\(([^]*)\s*\),\s*timeout_milliseconds:=(\d+) \)/i
) || []
}

// convert the header string to array of objects, clean up the values, trim them of spaces and remove the quotation marks at start and end
const headers = (matches[3] || '').split(',').map((s) => s.trim().replace(/^'|'$/g, ''))
const headersObjs: { name: string; value: string }[] = []
Expand All @@ -55,24 +65,16 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => {
}
}

// convert the parameter string to array of objects, clean up the values, trim them of spaces and remove the quotation marks at start and end
const parameters = (matches[4] || '').split(',').map((s) => s.trim().replace(/^'|'$/g, ''))
const parametersObjs: { name: string; value: string }[] = []
for (let i = 0; i < parameters.length; i += 2) {
if (parameters[i] && parameters[i].length > 0) {
parametersObjs.push({ name: parameters[i], value: parameters[i + 1] })
}
}

const url = matches[2] || ''
const body = matches[4] || ''

if (url.includes('.supabase.') && url.includes('/functions/v1/')) {
return {
type: 'edge_function',
method: matches[1] === 'http_get' ? 'GET' : 'POST',
edgeFunctionName: url,
httpHeaders: headersObjs,
httpParameters: parametersObjs,
httpBody: body,
timeoutMs: +matches[5] ?? 1000,
}
}
Expand All @@ -82,7 +84,7 @@ export const parseCronJobCommand = (originalCommand: string): CronJobType => {
method: matches[1] === 'http_get' ? 'GET' : 'POST',
endpoint: url,
httpHeaders: headersObjs,
httpParameters: parametersObjs,
httpBody: body,
timeoutMs: +matches[5] ?? 1000,
}
}
Expand Down
Loading
Loading