| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352 |
- import parser from 'cron-parser'
- import dayjs from 'dayjs'
- import { Copy, Edit, Minus, MoreVertical, Play, Trash } from 'lucide-react'
- import { parseAsString, useQueryState } from 'nuqs'
- import { useState } from 'react'
- import { toast } from 'sonner'
- import {
- Badge,
- Button,
- cn,
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
- copyToClipboard,
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogSection,
- DialogSectionSeparator,
- DialogTitle,
- DialogTrigger,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
- HoverCard,
- HoverCardContent,
- HoverCardTrigger,
- Switch,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- } from 'ui'
- import { TimestampInfo } from 'ui-patterns'
- import { CodeBlock } from 'ui-patterns/CodeBlock'
- import { useDatabaseCronJobRunCommandMutation } from '@/data/database-cron-jobs/database-cron-job-run-mutation'
- import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query'
- import { useDatabaseCronJobToggleMutation } from '@/data/database-cron-jobs/database-cron-jobs-toggle-mutation'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- const getNextRun = (schedule: string, lastRun?: string) => {
- // cron-parser can only deal with the traditional cron syntax but technically users can also
- // use strings like "30 seconds" now, For the latter case, we try our best to parse the next run
- // (can't guarantee as scope is quite big)
- if (schedule.includes('*') || schedule.includes('$')) {
- try {
- // pg_cron uses '$' for "last day of month", but cron-parser uses 'L'
- // Convert pg_cron syntax to cron-parser syntax before parsing
- const normalizedSchedule = schedule.replace(/\$/g, 'L')
- const interval = parser.parseExpression(normalizedSchedule, { tz: 'UTC' })
- return interval.next().getTime()
- } catch (error) {
- return undefined
- }
- } else {
- // [Joshen] Only going to attempt to parse if the schedule is as simple as "n second" or "n seconds"
- // Returned undefined otherwise - we can revisit this perhaps if we get feedback about this
- const [value, unit] = schedule.toLocaleLowerCase().split(' ')
- if (
- ['second', 'seconds'].includes(unit) &&
- !Number.isNaN(Number(value)) &&
- lastRun !== undefined
- ) {
- const parsedLastRun = dayjs(lastRun).add(Number(value), unit as dayjs.ManipulateType)
- return parsedLastRun.valueOf()
- } else {
- return undefined
- }
- }
- }
- interface CronJobTableCellProps {
- col: any
- row: any
- onSelectEdit: (job: CronJob) => void
- onSelectDelete: (job: CronJob) => void
- }
- export const CronJobTableCell = ({
- col,
- row,
- onSelectEdit,
- onSelectDelete,
- }: CronJobTableCellProps) => {
- const { data: project } = useSelectedProjectQuery()
- const [searchQuery] = useQueryState('search', parseAsString.withDefault(''))
- const [showToggleModal, setShowToggleModal] = useState(false)
- const value = row?.[col.id]
- const { jobid, schedule, latest_run, status, active, jobname } = row
- const formattedValue =
- col.id === 'jobname' && !jobname
- ? 'No name provided'
- : col.id === 'lastest_run'
- ? !!value
- ? dayjs(value).valueOf()
- : undefined
- : col.id === 'next_run'
- ? getNextRun(schedule, latest_run)
- : value
- const hasValue = col.id === 'next_run' ? !!formattedValue : col.id in row
- const { mutate: runCronJob, isPending: isRunning } = useDatabaseCronJobRunCommandMutation({
- onSuccess: () => {
- toast.success(`Command from "${jobname}" ran successfully`)
- },
- })
- const { mutate: toggleDatabaseCronJob, isPending: isToggling } = useDatabaseCronJobToggleMutation(
- {
- onSuccess: (_, vars) => {
- toast.success(`Successfully ${vars.active ? 'enabled' : 'disabled'} "${jobname}"`)
- setShowToggleModal(false)
- },
- }
- )
- const onRunCronJob = () => {
- runCronJob({
- projectRef: project?.ref!,
- connectionString: project?.connectionString,
- jobId: jobid,
- })
- }
- const onConfirmToggle = () => {
- toggleDatabaseCronJob({
- projectRef: project?.ref!,
- connectionString: project?.connectionString,
- jobId: jobid,
- active: !active,
- searchTerm: searchQuery,
- })
- }
- if (col.id === 'actions') {
- return (
- <div className="flex items-center">
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- type="text"
- loading={isRunning}
- className="h-6 w-6"
- icon={<MoreVertical />}
- onClick={(e) => e.stopPropagation()}
- />
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-44 space-y-1">
- <Tooltip>
- <TooltipTrigger className="w-full">
- <DropdownMenuItem
- className="gap-x-2"
- onClick={(e) => {
- e.stopPropagation()
- onRunCronJob()
- }}
- >
- <Play size={12} />
- Run command
- </DropdownMenuItem>
- </TooltipTrigger>
- <TooltipContent>
- Manual runs execute the command immediately and will not appear in the cron jobs
- table.
- </TooltipContent>
- </Tooltip>
- <DropdownMenuItem
- className="gap-x-2"
- onClick={(e) => {
- e.stopPropagation()
- onSelectEdit(row)
- }}
- >
- <Edit size={12} />
- Edit job
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- className="gap-x-2"
- onClick={(e) => {
- e.stopPropagation()
- onSelectDelete(row)
- }}
- >
- <Trash size={12} />
- Delete job
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- )
- }
- if (col.id === 'active') {
- return (
- <Dialog open={showToggleModal} onOpenChange={setShowToggleModal}>
- <DialogTrigger className="flex items-center" onClick={(e) => e.stopPropagation()}>
- <Switch
- id={`cron-job-active-${jobid}`}
- size="medium"
- disabled={isToggling}
- checked={active}
- />
- </DialogTrigger>
- <DialogContent
- onClick={(e) => e.stopPropagation()}
- dialogOverlayProps={{ onClick: (e) => e.stopPropagation() }}
- >
- <DialogHeader>
- <DialogTitle>{active ? 'Disable' : 'Enable'} cron job</DialogTitle>
- </DialogHeader>
- <DialogSectionSeparator />
- <DialogSection>
- <p className="text-sm">
- Are you sure you want to {active ? 'disable' : 'enable'} the cron job "{jobname}
- "?{' '}
- </p>
- </DialogSection>
- <DialogFooter>
- <Button type="default" onClick={() => setShowToggleModal(false)}>
- Cancel
- </Button>
- <Button
- type={active ? 'warning' : 'primary'}
- loading={isToggling}
- onClick={onConfirmToggle}
- >
- {active ? 'Disable' : 'Enable'}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
- return (
- <ContextMenu>
- <ContextMenuTrigger asChild>
- <div className={cn('w-full flex items-center text-xs')}>
- {['latest_run', 'next_run'].includes(col.id) ? (
- !hasValue ? (
- <Minus size={14} className="text-foreground-lighter" />
- ) : col.id === 'latest_run' && formattedValue === null ? (
- <p className="text-foreground-lighter">Job has not been run yet</p>
- ) : col.id === 'next_run' && !formattedValue ? (
- <p className="text-foreground-lighter">Unable to parse next run for job</p>
- ) : (
- <TimestampInfo
- utcTimestamp={formattedValue}
- labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
- className="font-sans text-xs"
- />
- )
- ) : col.id === 'command' ? (
- <HoverCard openDelay={0} closeDelay={0}>
- <HoverCardTrigger asChild>
- <div className="text-xs font-mono w-full h-full flex items-center">
- {formattedValue}
- </div>
- </HoverCardTrigger>
- <HoverCardContent
- align="end"
- className="p-0 w-[400px]"
- onClick={(e) => e.stopPropagation()}
- >
- <p className="text-xs font-mono px-2 py-1 border-b">Command</p>
- <CodeBlock
- hideLineNumbers
- language="sql"
- value={formattedValue.trim()}
- className={cn(
- 'py-0 px-3.5 max-w-full prose dark:prose-dark border-0 rounded-t-none',
- '[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap min-h-11',
- '[&>code]:text-xs'
- )}
- />
- </HoverCardContent>
- </HoverCard>
- ) : (
- <p
- className={cn(
- col.id === 'jobname' && !jobname && 'text-foreground-lighter',
- col.id === 'command' && 'font-mono'
- )}
- >
- {formattedValue}
- </p>
- )}
- {col.id === 'latest_run' && !!status && (
- <Badge
- variant={status === 'failed' ? 'destructive' : 'success'}
- className="capitalize ml-2"
- >
- {status}
- </Badge>
- )}
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent onClick={(e) => e.stopPropagation()}>
- <ContextMenuItem
- className="gap-x-2"
- onFocusCapture={(e) => e.stopPropagation()}
- onSelect={() => copyToClipboard(formattedValue)}
- >
- <Copy size={12} />
- <span>Copy {col.name.toLowerCase()}</span>
- </ContextMenuItem>
- <ContextMenuItem
- disabled={!jobname}
- onFocusCapture={(e) => e.stopPropagation()}
- onSelect={() => onSelectEdit(row)}
- >
- <Tooltip>
- <TooltipTrigger asChild>
- <div className="flex items-center gap-x-2 w-full">
- <Edit size={12} />
- <span>Edit job</span>
- </div>
- </TooltipTrigger>
- {!jobname && (
- <TooltipContent side="right" className="w-56">
- This cron job doesn’t have a name and can’t be edited. Create a new one and delete
- this job.
- </TooltipContent>
- )}
- </Tooltip>
- </ContextMenuItem>
- <ContextMenuSeparator />
- <ContextMenuItem
- className="gap-x-2"
- onFocusCapture={(e) => e.stopPropagation()}
- onSelect={() => onSelectDelete(row)}
- >
- <Trash size={12} />
- <span>Delete job</span>
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- )
- }
|