CronJobs.utils.tsx271 lines · main
1import { keyword, literal, safeSql, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format'
2import { toString as CronToString } from 'cronstrue'
3import { Column } from 'react-data-grid'
4import { cn } from 'ui'
5
6import { CronJobType } from './CreateCronJobSheet/CreateCronJobSheet.constants'
7import { CRON_TABLE_COLUMNS, HTTPHeader, secondsPattern } from './CronJobs.constants'
8import { CronJobTableCell } from './CronJobTableCell'
9import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query'
10
11const unescapeSqlLiteral = (value = '', isEscapeString = false) => {
12 const unescaped = value.replaceAll("''", "'")
13 return isEscapeString ? unescaped.replaceAll('\\\\', '\\') : unescaped
14}
15
16export function buildCronQuery(name: string, schedule: string, command: string): SafeSqlFragment {
17 return safeSql`select cron.schedule(${literal(name)}, ${literal(schedule)}, ${literal(command)});`
18}
19
20export const buildHttpRequestCommand = (
21 method: 'GET' | 'POST',
22 url: string,
23 headers: HTTPHeader[] = [],
24 body: string | undefined,
25 timeout: number
26): SafeSqlFragment => {
27 const funcName = keyword(method === 'GET' ? 'http_get' : 'http_post')
28 const headersJson = JSON.stringify(
29 Object.fromEntries(headers.filter((v) => v.name && v.value).map((v) => [v.name, v.value]))
30 )
31 const bodyPart = method === 'POST' && body ? safeSql`\n body:=${literal(body)},` : safeSql``
32 return safeSql`
33select
34 net.${funcName}(
35 url:=${literal(url)},
36 headers:=${literal(headersJson)}::jsonb, ${bodyPart}
37 timeout_milliseconds:=${literal(timeout)}
38 );`
39}
40
41const DEFAULT_CRONJOB_COMMAND = {
42 type: 'sql_snippet',
43 snippet: '',
44 // add default values for the other command types. Even though they don't exist in sql_snippet, they'll still work as default values.
45 method: 'POST',
46 timeoutMs: 1000,
47 httpBody: '',
48} as const
49
50export const parseCronJobCommand = (originalCommand: string, projectRef: string): CronJobType => {
51 const command = originalCommand.replaceAll('$$', ' ').replaceAll(/\n/g, ' ').trim()
52
53 if (command.toLocaleLowerCase().match(/^select\s+net\./)) {
54 const methodMatch = command.match(/select\s+net\.([^']+)\(\s*url:=/i)
55 const method = methodMatch?.[1] || ''
56
57 const urlMatch = command.match(/url:=(E)?'((?:''|[^'])*)'/i)
58 const url = unescapeSqlLiteral(urlMatch?.[2], Boolean(urlMatch?.[1]))
59
60 const bodyMatch = command.match(/body:=(E)?'((?:''|[^'])*)'/i)
61 const body = unescapeSqlLiteral(bodyMatch?.[2], Boolean(bodyMatch?.[1]))
62
63 const timeoutMatch = command.match(/timeout_milliseconds:=(\d+)/i)
64 const timeout = timeoutMatch?.[1] || ''
65
66 const headersJsonBuildObjectMatch = command.match(/headers:=jsonb_build_object\(([^)]*)/i)
67 const headersJsonBuildObject = headersJsonBuildObjectMatch?.[1] || ''
68
69 let headersObjs: { name: string; value: string }[] = []
70 if (headersJsonBuildObject) {
71 const headers = headersJsonBuildObject
72 .split(',')
73 .map((s) => unescapeSqlLiteral(s.trim().replace(/^'|'$/g, '')))
74
75 for (let i = 0; i < headers.length; i += 2) {
76 if (headers[i] && headers[i].length > 0) {
77 headersObjs.push({ name: headers[i], value: headers[i + 1] })
78 }
79 }
80 } else {
81 const headersStringMatch = command.match(/headers:=(E)?'((?:''|[^'])*)'/i)
82 const headersString =
83 unescapeSqlLiteral(headersStringMatch?.[2], Boolean(headersStringMatch?.[1])) || '{}'
84 try {
85 const parsedHeaders = JSON.parse(headersString)
86 headersObjs = Object.entries(parsedHeaders).map(([name, value]) => ({
87 name,
88 value: value as string,
89 }))
90 } catch (error) {
91 console.error('Error parsing headers:', error)
92 }
93 }
94
95 // If there's a search param or hash in the edge function URL, let it be handled by the HTTP Request case.
96 // Otherwise, the params/hash may be lost during editing of the cron job.
97 let searchParams = ''
98 let urlHash = ''
99 try {
100 const urlObject = new URL(url)
101 searchParams = urlObject.search
102 urlHash = urlObject.hash
103 } catch {}
104
105 if (
106 url.includes(`${projectRef}.briven.`) &&
107 url.includes('/functions/v1/') &&
108 searchParams.length === 0 &&
109 urlHash.length === 0
110 ) {
111 return {
112 type: 'edge_function',
113 method: method === 'http_get' ? 'GET' : 'POST',
114 edgeFunctionName: url,
115 httpHeaders: headersObjs,
116 httpBody: body,
117 timeoutMs: Number(timeout ?? 1000),
118 snippet: originalCommand,
119 }
120 }
121
122 if (url !== '') {
123 return {
124 type: 'http_request',
125 method: method === 'http_get' ? 'GET' : 'POST',
126 endpoint: url,
127 httpHeaders: headersObjs,
128 httpBody: body,
129 timeoutMs: Number(timeout ?? 1000),
130 snippet: originalCommand,
131 }
132 }
133 }
134
135 const regexDBFunction = /select\s+[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+\s*\(\)/g
136 if (command.toLocaleLowerCase().match(regexDBFunction)) {
137 const [schemaName, functionName] = command
138 .replace(/^select\s+/i, '')
139 .replace(/\(.*\);*/, '')
140
141 .trim()
142 .split('.')
143
144 return {
145 type: 'sql_function',
146 schema: schemaName,
147 functionName: functionName,
148 snippet: originalCommand,
149 }
150 }
151
152 if (command.length > 0) {
153 return {
154 type: 'sql_snippet',
155 snippet: originalCommand,
156 }
157 }
158
159 return DEFAULT_CRONJOB_COMMAND
160}
161
162export function calculateDuration(start: string, end: string): string {
163 const startTime = new Date(start).getTime()
164 const endTime = new Date(end).getTime()
165 const duration = endTime - startTime
166
167 if (isNaN(duration)) return 'Invalid Date'
168
169 if (duration < 1000) return `${duration}ms`
170 if (duration < 60000) return `${(duration / 1000).toFixed(1)}s`
171 return `${(duration / 60000).toFixed(1)}m`
172}
173
174export function formatDate(dateString: string): string {
175 const date = new Date(dateString)
176 if (isNaN(date.getTime())) {
177 return 'Invalid Date'
178 }
179 const options: Intl.DateTimeFormatOptions = {
180 year: 'numeric',
181 month: 'short', // Use 'long' for full month name
182 day: '2-digit',
183 hour: '2-digit',
184 minute: '2-digit',
185 second: '2-digit',
186 hour12: false, // Use 12-hour format if preferred
187 timeZoneName: 'short', // Optional: to include timezone
188 }
189 return date.toLocaleString(undefined, options)
190}
191
192export function isSecondsFormat(schedule: string): boolean {
193 return secondsPattern.test(schedule.trim().toLocaleLowerCase())
194}
195
196export function getScheduleMessage(scheduleString: string) {
197 if (!scheduleString) {
198 return 'Enter a valid cron expression above'
199 }
200
201 // if the schedule is in seconds format, scheduleString is same as the schedule
202 if (secondsPattern.test(scheduleString)) {
203 return `The cron will run every ${scheduleString}`
204 }
205
206 if (scheduleString.includes('Invalid cron expression')) {
207 return scheduleString
208 }
209
210 const readableSchedule = scheduleString
211 .split(' ')
212 .map((s, i) => (i === 0 ? s.toLowerCase() : s))
213 .join(' ')
214
215 return `The cron will run ${readableSchedule}.`
216}
217
218export const formatScheduleString = (value: string) => {
219 try {
220 if (secondsPattern.test(value)) {
221 return value
222 } else {
223 return CronToString(value)
224 }
225 } catch (error) {
226 return ''
227 }
228}
229
230export const formatCronJobColumns = ({
231 onSelectEdit,
232 onSelectDelete,
233}: {
234 onSelectEdit: (job: CronJob) => void
235 onSelectDelete: (job: CronJob) => void
236}): Array<Column<CronJob>> => {
237 return CRON_TABLE_COLUMNS.map((col) => {
238 const res: Column<CronJob> = {
239 key: col.id,
240 name: col.name,
241 minWidth: col.minWidth ?? 100,
242 maxWidth: col.maxWidth,
243 width: col.width,
244 resizable: col.resizable ?? false,
245 sortable: false,
246 draggable: false,
247 headerCellClass: undefined,
248 renderHeaderCell: () => {
249 return (
250 <div
251 className={cn(
252 'flex items-center justify-between font-normal text-xs w-full',
253 col.id === 'jobname' && 'ml-8'
254 )}
255 >
256 <p className="text-foreground!">{col.name}</p>
257 </div>
258 )
259 },
260 renderCell: ({ row }) => (
261 <CronJobTableCell
262 row={row}
263 col={col}
264 onSelectEdit={onSelectEdit}
265 onSelectDelete={onSelectDelete}
266 />
267 ),
268 }
269 return res
270 })
271}