CronJobs.utils.tsx 8.2 KB

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