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