CronJobTableCell.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. import parser from 'cron-parser'
  2. import dayjs from 'dayjs'
  3. import { Copy, Edit, Minus, MoreVertical, Play, Trash } from 'lucide-react'
  4. import { parseAsString, useQueryState } from 'nuqs'
  5. import { useState } from 'react'
  6. import { toast } from 'sonner'
  7. import {
  8. Badge,
  9. Button,
  10. cn,
  11. ContextMenu,
  12. ContextMenuContent,
  13. ContextMenuItem,
  14. ContextMenuSeparator,
  15. ContextMenuTrigger,
  16. copyToClipboard,
  17. Dialog,
  18. DialogContent,
  19. DialogFooter,
  20. DialogHeader,
  21. DialogSection,
  22. DialogSectionSeparator,
  23. DialogTitle,
  24. DialogTrigger,
  25. DropdownMenu,
  26. DropdownMenuContent,
  27. DropdownMenuItem,
  28. DropdownMenuSeparator,
  29. DropdownMenuTrigger,
  30. HoverCard,
  31. HoverCardContent,
  32. HoverCardTrigger,
  33. Switch,
  34. Tooltip,
  35. TooltipContent,
  36. TooltipTrigger,
  37. } from 'ui'
  38. import { TimestampInfo } from 'ui-patterns'
  39. import { CodeBlock } from 'ui-patterns/CodeBlock'
  40. import { useDatabaseCronJobRunCommandMutation } from '@/data/database-cron-jobs/database-cron-job-run-mutation'
  41. import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query'
  42. import { useDatabaseCronJobToggleMutation } from '@/data/database-cron-jobs/database-cron-jobs-toggle-mutation'
  43. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  44. const getNextRun = (schedule: string, lastRun?: string) => {
  45. // cron-parser can only deal with the traditional cron syntax but technically users can also
  46. // use strings like "30 seconds" now, For the latter case, we try our best to parse the next run
  47. // (can't guarantee as scope is quite big)
  48. if (schedule.includes('*') || schedule.includes('$')) {
  49. try {
  50. // pg_cron uses '$' for "last day of month", but cron-parser uses 'L'
  51. // Convert pg_cron syntax to cron-parser syntax before parsing
  52. const normalizedSchedule = schedule.replace(/\$/g, 'L')
  53. const interval = parser.parseExpression(normalizedSchedule, { tz: 'UTC' })
  54. return interval.next().getTime()
  55. } catch (error) {
  56. return undefined
  57. }
  58. } else {
  59. // [Joshen] Only going to attempt to parse if the schedule is as simple as "n second" or "n seconds"
  60. // Returned undefined otherwise - we can revisit this perhaps if we get feedback about this
  61. const [value, unit] = schedule.toLocaleLowerCase().split(' ')
  62. if (
  63. ['second', 'seconds'].includes(unit) &&
  64. !Number.isNaN(Number(value)) &&
  65. lastRun !== undefined
  66. ) {
  67. const parsedLastRun = dayjs(lastRun).add(Number(value), unit as dayjs.ManipulateType)
  68. return parsedLastRun.valueOf()
  69. } else {
  70. return undefined
  71. }
  72. }
  73. }
  74. interface CronJobTableCellProps {
  75. col: any
  76. row: any
  77. onSelectEdit: (job: CronJob) => void
  78. onSelectDelete: (job: CronJob) => void
  79. }
  80. export const CronJobTableCell = ({
  81. col,
  82. row,
  83. onSelectEdit,
  84. onSelectDelete,
  85. }: CronJobTableCellProps) => {
  86. const { data: project } = useSelectedProjectQuery()
  87. const [searchQuery] = useQueryState('search', parseAsString.withDefault(''))
  88. const [showToggleModal, setShowToggleModal] = useState(false)
  89. const value = row?.[col.id]
  90. const { jobid, schedule, latest_run, status, active, jobname } = row
  91. const formattedValue =
  92. col.id === 'jobname' && !jobname
  93. ? 'No name provided'
  94. : col.id === 'lastest_run'
  95. ? !!value
  96. ? dayjs(value).valueOf()
  97. : undefined
  98. : col.id === 'next_run'
  99. ? getNextRun(schedule, latest_run)
  100. : value
  101. const hasValue = col.id === 'next_run' ? !!formattedValue : col.id in row
  102. const { mutate: runCronJob, isPending: isRunning } = useDatabaseCronJobRunCommandMutation({
  103. onSuccess: () => {
  104. toast.success(`Command from "${jobname}" ran successfully`)
  105. },
  106. })
  107. const { mutate: toggleDatabaseCronJob, isPending: isToggling } = useDatabaseCronJobToggleMutation(
  108. {
  109. onSuccess: (_, vars) => {
  110. toast.success(`Successfully ${vars.active ? 'enabled' : 'disabled'} "${jobname}"`)
  111. setShowToggleModal(false)
  112. },
  113. }
  114. )
  115. const onRunCronJob = () => {
  116. runCronJob({
  117. projectRef: project?.ref!,
  118. connectionString: project?.connectionString,
  119. jobId: jobid,
  120. })
  121. }
  122. const onConfirmToggle = () => {
  123. toggleDatabaseCronJob({
  124. projectRef: project?.ref!,
  125. connectionString: project?.connectionString,
  126. jobId: jobid,
  127. active: !active,
  128. searchTerm: searchQuery,
  129. })
  130. }
  131. if (col.id === 'actions') {
  132. return (
  133. <div className="flex items-center">
  134. <DropdownMenu>
  135. <DropdownMenuTrigger asChild>
  136. <Button
  137. type="text"
  138. loading={isRunning}
  139. className="h-6 w-6"
  140. icon={<MoreVertical />}
  141. onClick={(e) => e.stopPropagation()}
  142. />
  143. </DropdownMenuTrigger>
  144. <DropdownMenuContent align="end" className="w-44 space-y-1">
  145. <Tooltip>
  146. <TooltipTrigger className="w-full">
  147. <DropdownMenuItem
  148. className="gap-x-2"
  149. onClick={(e) => {
  150. e.stopPropagation()
  151. onRunCronJob()
  152. }}
  153. >
  154. <Play size={12} />
  155. Run command
  156. </DropdownMenuItem>
  157. </TooltipTrigger>
  158. <TooltipContent>
  159. Manual runs execute the command immediately and will not appear in the cron jobs
  160. table.
  161. </TooltipContent>
  162. </Tooltip>
  163. <DropdownMenuItem
  164. className="gap-x-2"
  165. onClick={(e) => {
  166. e.stopPropagation()
  167. onSelectEdit(row)
  168. }}
  169. >
  170. <Edit size={12} />
  171. Edit job
  172. </DropdownMenuItem>
  173. <DropdownMenuSeparator />
  174. <DropdownMenuItem
  175. className="gap-x-2"
  176. onClick={(e) => {
  177. e.stopPropagation()
  178. onSelectDelete(row)
  179. }}
  180. >
  181. <Trash size={12} />
  182. Delete job
  183. </DropdownMenuItem>
  184. </DropdownMenuContent>
  185. </DropdownMenu>
  186. </div>
  187. )
  188. }
  189. if (col.id === 'active') {
  190. return (
  191. <Dialog open={showToggleModal} onOpenChange={setShowToggleModal}>
  192. <DialogTrigger className="flex items-center" onClick={(e) => e.stopPropagation()}>
  193. <Switch
  194. id={`cron-job-active-${jobid}`}
  195. size="medium"
  196. disabled={isToggling}
  197. checked={active}
  198. />
  199. </DialogTrigger>
  200. <DialogContent
  201. onClick={(e) => e.stopPropagation()}
  202. dialogOverlayProps={{ onClick: (e) => e.stopPropagation() }}
  203. >
  204. <DialogHeader>
  205. <DialogTitle>{active ? 'Disable' : 'Enable'} cron job</DialogTitle>
  206. </DialogHeader>
  207. <DialogSectionSeparator />
  208. <DialogSection>
  209. <p className="text-sm">
  210. Are you sure you want to {active ? 'disable' : 'enable'} the cron job "{jobname}
  211. "?{' '}
  212. </p>
  213. </DialogSection>
  214. <DialogFooter>
  215. <Button type="default" onClick={() => setShowToggleModal(false)}>
  216. Cancel
  217. </Button>
  218. <Button
  219. type={active ? 'warning' : 'primary'}
  220. loading={isToggling}
  221. onClick={onConfirmToggle}
  222. >
  223. {active ? 'Disable' : 'Enable'}
  224. </Button>
  225. </DialogFooter>
  226. </DialogContent>
  227. </Dialog>
  228. )
  229. }
  230. return (
  231. <ContextMenu>
  232. <ContextMenuTrigger asChild>
  233. <div className={cn('w-full flex items-center text-xs')}>
  234. {['latest_run', 'next_run'].includes(col.id) ? (
  235. !hasValue ? (
  236. <Minus size={14} className="text-foreground-lighter" />
  237. ) : col.id === 'latest_run' && formattedValue === null ? (
  238. <p className="text-foreground-lighter">Job has not been run yet</p>
  239. ) : col.id === 'next_run' && !formattedValue ? (
  240. <p className="text-foreground-lighter">Unable to parse next run for job</p>
  241. ) : (
  242. <TimestampInfo
  243. utcTimestamp={formattedValue}
  244. labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
  245. className="font-sans text-xs"
  246. />
  247. )
  248. ) : col.id === 'command' ? (
  249. <HoverCard openDelay={0} closeDelay={0}>
  250. <HoverCardTrigger asChild>
  251. <div className="text-xs font-mono w-full h-full flex items-center">
  252. {formattedValue}
  253. </div>
  254. </HoverCardTrigger>
  255. <HoverCardContent
  256. align="end"
  257. className="p-0 w-[400px]"
  258. onClick={(e) => e.stopPropagation()}
  259. >
  260. <p className="text-xs font-mono px-2 py-1 border-b">Command</p>
  261. <CodeBlock
  262. hideLineNumbers
  263. language="sql"
  264. value={formattedValue.trim()}
  265. className={cn(
  266. 'py-0 px-3.5 max-w-full prose dark:prose-dark border-0 rounded-t-none',
  267. '[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap min-h-11',
  268. '[&>code]:text-xs'
  269. )}
  270. />
  271. </HoverCardContent>
  272. </HoverCard>
  273. ) : (
  274. <p
  275. className={cn(
  276. col.id === 'jobname' && !jobname && 'text-foreground-lighter',
  277. col.id === 'command' && 'font-mono'
  278. )}
  279. >
  280. {formattedValue}
  281. </p>
  282. )}
  283. {col.id === 'latest_run' && !!status && (
  284. <Badge
  285. variant={status === 'failed' ? 'destructive' : 'success'}
  286. className="capitalize ml-2"
  287. >
  288. {status}
  289. </Badge>
  290. )}
  291. </div>
  292. </ContextMenuTrigger>
  293. <ContextMenuContent onClick={(e) => e.stopPropagation()}>
  294. <ContextMenuItem
  295. className="gap-x-2"
  296. onFocusCapture={(e) => e.stopPropagation()}
  297. onSelect={() => copyToClipboard(formattedValue)}
  298. >
  299. <Copy size={12} />
  300. <span>Copy {col.name.toLowerCase()}</span>
  301. </ContextMenuItem>
  302. <ContextMenuItem
  303. disabled={!jobname}
  304. onFocusCapture={(e) => e.stopPropagation()}
  305. onSelect={() => onSelectEdit(row)}
  306. >
  307. <Tooltip>
  308. <TooltipTrigger asChild>
  309. <div className="flex items-center gap-x-2 w-full">
  310. <Edit size={12} />
  311. <span>Edit job</span>
  312. </div>
  313. </TooltipTrigger>
  314. {!jobname && (
  315. <TooltipContent side="right" className="w-56">
  316. This cron job doesn’t have a name and can’t be edited. Create a new one and delete
  317. this job.
  318. </TooltipContent>
  319. )}
  320. </Tooltip>
  321. </ContextMenuItem>
  322. <ContextMenuSeparator />
  323. <ContextMenuItem
  324. className="gap-x-2"
  325. onFocusCapture={(e) => e.stopPropagation()}
  326. onSelect={() => onSelectDelete(row)}
  327. >
  328. <Trash size={12} />
  329. <span>Delete job</span>
  330. </ContextMenuItem>
  331. </ContextMenuContent>
  332. </ContextMenu>
  333. )
  334. }