CronJobs.utils.tsx271 lines · main
| 1 | import { keyword, literal, safeSql, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' |
| 2 | import { toString as CronToString } from 'cronstrue' |
| 3 | import { Column } from 'react-data-grid' |
| 4 | import { cn } from 'ui' |
| 5 | |
| 6 | import { CronJobType } from './CreateCronJobSheet/CreateCronJobSheet.constants' |
| 7 | import { CRON_TABLE_COLUMNS, HTTPHeader, secondsPattern } from './CronJobs.constants' |
| 8 | import { CronJobTableCell } from './CronJobTableCell' |
| 9 | import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query' |
| 10 | |
| 11 | const unescapeSqlLiteral = (value = '', isEscapeString = false) => { |
| 12 | const unescaped = value.replaceAll("''", "'") |
| 13 | return isEscapeString ? unescaped.replaceAll('\\\\', '\\') : unescaped |
| 14 | } |
| 15 | |
| 16 | export function buildCronQuery(name: string, schedule: string, command: string): SafeSqlFragment { |
| 17 | return safeSql`select cron.schedule(${literal(name)}, ${literal(schedule)}, ${literal(command)});` |
| 18 | } |
| 19 | |
| 20 | export 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` |
| 33 | select |
| 34 | net.${funcName}( |
| 35 | url:=${literal(url)}, |
| 36 | headers:=${literal(headersJson)}::jsonb, ${bodyPart} |
| 37 | timeout_milliseconds:=${literal(timeout)} |
| 38 | );` |
| 39 | } |
| 40 | |
| 41 | const 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 | |
| 50 | export 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 | |
| 162 | export 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 | |
| 174 | export 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 | |
| 192 | export function isSecondsFormat(schedule: string): boolean { |
| 193 | return secondsPattern.test(schedule.trim().toLocaleLowerCase()) |
| 194 | } |
| 195 | |
| 196 | export 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 | |
| 218 | export 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 | |
| 230 | export 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 | } |